diff --git a/.github/MERGE_QUEUE.md b/.github/MERGE_QUEUE.md new file mode 100644 index 0000000000..96c98ab86a --- /dev/null +++ b/.github/MERGE_QUEUE.md @@ -0,0 +1,131 @@ +# Merge Queue — Setup & Process + +This document describes the GitHub repository settings required to enable Merge Queue on `master`, and the engineering process for protected-path changes (new/modified products, platform config). + +--- + +## Required Repository Settings Checklist + +### 1. General + +- [ ] **Settings → General → Allow auto-merge** — enable so PRs can be set to auto-merge and enter the queue automatically once approved. + +### 2. Branch Protection / Ruleset on `master` + +Navigate to **Settings → Branches → Add rule** (classic branch protection) or **Settings → Rules → Rulesets** (newer UI): + +- [ ] **Require merge queue** — enables the GitHub Merge Queue. PRs cannot be merged directly; they must enter the queue. +- [ ] **Require status checks to pass before merging** — add ALL of the following required checks: + - `scope-gate` (from `pr-gate.yml`) + - `check-product` (from `pr-gate.yml`) + - `test` (from `pr-gate.yml`) + - `build-matrix` (6× platform matrix, from `pr-gate.yml`) + - `build` (from `build.yml`) +- [ ] **Require branches to be up to date before merging** — the merge queue handles this automatically, but must be enabled. +- [ ] **Include administrators** — prevents bypass by repo admins. + +> **Why `merge_group` trigger matters:** Every workflow listed as a required check must trigger on the `merge_group` event. Without it, the checks remain in "Pending" state inside the queue and PRs can never be merged. Both `pr-gate.yml` and `build.yml` now include `merge_group:` in their `on:` triggers. + +--- + +## Merge Queue Availability Caveat + +GitHub Merge Queue requires one of: + +- **Public repository** on any plan (free tier included). +- **GitHub Enterprise Cloud (GHEC)** for private repositories. + +If Merge Queue is unavailable for this repo's plan/visibility, use the fallback: + +- Enable **"Require branches to be up to date"** + all required status checks (same list above). +- Merge PRs serially — only merge one at a time, waiting for CI to pass on each before merging the next. +- This avoids broken `master` from parallel merges but reduces velocity. + +--- + +## New / Enable / Disable a Product — Platform-Serial PR Process + +Changes to `products.yaml` and the regenerated artifacts (`cmd/products.gen.go`, golden test fixtures, generated docs) are **protected paths** and follow a separate, stricter process: + +### Why a separate process? + +`products.yaml` is the source of truth for all product scaffolding. Changes to it affect: + +- `cmd/products.gen.go` — regenerated via `go run ./hack/gen-products` +- Golden test fixtures under `products//testdata/` +- Any auto-generated documentation + +Hand-editing regenerated files in a product PR will cause the `check-product` gate to fail. + +### Rules + +1. **Product-local PRs** (feature work inside `products//`) MUST NOT touch: + - `products.yaml` + - `cmd/products.gen.go` + - `hack/` + - `.github/workflows/` + - `go.mod` / `go.sum` + - `.goreleaser.yaml` / `.svu.yaml` + + The `scope-gate` job enforces this automatically. + +2. **Platform-serial PR** (one at a time, through platform review) is required for: + - Adding a new product entry to `products.yaml` + - Enabling or disabling an existing product + - Any change to `hack/`, `go.mod`, or CI workflows + +3. **Regeneration is mandatory** — after editing `products.yaml`, run: + ```sh + go run ./hack/gen-products + ``` + Commit both `products.yaml` and all regenerated artifacts in the same platform PR. + +4. **No hand-edits to generated files** — `cmd/products.gen.go` and golden fixtures are outputs, not inputs. The `check-product` job verifies this and will fail if they drift from `products.yaml`. + +--- + +## `release.yml` Manual Approval Gate + +The `release.yml` workflow's first run is guarded by a **GitHub Environment** named `release` that requires manual approval before the release job executes. This prevents accidental releases during initial setup. + +After one verified release has shipped successfully, the manual approval requirement can be removed from the `release` environment (Settings → Environments → release → Protection rules). + +Cross-reference: Task E8 (release workflow setup). + +--- + +## Reference: `gh api` Commands (for humans, do not run in CI) + +The following commands configure the branch protection rule via the GitHub API. Run manually by a repo admin after confirming settings in the UI. + +```sh +# Replace ORG/REPO with the actual values +ORG=ucloud +REPO=ucloud-cli + +# Enable branch protection on master with required status checks and merge queue +gh api --method PUT \ + repos/$ORG/$REPO/branches/master/protection \ + --input - <<'EOF' +{ + "required_status_checks": { + "strict": true, + "checks": [ + {"context": "scope-gate"}, + {"context": "check-product"}, + {"context": "test"}, + {"context": "build-matrix"}, + {"context": "build"} + ] + }, + "enforce_admins": true, + "required_pull_request_reviews": null, + "restrictions": null +} +EOF + +# Enable merge queue (requires GHEC or public repo; uses GraphQL Rulesets API) +# For public repos this is done via UI: Settings → Branches → Edit rule → "Require merge queue" +``` + +> Note: The Merge Queue option in branch protection is not fully exposed in the REST API for all plan tiers. Use the Settings UI as the primary method; the commands above are a partial reference. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..16e3307388 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ +**Features:** + +**Fixes:** diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000000..f3552986f6 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,119 @@ +# Enables GitHub native auto-merge for product-autonomous PRs, including PRs +# opened from forks. This workflow runs in the trusted base-repository context: +# it checks out only the base SHA and treats PR file metadata as data. + +name: Auto Merge + +on: + pull_request_target: + types: [opened, synchronize, reopened, edited, ready_for_review, converted_to_draft, labeled, unlabeled] + +concurrency: + group: auto-merge-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +jobs: + enable: + if: github.event.pull_request.base.ref == 'master' + runs-on: ubuntu-latest + steps: + - name: Check pause state + id: state + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + DRAFT: ${{ github.event.pull_request.draft }} + HOLD: ${{ contains(github.event.pull_request.labels.*.name, 'do-not-merge/hold') }} + run: | + set -euo pipefail + + if [ "$DRAFT" = "true" ] || [ "$HOLD" = "true" ]; then + echo "paused=true" >> "$GITHUB_OUTPUT" + auto_merge="$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json autoMergeRequest --jq '.autoMergeRequest != null')" + if [ "$auto_merge" = "true" ]; then + gh pr merge --disable-auto "$PR" --repo "$GITHUB_REPOSITORY" + fi + echo "Auto-merge paused: draft=$DRAFT hold=$HOLD" + exit 0 + fi + + echo "paused=false" >> "$GITHUB_OUTPUT" + + - name: Checkout trusted base + if: steps.state.outputs.paused != 'true' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 1 + + - name: Set up go + if: steps.state.outputs.paused != 'true' + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Judge product autonomy + if: steps.state.outputs.paused != 'true' + id: judge + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + OWNER_GATE_AUTHOR: ${{ github.event.pull_request.user.login }} + OWNER_GATE_BASE_SHA: ${{ github.event.pull_request.base.sha }} + OWNER_GATE_PLATFORM_CLEARED: "false" + GOPROXY: 'https://goproxy.cn,direct' + run: | + set -euo pipefail + + diff_file="$RUNNER_TEMP/pr-files.name-status" + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate \ + --jq '.[] | if .status == "renamed" then "R100\t\(.previous_filename)\t\(.filename)" elif .status == "removed" then "D\t\(.filename)" elif .status == "added" then "A\t\(.filename)" else "M\t\(.filename)" end' \ + > "$diff_file" + + decision="$(go run ./hack/owner-gate < "$diff_file")" + echo "$decision" + + auto_merge="$(jq -r '.autoMergeEligible' <<<"$decision")" + reason="$(jq -r '.reason' <<<"$decision")" + { + echo "eligible=$auto_merge" + echo "reason<> "$GITHUB_OUTPUT" + + - name: Disable auto-merge for non-autonomous PRs + if: steps.state.outputs.paused != 'true' && steps.judge.outputs.eligible != 'true' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + REASON: ${{ steps.judge.outputs.reason }} + run: | + set -euo pipefail + + auto_merge="$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json autoMergeRequest --jq '.autoMergeRequest != null')" + if [ "$auto_merge" = "true" ]; then + gh pr merge --disable-auto "$PR" --repo "$GITHUB_REPOSITORY" + fi + echo "Auto-merge not enabled: $REASON" + + - name: Enable native auto-merge + if: steps.state.outputs.paused != 'true' && steps.judge.outputs.eligible == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + auto_merge="$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json autoMergeRequest --jq '.autoMergeRequest != null')" + if [ "$auto_merge" = "true" ]; then + echo "Auto-merge is already enabled for PR #$PR." + exit 0 + fi + + gh pr merge --auto --squash "$PR" --repo "$GITHUB_REPOSITORY" --match-head-commit "$HEAD_SHA" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..3b34e18fb7 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,28 @@ +name: Build Go + +on: + push: + branches: + - "master" + pull_request: + branches: + - "master" + merge_group: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Build + env: + GOPROXY: https://goproxy.cn,direct + run: go build ./... + diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 0000000000..cea2506e3f --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,250 @@ +# PR Admission Gate +# +# Required status checks (repo Settings → Branches/Rulesets): +# - owner-gate (product-autonomous vs platform; platform fail-closed) +# - hold-gate (red while a do-not-merge/hold label is present) +# - scope-gate (conventional commit title) +# - check-product (generated registry drift + rule1–10 boundary checks) +# - test (build + vet + offline tests) +# - build-matrix (6 × platform) +# +# owner-gate model (see docs/owner-gate-hardblock-design.md): +# - product PR (single product, author is BASE-version owner) → green, auto-merge. +# - platform PR (platform files / cross-product / non-owner / onboarding / +# offboarding) → fail-closed RED, UNLESS cleared: the PR author is an admin, +# OR an admin (≠ author) has APPROVED the PR. A "Compute platform clearance" +# step computes this via the API; the "Admission" step turns Blocking into a +# red check. owner-gate (Go) itself only routes and never fails (exit 0). +# +# Repo settings that this gate RELIES ON: +# - Settings → General → "Allow auto-merge" ✓ + "Allow squash merging" ✓. +# The separate Auto Merge workflow enables GitHub native auto-merge for +# product-autonomous PRs after re-running the owner-gate decision in the +# trusted base-repository context. +# - Required approvals = 0. Do NOT set a global "require N approvals" — that +# would also block product autonomy. Platform approval is enforced by +# owner-gate (admin review), not by branch protection's approval count. +# - The stale-approval defense is SELF-CONTAINED: clearance only counts an +# admin approval whose commit_id == current head, so a post-approval push +# auto-invalidates it without depending on branch protection's "Dismiss +# stale approvals" toggle. Enabling that toggle is fine as defense-in-depth. +# +# Triggers: pull_request_review is added so owner-gate re-runs (red→green) when an +# admin approves and (green→red) when the approval is dismissed. Security/CI jobs +# run on review events too — they must NOT be `if`-skipped, because GitHub counts +# a skipped required check as success and would mask a real prior failure. +# +# Merge-queue note: owner-gate/hold-gate/scope-gate are `pull_request*` only and +# do not run under merge_group. BEFORE enabling a merge queue you MUST either run +# owner-gate/hold-gate in merge_group (short-circuit on already-judged PRs) or +# keep them PR-level-required only (queue-level required = check-product/test/ +# build-matrix). Otherwise a skipped owner-gate counts as success in the queue. + +name: PR Gate + +on: + pull_request: + # labeled/unlabeled REQUIRED so hold-gate re-runs when do-not-merge/hold is + # added/removed after checks went green. edited REQUIRED so title fixes + # re-run scope-gate. + types: [opened, synchronize, reopened, edited, ready_for_review, labeled, unlabeled] + pull_request_review: + # submitted/dismissed REQUIRED so owner-gate re-evaluates platform clearance + # when an admin approves or the approval is dismissed (stale-on-push). + types: [submitted, dismissed] + merge_group: + +# One run per PR; a newer event (push / review) cancels the older in-flight run +# so the latest state always wins and interleaved label/review/push events can't +# leave a stale conclusion standing. +concurrency: + group: pr-gate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + scope-gate: + # Conventional commit title only. + if: github.event_name == 'pull_request' || github.event_name == 'pull_request_review' + runs-on: ubuntu-latest + steps: + - name: Conventional commit title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -euo pipefail + if ! echo "$PR_TITLE" | grep -qE '^(feat|fix|refactor|build|ci|docs|test|chore|perf)(\([a-z0-9_-]+\))?!?: .+'; then + echo "::error::PR title is not a conventional commit: $PR_TITLE" + exit 1 + fi + echo "scope-gate: conventional title OK" + + owner-gate: + # Routes product vs platform (Go, base-version aware) and enforces the + # platform hard-block. Green on product OR cleared platform; red on + # un-cleared platform. Eligibility (auto_merge) is consumed downstream. + if: github.event_name == 'pull_request' || github.event_name == 'pull_request_review' + runs-on: ubuntu-latest + outputs: + auto_merge: ${{ steps.gate.outputs.autoMergeEligible }} + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } # need base+head for git diff / git show + - uses: actions/setup-go@v5 + with: { go-version: '1.25' } + + - name: Compute platform clearance + id: clr + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -uo pipefail + PR='${{ github.event.pull_request.number }}' + AUTHOR='${{ github.event.pull_request.user.login }}' + # admin/maintain 视为管理员级;查询失败(如 fork 只读 token 403)→ 视为非管理员(fail-closed)。 + is_admin() { + perm=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$1/permission" --jq '.permission' 2>/dev/null || echo none) + case "$perm" in admin|maintain) return 0 ;; *) return 1 ;; esac + } + cleared=false + if is_admin "$AUTHOR"; then + cleared=true + echo "作者 $AUTHOR 具管理员权限,平台改动放行。" + else + # 每个 reviewer 取其最新一条 review;state=APPROVED、reviewer 是管理员(≠作者), + # 且 **批准时的 commit == 当前 head**(把批准钉死到被审的那个 commit)即放行。 + # 作者批准后再 push,新 head ≠ 批准 commit → 该 review 不再命中 → 回红,需重新批准。 + # 这条 self-contained 的 stale 防线不依赖分支保护的 Dismiss-stale 开关。 + approvers=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/reviews" --paginate 2>/dev/null \ + | jq -s --arg head "$HEAD_SHA" 'add | group_by(.user.login) | map(max_by(.id)) | map(select(.state=="APPROVED" and .commit_id==$head)) | .[].user.login' -r 2>/dev/null || true) + for a in $approvers; do + [ "$a" = "$AUTHOR" ] && continue + if is_admin "$a"; then cleared=true; echo "已由管理员 $a 批准放行。"; break; fi + done + fi + echo "cleared=$cleared" >> "$GITHUB_OUTPUT" + + - name: Judge admission + id: gate + env: + OWNER_GATE_AUTHOR: ${{ github.event.pull_request.user.login }} + OWNER_GATE_BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + OWNER_GATE_PLATFORM_CLEARED: ${{ steps.clr.outputs.cleared }} + GOPROXY: 'https://goproxy.cn,direct' + run: | + set -euo pipefail + # Three-dot diff = the PR's own changes (merge-base..head), matching + # GitHub's "Files changed". A two-dot diff would, when the branch is + # behind master, surface unrelated master-side changes. + git diff --name-status "$OWNER_GATE_BASE_SHA...$HEAD_SHA" \ + | go run ./hack/owner-gate + + - name: Post verdict to PR (sticky comment) + # always() + tolerant: must update the verdict even when admission will + # fail the job (red), and must not fail the job if the comment write is + # denied (fork PRs get a read-only token). + if: always() && steps.gate.outputs.reason != '' + env: + GH_TOKEN: ${{ github.token }} + REASON: ${{ steps.gate.outputs.reason }} + PR: ${{ github.event.pull_request.number }} + run: | + set -uo pipefail + MARKER='' + BODY="$MARKER"$'\n'"$REASON" + ID=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" \ + --jq "map(select(.body|startswith(\"$MARKER\")))|.[0].id // empty" 2>/dev/null || true) + if [ -n "$ID" ]; then + gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$ID" -f body="$BODY" >/dev/null 2>&1 \ + || echo "::warning::owner-gate: 更新裁决评论失败(可能是 fork 只读 token)" + else + gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -f body="$BODY" >/dev/null 2>&1 \ + || echo "::warning::owner-gate: 贴裁决评论失败(可能是 fork 只读 token)" + fi + echo "$REASON" + + - name: Admission (hard-block un-cleared platform PR) + # The actual red: owner-gate (Go) always exits 0 and writes blocking; + # this step is what fails the required check when blocking is true. + if: steps.gate.outputs.blocking == 'true' + env: + REASON: ${{ steps.gate.outputs.reason }} + run: | + echo "::error::$REASON" + exit 1 + + hold-gate: + # /hold emergency brake: red while the PR carries do-not-merge/hold. Runs on + # review events too so a skipped run can't mask a real red (skipped=success). + if: github.event_name == 'pull_request' || github.event_name == 'pull_request_review' + runs-on: ubuntu-latest + steps: + - name: Fail if on hold + if: contains(github.event.pull_request.labels.*.name, 'do-not-merge/hold') + run: | + echo "::error::PR is on hold (do-not-merge/hold label present) — remove the label to allow merge." + exit 1 + - run: echo "hold-gate - not on hold" + + check-product: + # Runs on every trigger (incl. merge_group + review events): must produce a + # real conclusion, never an `if`-skip that GitHub would count as success. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: '1.25' } + - name: Verify generated product registry + run: | + set -euo pipefail + go run ./hack/gen-products + if ! git diff --quiet -- cmd/products.gen.go; then + echo "::error file=cmd/products.gen.go,line=1::cmd/products.gen.go is out of date. Run 'go run ./hack/gen-products' from the repo root and commit the result." + git diff -- cmd/products.gen.go + exit 1 + fi + echo "cmd/products.gen.go is up to date." + env: { GOPROXY: 'https://goproxy.cn,direct' } + - run: go run ./hack/check-product + env: { GOPROXY: 'https://goproxy.cn,direct' } + + test: + # NOTE: Only OFFLINE-safe packages. The `cmd` integration tests hit a live + # UCloud API and are excluded; run `go test ./cmd/...` separately with creds. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: '1.25' } + - run: go build ./... + env: { GOPROXY: 'https://goproxy.cn,direct' } + - run: go vet ./... + env: { GOPROXY: 'https://goproxy.cn,direct' } + - run: go test ./pkg/... ./cmd/internal/... ./hack/... -count=1 + env: { GOPROXY: 'https://goproxy.cn,direct' } + + build-matrix: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - { goos: darwin, goarch: amd64 } + - { goos: darwin, goarch: arm64 } + - { goos: linux, goarch: amd64 } + - { goos: linux, goarch: arm64 } + - { goos: windows, goarch: amd64 } + - { goos: windows, goarch: arm64 } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: { go-version: '1.25' } + - run: go build -o /dev/null . + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + GOPROXY: 'https://goproxy.cn,direct' diff --git a/.github/workflows/release-drift.yml b/.github/workflows/release-drift.yml new file mode 100644 index 0000000000..e22e0d41c8 --- /dev/null +++ b/.github/workflows/release-drift.yml @@ -0,0 +1,87 @@ +# Release Drift Guard +# +# Detects the failure mode where the weekly Release workflow misses its release +# window while master already had releasable commits waiting. This guard only +# alerts; release.yml remains the single writer of tags and GitHub Releases. It +# runs after the weekly release window, skips while Release is already +# queued/running, and gives fresh master commits a grace window so changes that +# land after the release window wait for the next week instead of failing drift. + +name: Release Drift Guard + +on: + schedule: + # Tuesdays 03:00 Asia/Shanghai (Monday 19:00 UTC), four hours after Release. + - cron: "0 19 * * 1" + workflow_dispatch: {} + +permissions: + actions: read + contents: read + +jobs: + release-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Install svu + run: go install github.com/caarlos0/svu@latest + env: + GOPROXY: https://goproxy.cn,direct + + - name: Detect unreleased master changes + env: + GH_TOKEN: ${{ github.token }} + RELEASE_GRACE_SECONDS: "14400" + run: | + set -euo pipefail + + running_releases=$( + gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow Release \ + --branch master \ + --limit 20 \ + --json status \ + --jq '[.[] | select(.status=="queued" or .status=="in_progress" or .status=="waiting" or .status=="requested" or .status=="pending")] | length' + ) + + if [ "$running_releases" -gt 0 ]; then + echo "Release workflow already queued/running on master; skipping drift check." + exit 0 + fi + + head_ts=$(git log -1 --format=%ct HEAD) + now_ts=$(date +%s) + head_age=$((now_ts - head_ts)) + if [ "$head_age" -lt "$RELEASE_GRACE_SECONDS" ]; then + echo "master HEAD is ${head_age}s old, within ${RELEASE_GRACE_SECONDS}s release grace window; skipping drift check." + exit 0 + fi + + SVU="$(go env GOPATH)/bin/svu" + CURRENT="$("$SVU" current)" + DETECT="$("$SVU" next)" + LOG="$(git log --format='%B' "$CURRENT"..HEAD)" + + RELEASABLE="" + if grep -qE '^(refactor|perf)(\([^)]+\))?!?:' <<<"$LOG"; then + RELEASABLE="refactor/perf" + fi + + if [ "$DETECT" != "$CURRENT" ] || [ -n "$RELEASABLE" ]; then + echo "::error::master has releasable commits after $CURRENT outside the weekly release grace window, but no release tag covers HEAD. Check the weekly Release workflow or run it manually." + echo "current=$CURRENT" + echo "detected=$DETECT" + git log --oneline "$CURRENT"..HEAD + exit 1 + fi + + echo "Release is up to date at $CURRENT." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..b4ccc3883e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,106 @@ +# Weekly release automation +# +# Flow: +# 1. Once per week (or workflow_dispatch for an urgent/manual run), the +# workflow checks master for releasable commits since the latest tag. +# 2. Version policy: every release is a PATCH bump (0.3.3 → 0.3.4 → 0.3.5), +# regardless of whether the commits are feat or fix — we intentionally do +# NOT auto-minor on feat. svu is used in two roles here: +# - `svu next` → DETECTOR: if it equals `svu current`, svu sees no +# releasable feat/fix/breaking commit since the last tag. +# We additionally treat refactor/perf commits as +# releasable (a `git log` grep), so platform refactors +# ship too; pure docs/chore/test/ci/style/build still skip. +# - `svu patch` → the actual version: always current + 0.0.1. +# 3. If there are releasable commits, the job tags HEAD with that patch version +# and pushes the tag — all in the SAME job that runs goreleaser. +# This avoids the "default GITHUB_TOKEN-pushed tag doesn't trigger a +# downstream workflow" trap: tag + release happen in one atomic job. +# 4. goreleaser builds the binaries and creates the GitHub Release. +# 5. If there are no new releasable commits (feat/fix/perf/refactor/breaking), +# the job exits early without creating a release. +# +# Release is intentionally batched weekly. PRs are still admitted by PR Gate and +# normal CI/CD checks before merge; this workflow only decides whether the +# already-merged master branch should be published during the release window. + +name: Release + +on: + schedule: + # Mondays 23:00 Asia/Shanghai (15:00 UTC). + - cron: "0 15 * * 1" + workflow_dispatch: {} # manual trigger for urgent releases or guarded runs + +concurrency: + group: release + cancel-in-progress: false # never cancel an in-flight release + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write # push tags + create GitHub Releases + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # svu and goreleaser both need full tag history + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Install svu + run: go install github.com/caarlos0/svu@latest + env: + GOPROXY: https://goproxy.cn,direct + + - name: Compute next version & tag + id: ver + run: | + SVU="$(go env GOPATH)/bin/svu" + CURRENT="$("$SVU" current)" + echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + # DETECTOR: is there anything releasable since the last tag ($CURRENT)? + # - `svu next` catches feat/fix/breaking (next != current ⇒ releasable). + # - We ADD refactor/perf so platform refactors also ship. Pure + # docs/chore/test/ci/style/build merges still skip. + # We never publish `svu next` itself — the version policy is patch-only + # (every release is current + 0.0.1; see header). + DETECT="$("$SVU" next)" + # Also releasable if a refactor/perf commit exists since $CURRENT. + # Scan %B (full message, not just %s) so a type that only shows up in a + # merge-commit body / PR title is still caught. Capture first and match + # via here-string (NO pipe): `grep -q` exits on first hit; a piped + # `git log` would then take SIGPIPE and — under bash -o pipefail (the + # GitHub Actions default) — flip the whole pipeline non-zero, which + # would falsely read as "nothing releasable" and skip. The here-string + # avoids the pipe entirely (no-match just makes the `if` false). + RELEASABLE="" + LOG="$(git log --format='%B' "$CURRENT"..HEAD)" + if grep -qE '^(refactor|perf)(\([^)]+\))?!?:' <<<"$LOG"; then + RELEASABLE="refactor/perf" + fi + if [ "$DETECT" != "$CURRENT" ] || [ -n "$RELEASABLE" ]; then + NEXT="$("$SVU" patch)" # force a patch bump regardless of commit type + echo "next=$NEXT" >> "$GITHUB_OUTPUT" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$NEXT" + git push origin "$NEXT" + echo "released=true" >> "$GITHUB_OUTPUT" + else + echo "No new releasable commits since $CURRENT — skipping release." + echo "next=$CURRENT" >> "$GITHUB_OUTPUT" + echo "released=false" >> "$GITHUB_OUTPUT" + fi + + - uses: goreleaser/goreleaser-action@v6 + if: steps.ver.outputs.released == 'true' + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GOPROXY: https://goproxy.cn,direct diff --git a/.gitignore b/.gitignore index 84588f3044..513fbbbf93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ out .vscode/ .idea/ +bin/ +.DS_Store +.codegraph/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000000..31275f7578 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,71 @@ +version: 2 + +builds: + - id: ucloud + main: . + binary: ucloud + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X github.com/ucloud/ucloud-cli/cmd/internal/version.Version={{.Version}} + +archives: + - id: ucloud + ids: + - ucloud + formats: + - zip + name_template: "ucloud-{{ .Os }}_{{ .Arch }}" + files: + - LICENSE + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + use: git + sort: asc + # Render each entry as the bare commit subject (e.g. "feat(udb): ...") instead + # of goreleaser's default "<40-char-sha> ". The subject already carries + # the Conventional-Commit type+scope, and the groups below bucket by type, so the + # raw SHA prefix is just noise. Traceability stays available via the Release's + # auto-generated compare link on GitHub. + format: "{{ .Message }}" + # Customer-facing release notes: surface only user-perceivable change types + # (feat/fix/perf/docs). Internal-only commit types are filtered out below so a + # release — especially a large internal refactor — doesn't bury users in noise. + # Those commits still live in git history and the Release's compare link. + groups: + - title: Features + regexp: '^feat(\(.+\))?\!?:.+' + order: 0 + - title: Bug Fixes + regexp: '^fix(\(.+\))?\!?:.+' + order: 1 + - title: Performance Improvements + regexp: '^perf(\(.+\))?\!?:.+' + order: 2 + - title: Documentation + regexp: '^docs(\(.+\))?\!?:.+' + order: 3 + filters: + exclude: + - '^Merge ' + - '^refactor(\(.+\))?\!?:' + - '^test(\(.+\))?\!?:' + - '^style(\(.+\))?\!?:' + - '^chore(\(.+\))?\!?:' + - '^ci(\(.+\))?\!?:' + - '^build(\(.+\))?\!?:' + +# release: the target repo (owner/name) is intentionally NOT hardcoded — goreleaser +# auto-detects it from GITHUB_REPOSITORY / the git remote, so the same config +# publishes to whichever repo runs it (a fork when testing, ucloud/ucloud-cli in +# production). Hardcoding it would let a fork's release accidentally target upstream. diff --git a/.svu.yaml b/.svu.yaml new file mode 100644 index 0000000000..130304120d --- /dev/null +++ b/.svu.yaml @@ -0,0 +1,2 @@ +tag: + prefix: v diff --git a/CHANGELOG.md b/CHANGELOG.md index ad091bd658..cb101308a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,238 @@ -## Change Log -v0.1.16 +# Changelog + +This file is a historical archive of changes up to and including **v0.3.3**. + +Starting with the next release, change logs are published with each GitHub Release +(auto-generated from Conventional Commits): +https://github.com/ucloud/ucloud-cli/releases + +--- + +## 0.3.3 (2026-06-18) + +* chore: bump `base.Version` to 0.3.3 to match the release tag + +## 0.3.2 (2026-06-18) + +* `mysql db create`: switch to the latest UDB create API and improve usability +* expose the `mysql db list-machine-type` command + +## 0.3.1 (2026-06-11) + +* OAuth browser login: `ucloud auth login` / `ucloud auth logout` (RFC 8252 loopback auto-capture, `--no-browser` fallback) +* automatic token lifecycle: proactive refresh before expiry, reactive refresh-and-replay on auth failure (RetCode 174), flock-serialized rotation safe for concurrent processes +* atomic temp+rename writes for config/credential files +* `auth_mode` picks exactly one credential mechanism per profile; AK/SK profiles unchanged +* fix: `config update --base-url` no longer validates against the old gateway +* fix: login validates an existing project_id against the logged-in account +* fix: `init` on an OAuth profile persists the switch back to AK/SK +* token redaction across all log sinks; panic output redacted + +## 0.3.0 (2024-09-20) + +* support naming in batch on creating uhost + +## 0.2.0 (2024-06-12) + +* ssh key pair and security group support for creating uhost + +## 0.1.49 (2024-05-09) + +* fix cli version config + +## 0.1.48 (2024-05-09) + +* update golang version to 1.19 + +## 0.1.47 (2024-05-08) + +* support repeating generic api call + +## 0.1.46 (2023-12-28) + +* update ucloud go sdk version + +## 0.1.45 (2023-12-18) + +* fix reinstall password encoding + +## 0.1.44 (2023-08-15) + +* fix failing to specify release-eip and delete-cloud-disk to false + +## 0.1.43 (2023-07-13) + +* skip no permission region when query all uhosts in all regions + +## 0.1.42 (2023-02-22) + +* add concurrent parameter for uhost creating to tune concurrent level + +## 0.1.41 (2022-11-30) + +* remove bandwith limit for unet and udpn product in cli + +## 0.1.40 (2022-08-31) + +* add `signature` command to calculate signature quickly. + +## 0.1.39 (2022-06-13) + +* fix init failure when user donot have a default project in ucloud console. +* update the description of init. + +## 0.1.38 (2022-04-27) + +* fix build failure when using go 1.18 on darwin_arm64. ([golang/go#49219](https://github.com/golang/go/issues/49219)) + +## 0.1.36 (2021-07-07) + +ENHANCEMENTS: + +* add the flags: `--instance-type`, `--forward-region`, `--bandwidth-package` about command `gssh create` to customize specific instance type of global ssh. +* add response fields: `GlobalSSHPort`, `InstanceType` about command `gssh list` to list specific instance type of global ssh. +* update cmd `uhost create` about bind EIP: EIP creates and binds to UHost when UHost creating instead of after UHost creating. + +## 0.1.35 (2020-11-11) + +ENHANCEMENTS: + +* add the flag `--user-data-base64` about command `ucloud uhost create` to customize the startup behaviors when launching the uhost instance and the value must be base64-encode.(#55) + +## 0.1.34 (2020-11-11) + +ENHANCEMENTS: + +* add the flag `--user-data` about command `ucloud uhost create` to customize the startup behaviors when launching the uhost instance.(#54) +* add the flag `--gpu-type` about command `ucloud uhost create` to define the type of GPU instance.(#54) + +## 0.1.33 + +* Add command 'ucloud api', which can call any API of ucloud like this + - ucloud api --Action DescribeUHostInstance --Region cn-bj2 or + - ucloud api --local-file ./create_uhost.json +* Adapt to cloudshell + +## 0.1.32 + +* Fixbug for creating uhost with shared bandwith. Now you can create uhost bound with shared bandwith using follow command. +``` +ucloud uhost create --cpu 1 --memory-gb 2 --image-id uimage-xxx --password xxxxx --create-eip-traffic-mode ShareBandwidth --shared-bw-id bwshare-lxxxx +``` + +## 0.1.31 + +* fixbug, password missed when creating redis + +## 0.1.30 + +* support creating uhost without data disk. +* default value of flag '--machine-type' changed to 'N' from empty when creating uhost. + +## 0.1.29 + +* resize attached disk without stop uhost +* make batch creating uhost faster + +## 0.1.28 + +* command 'ucloud uhost resize' add flag '--data-disk-id', to resize the specified udisk. +* fixbug #45 + +## 0.1.27 + +* Enable hot-plug for uhost when running 'ucloud uhost create' +* Add command 'ucloud uhost leave-isolation-group', 'ucloud uhost isolation-group create' and 'ucloud uhost isolation-group delete' + +## 0.1.26 + +* fixbug about base-url + +## 0.1.25 + +* ask permission for upload log when executing 'ucloud init' + +## 0.1.24 + +* add global flags --base-url, --timeout-sec, --max-retry-times +* command [ucloud uhost create] add flag --hot-plug, --isolation-group +* add command [ucloud uhost isolation-group list] + +## 0.1.23 + +* fix dead lock when creating uhosts in parallel +* refactor part of eip and ulb operations + +## 0.1.22 + +* Add global flag '--public-key' and '--private-key' to override public-key and private-key in local config files. +* Add flag '--max-retry-times' for command 'ucloud config' so that users can set retry times for failed idempotent API calls. +* Add flag '--region-all' and '--output' for command 'ucloud uhost list' so that users can list uhosts in all regions and display more infomations about uhost. + +## 0.1.21 + +* Add global flag '--profile' to specify profile for any command. +* Add command 'ucloud ext uhost switch-eip' + +## 0.1.20 + +* Add command: + ucloud pathx uga create | delete | list | describe | add-port | delete-port + ucloud pathx upath list + +## 0.1.19 + +* Bugfix for running command ucloud init failed. + +## 0.1.18 + +* Add following commands: + - `ucloud config add` + - `ucloud config update` + - `ucloud redis restart` + - `ucloud memcache restart` + +* Command [ucloud uhost list --uhost-id-only] list uhost-ids separated by comma +* Command [ucloud uhost delete --uhost-id xx,xx] can delete uhost instances concurrently. + You can use [ucloud uhost delete --uhost-id \`ucloud uhost list --uhost-id-only --page-off\`] to delete all uhost instances in parallel. + +## 0.1.17 + +* add flags page-off and uhost-id-only for uhost list + +## 0.1.16 + * Support log rotation. Log file path $HOME/.ucloud/cli.log. * Bugfix for display nothing when uhost create failed -v0.1.15 +## 0.1.15 + * Update documents * Add test for uhost -v0.1.14 +## 0.1.14 + * Create uhost concurrently -v0.1.13 +## 0.1.13 + * Update version of ucloud-sdk-go to fix bug -v0.1.12 +## 0.1.12 + * Preliminary support umem -v0.1.11 +## 0.1.11 + * Use go modules to manage dependencies * Fix bug for uhost clone -v0.1.10 +## 0.1.10 + * Support udb mysql -v0.1.9 +## 0.1.9 + * Better flag value completion with local cache and multiple resource ID completion * Command structure adjustment - ucloud bw-pkg => ucloud bw pkg @@ -33,29 +242,37 @@ v0.1.9 - ucloud ulb-vserver add-node/update-node/delete-node/list-node => ucloud ulb vserver backend add/update/delete/list - ucloud ulb-vserver add-policy/list-policy/update-policy/delete-policy => ucloud ulb vserver policy add/list/update/delete -v0.1.8 +## 0.1.8 + * Support ulb -v0.1.7 +## 0.1.7 + * Add udpn, firewall, shared bandwidth and bandwidth package; Refactor vpc, subnet and eip -v0.1.6 +## 0.1.6 + * Improve uhost,image and disk-snapshot -v0.1.5 +## 0.1.5 + * support batch operation. -v0.1.4 +## 0.1.4 + * Support udisk. * Polling udisk and uhost long time operation * Async complete resource-id -v0.1.3 +## 0.1.3 + * Integrate auto completion. * Support uhost create, stop, delete and so on. -v0.1.2 +## 0.1.2 + * Simplify config and completion. -v0.1.1 -* UHost list; EIP list,delete and allocate; GlobalSSH list,delete,modify and create. \ No newline at end of file +## 0.1.1 + +* UHost list; EIP list,delete and allocate; GlobalSSH list,delete,modify and create. diff --git a/Makefile b/Makefile index 838b3f591a..4b71425727 100644 --- a/Makefile +++ b/Makefile @@ -1,28 +1,18 @@ -export VERSION=0.1.29 +GOFMT_FILES?=$$(find . -name '*.go') +LDFLAGS=-s -w -X github.com/ucloud/ucloud-cli/cmd/internal/version.Version=$(shell git describe --tags --always --dirty) -.PHONY : install -install: - go build -i -v -mod=vendor -o out/ucloud main.go - cp out/ucloud /usr/local/bin - -.PHONY : build_mac -build_mac: - GOOS=darwin GOARCH=amd64 go build -mod=vendor -o out/ucloud main.go - tar zcvf out/ucloud-cli-macosx-${VERSION}-amd64.tgz -C out ucloud - shasum -a 256 out/ucloud-cli-macosx-${VERSION}-amd64.tgz +.PHONY: build +build: + go build -ldflags "$(LDFLAGS)" -o out/ucloud . -.PHONY : build_linux -build_linux: - GOOS=linux GOARCH=amd64 go build -mod=vendor -o out/ucloud main.go - tar zcvf out/ucloud-cli-linux-${VERSION}-amd64.tgz -C out ucloud - shasum -a 256 out/ucloud-cli-linux-${VERSION}-amd64.tgz - -.PHONY : build_windows -build_windows: - GOOS=windows GOARCH=amd64 go build -mod=vendor -o out/ucloud.exe main.go - zip -r out/ucloud-cli-windows-${VERSION}-amd64.zip out/ucloud.exe - shasum -a 256 out/ucloud-cli-windows-${VERSION}-amd64.zip +.PHONY: install +install: build + cp out/ucloud /usr/local/bin -.PHONY : build_all -build_all: build_mac build_linux build_windows +.PHONY: fmt +fmt: + gofmt -w -s $(GOFMT_FILES) +.PHONY: release-snapshot +release-snapshot: + goreleaser release --snapshot --clean diff --git a/README-CN.md b/README-CN.md index 5f34dd916d..db01226b0b 100644 --- a/README-CN.md +++ b/README-CN.md @@ -172,6 +172,83 @@ $ ucloud config update --profile xxx --region cn-sh2 $ ucloud config --help ``` +## 认证方式 + +UCloud CLI 支持两种认证方式,请根据使用场景选择: + +| 使用场景 | 推荐方式 | +| --- | --- | +| 交互终端上的人类用户 | OAuth 浏览器登录:`ucloud auth login`(推荐) | +| 脚本、CI/CD 等无人值守自动化 | AK/SK profile:`ucloud init` 或 `ucloud config` | + +### 浏览器登录(OAuth) + +``` +$ ucloud auth login +``` + +执行过程: + +1. CLI 在 127.0.0.1 的临时端口上启动一个本地回调 server,并打开浏览器访问 UCloud 授权页。如果浏览器没有自动打开,复制终端打印的 URL 手动打开即可。 +2. 在浏览器中登录并授权后,浏览器会跳转到 `http://localhost:/authorization`,CLI 自动捕获授权码并展示「登录成功」页面——全程无需复制粘贴,关闭页面回到终端即可。 +3. CLI 用授权码换取 token 并保存。如果当前 profile 还没有配置 region/zone/project,会自动获取并配置默认值: + +``` +Configured default region:cn-bj2 zone:cn-bj2-02 +Configured default project:org-xxxxxx Default +Logged in as you@example.com, token valid until 18:30 +``` + +### 手工回退(本机无浏览器) + +SSH 会话或无图形界面的机器,请加 `--no-browser`: + +``` +$ ucloud auth login --no-browser +``` + +CLI 会打印授权 URL 而不是打开浏览器。在任意设备上打开该 URL,登录并授权后,浏览器会跳转到一个**无法打开的** `http://localhost:/authorization?...` 页面——**这是预期行为**。把地址栏中的完整 URL 复制下来,粘贴回终端即可。 + +默认模式下同样存在这条粘贴回退路径:如果自动捕获在 3 分钟内没有收到回调,CLI 会打印 "Automatic capture timed out. Paste the callback URL here as a fallback:" 并等待粘贴回调 URL。 + +针对非默认环境,可加 `--oauth-base-url ` 覆盖 OAuth 授权服务器地址。该参数在无任何已有配置时即可使用,并会保存到 profile,后续 token 刷新会沿用它。 + +### Token 存储与有效期 + +- Token 存储在 `~/.ucloud/credential.json`,文件权限 0600。 +- access token 有效期约 1 小时,到期后通过 refresh token 静默续期,全程无感知。续期发生在使用时:运行命令时 CLI 会先检查并刷新临期 token;若网关在命令执行中拒绝 token,也会自动刷新并重试。没有后台常驻进程。 +- refresh token 当前有效期为 7 天,且每次续期都会轮换出新的 7 天有效期——只要 7 天内用过一次 CLI,登录态就一直延续;连续 7 天未使用后,下次命令会提示重新执行 `ucloud auth login`。 +- 登录态会一直保持,直到 refresh token 在服务端过期,或执行 `ucloud auth logout`。logout 只删除当前 profile 的本地 token,不会动已存储的 AK/SK。退出 UCloud 网页控制台不影响 CLI 登录态。 + +### 一个 profile 只用一种认证方式 + +- 每个 profile 同一时刻只使用一种认证方式,可在 `ucloud config list` 的 `AuthMode` 列查看(`oauth` 为浏览器登录,空值为 AK/SK 签名)。 +- 执行 `ucloud auth login` 会把当前 profile 切换到 OAuth 模式;已有的 AK/SK 仍保留在配置中,但不再参与签名。 +- 在 OAuth 模式的 profile 上执行 `ucloud init`,会先要求确认,确认后才切回 AK/SK。 +- 命令行同时传入 `--public-key` 和 `--private-key` 时始终优先生效:该次调用使用 AK/SK 签名,与 profile 的认证模式无关。 + +### 代理 + +OAuth token 请求遵循标准的 `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` 环境变量。 + +### 使用限制 + +- **OAuth 登录按机器隔离。** 不要在多台机器之间拷贝或共享 `~/.ucloud`:refresh token 每次续期都会轮换,一台机器续期会把另一台「挤」下线。多机或共享场景请使用 AK/SK profile。 +- **降级会丢失 token。** 旧版本 ucloud-cli 不认识 token 字段,重写配置文件时会静默丢弃它们。降级后请重新执行 `ucloud auth login`。 + +### 故障排查 + +| 现象 / 报错 | 处理方法 | +| --- | --- | +| `authorization code or refresh token expired or already used (each code works only once)` | 每个授权码只能使用一次且很快过期。重新执行 `ucloud auth login` 并尽快完成流程。 | +| `state mismatch: the pasted URL likely comes from a previous login attempt` | 粘贴的是上一次登录尝试的回调 URL。重新执行 `ucloud auth login`,并粘贴本次的 URL。 | +| `Login expired for profile ''` | refresh token 已失效。重新执行 `ucloud auth login`。 | +| `cannot reach oauth server ... (check network or proxy settings)` | 网络或代理问题。检查网络连通性以及 `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` 设置。 | +| `'ucloud auth login' requires an interactive terminal` | 当前处于 CI 或管道(非交互)环境。OAuth 登录面向交互人类用户,自动化场景请改用 AK/SK profile。 | +| 浏览器没有自动打开 | 复制终端打印的 URL 手动打开,或改用 `--no-browser`。 | +| (手工模式)浏览器提示 localhost 页面无法打开 | 预期行为——手工模式下 CLI 并未监听该端口。复制地址栏中的完整 URL 粘贴回终端即可。 | +| (手工模式)localhost 页面显示了其他本地程序的内容 | 无害——恰好有其他程序监听了该端口。只有地址栏里的 URL 有用,复制粘贴即可。 | + ## 举例说明 用UCloud CLI在尼日利亚创建数据中心创建一台主机并绑定一个外网IP,然后配置GlobalSSH加速,加速中国大陆到目的主机的SSH登陆 @@ -216,3 +293,39 @@ gssh[uga-0psxxx] created $ ssh root@152.32.140.92.ipssh.net root@152.32.140.92.ipssh.net's password: password of the uhost instance ``` + +使用"ucloud api"命令调用任意API,根据API文档把某个API的参数依次填入。此命令比较特殊,不支持--public-key,--private-key,--debug,--profile,--timeout-sec等公共参数,如果要开启debug模式,可以设置环境变量$UCLOUD_CLI_DEBUG=on + +``` +$ ucloud api --Action --Param1 --Param2 ... +``` +或者把API参数写到JSON文件中,举例如下 +``` +$ ucloud api --local-file ./create_uhost.json + +//create_uhost.json文件内容 +{ + "Action":"CreateUHostInstance", + "Region":"cn-bj2", + "Zone":"cn-bj2-02", + "ImageId":"uimage-gk2x3x", + "NetworkInterface": [{ + "EIP":{ + "Bandwidth":1, + "OperatorName":"Bgp", + "PayMode": "Bandwidth" + } + }], + "LoginMode":"Password", + "Password":"dGVzdGx4ajEy", + "CPU":1, + "Memory":2048, + "Disks":[ + { + "Size":20, + "Type":"LOCAL_NORMAL", + "IsBoot":"true" + } + ] +} +``` \ No newline at end of file diff --git a/README.md b/README.md index 9416b066f0..54860f59d7 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,83 @@ For more information, run: $ ucloud config --help ``` +## Authentication + +The UCloud CLI supports two ways to authenticate. Pick one based on how you use the CLI: + +| You are | Use | +| --- | --- | +| A human at an interactive terminal | OAuth browser login: `ucloud auth login` (recommended) | +| Scripts, CI/CD or other unattended automation | AK/SK profile: `ucloud init` or `ucloud config` | + +### Log in via browser (OAuth) + +``` +$ ucloud auth login +``` + +What happens: + +1. The CLI starts a temporary local callback server on an ephemeral 127.0.0.1 port and opens your browser at the UCloud authorization page. If the browser does not open, copy the printed URL and open it manually. +2. You log in and approve. The browser is redirected to `http://localhost:/authorization`, where the CLI captures the authorization code automatically and shows a "Login successful" page — no copy-paste needed. Just close the tab and return to the terminal. +3. The CLI exchanges the code for tokens and saves them. If the profile has no region/zone/project configured yet, it also fetches and configures the defaults: + +``` +Configured default region:cn-bj2 zone:cn-bj2-02 +Configured default project:org-xxxxxx Default +Logged in as you@example.com, token valid until 18:30 +``` + +### Manual fallback (no browser on this machine) + +For SSH sessions or headless machines, pass `--no-browser`: + +``` +$ ucloud auth login --no-browser +``` + +The CLI prints the authorization URL instead of opening a browser. Open it on any device, log in and approve. The browser will then be redirected to a `http://localhost:/authorization?...` page that **cannot open — this is expected**. Copy the FULL URL from the address bar and paste it back into the terminal. + +The same paste prompt is also used as a fallback in the default mode: if the automatic capture does not receive the callback within 3 minutes, the CLI prints "Automatic capture timed out. Paste the callback URL here as a fallback:" and waits for the pasted URL. + +For non-default environments, pass `--oauth-base-url ` to override the OAuth authorization server URL. It works with no prior config and is saved to the profile, so later token refreshes reuse it. + +### Token storage and lifetime + +- Tokens are stored in `~/.ucloud/credential.json` with file mode 0600. +- The access token is valid for about 1 hour and is renewed silently via the refresh token — no action needed from you. Renewal happens on use: when you run a command, the CLI refreshes the token if it is about to expire, and also recovers automatically if the gateway rejects a token mid-command. There is no background daemon. +- The refresh token is currently valid for 7 days and is replaced with a fresh one on every renewal, so any use of the CLI within that window keeps you logged in indefinitely. After 7 days without use, the next command asks you to run `ucloud auth login` again. +- You stay logged in until the refresh token expires on the server side, or until you run `ucloud auth logout`. Logout only removes the locally stored tokens of the current profile; it does not touch any stored AK/SK keys. Logging out of the UCloud web console does not affect CLI sessions. + +### One profile, one auth method + +- Each profile uses exactly one auth method at a time, shown in the `AuthMode` column of `ucloud config list` (`oauth` for browser login, empty for AK/SK signing). +- Running `ucloud auth login` switches the current profile to OAuth. Existing AK/SK keys are kept stored but no longer used for signing. +- Running `ucloud init` on an OAuth profile asks for confirmation before switching the profile back to AK/SK. +- Passing both `--public-key` and `--private-key` flags on a command always takes precedence: that invocation uses AK/SK signing regardless of the profile's auth mode. + +### Proxies + +OAuth token requests honor the standard `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` environment variables. + +### Limitations + +- **OAuth login is per machine.** Do not copy or share `~/.ucloud` across machines: the refresh token rotates on every renewal, so a renewal on one machine logs the other machine out. For multi-machine or shared setups, use an AK/SK profile. +- **Downgrading drops tokens.** Older ucloud-cli versions do not know the token fields and silently drop them when rewriting the config files. After downgrading, run `ucloud auth login` again. + +### Troubleshooting + +| Symptom / message | What to do | +| --- | --- | +| `authorization code or refresh token expired or already used (each code works only once)` | Each authorization code works only once and expires quickly. Run `ucloud auth login` again and complete the flow promptly. | +| `state mismatch: the pasted URL likely comes from a previous login attempt` | You pasted a callback URL from an earlier attempt. Run `ucloud auth login` again and paste the URL from THIS attempt. | +| `Login expired for profile ''` | The refresh token is no longer valid. Run `ucloud auth login` again. | +| `cannot reach oauth server ... (check network or proxy settings)` | Network or proxy issue. Check connectivity and your `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` settings. | +| `'ucloud auth login' requires an interactive terminal` | You are in CI or piping stdin. OAuth login is for interactive humans; use an AK/SK profile instead. | +| Browser did not open | Copy the URL printed in the terminal and open it manually, or use `--no-browser`. | +| (manual mode) browser shows the localhost page cannot open | Expected — the CLI is not listening in manual mode. Copy the full URL from the address bar and paste it into the terminal. | +| (manual mode) the localhost page shows unexpected content from another local program | Harmless — something else happens to listen on that port. Only the URL in the address bar matters; copy and paste it. | + ## For example I want to create a uhost in Nigeria (region: air-nigeria) and bind a public IP, and then configure GlobalSSH to accelerate efficiency of SSH service beyond China mainland. @@ -225,3 +302,39 @@ gssh[uga-0psxxx] created $ ssh root@152.32.140.92.ipssh.net root@152.32.140.92.ipssh.net's password: password of the uhost instance ``` + +Using command "ucloud api" to call any API.Fill in the parameters of an API in sequence according to the API documentation. This command is quite special, and public parameters such as --public-key,--private-key,--debug,--profile,--timeout-sec are not supported. If you want to tune on debug mode, set environment variable $UCLOUD_CLI_DEBUG=on + +``` +$ ucloud api --Action --Param1 --Param2 ... +``` +You can also put those API parameters into a json file, like this. +``` +$ ucloud api --local-file ./create_uhost.json + +//content of file create_uhost.json +{ + "Action":"CreateUHostInstance", + "Region":"cn-bj2", + "Zone":"cn-bj2-02", + "ImageId":"uimage-gk2x3x", + "NetworkInterface": [{ + "EIP":{ + "Bandwidth":1, + "OperatorName":"Bgp", + "PayMode": "Bandwidth" + } + }], + "LoginMode":"Password", + "Password":"dGVzdGx4ajEy", + "CPU":1, + "Memory":2048, + "Disks":[ + { + "Size":20, + "Type":"LOCAL_NORMAL", + "IsBoot":"true" + } + ] +} +``` diff --git a/ansi/code.go b/ansi/code.go deleted file mode 100644 index f702a87516..0000000000 --- a/ansi/code.go +++ /dev/null @@ -1,34 +0,0 @@ -//Package ansi reference https://github.com/sindresorhus/ansi-escapes -package ansi - -import ( - "fmt" -) - -const csi = "\x1b[" - -const sep = ";" - -//CursorLeft move cursor to the left side -var CursorLeft = fmt.Sprintf("%sG", csi) - -//EraseDown Erase the screen from the current line down to the bottom of the -var EraseDown = fmt.Sprintf("%sJ", csi) - -//EraseUp Erase the screen from the current line up to the top of the screen -var EraseUp = fmt.Sprintf("%s1J", csi) - -//CursorUp Move cursor up a specific amount of rows. -func CursorUp(count int) string { - return fmt.Sprintf("%s%dA", csi, count) -} - -//CursorPrevLine Move cursor up a specific amount of rows. -func CursorPrevLine(count int) string { - return fmt.Sprintf("%s%dF", csi, count) -} - -//CursorTo Set the absolute position of the cursor. `x` `y` is the top left of the screen. -func CursorTo(x, y int) string { - return fmt.Sprintf("%s%d;%dH", csi, y+1, x+1) -} diff --git a/base/client.go b/base/client.go deleted file mode 100644 index 761174eda3..0000000000 --- a/base/client.go +++ /dev/null @@ -1,112 +0,0 @@ -package base - -import ( - ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" - pudb "github.com/ucloud/ucloud-sdk-go/private/services/udb" - puhost "github.com/ucloud/ucloud-sdk-go/private/services/uhost" - pumem "github.com/ucloud/ucloud-sdk-go/private/services/umem" - "github.com/ucloud/ucloud-sdk-go/services/pathx" - "github.com/ucloud/ucloud-sdk-go/services/uaccount" - "github.com/ucloud/ucloud-sdk-go/services/udb" - "github.com/ucloud/ucloud-sdk-go/services/udisk" - "github.com/ucloud/ucloud-sdk-go/services/udpn" - "github.com/ucloud/ucloud-sdk-go/services/uhost" - "github.com/ucloud/ucloud-sdk-go/services/ulb" - "github.com/ucloud/ucloud-sdk-go/services/umem" - "github.com/ucloud/ucloud-sdk-go/services/unet" - "github.com/ucloud/ucloud-sdk-go/services/uphost" - "github.com/ucloud/ucloud-sdk-go/services/vpc" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - "github.com/ucloud/ucloud-sdk-go/ucloud/auth" - "github.com/ucloud/ucloud-sdk-go/ucloud/request" -) - -//PrivateUHostClient 私有模块的uhost client 即未在官网开放的接口 -type PrivateUHostClient = puhost.UHostClient - -//PrivateUDBClient 私有模块的udb client 即未在官网开放的接口 -type PrivateUDBClient = pudb.UDBClient - -//PrivateUMemClient 私有模块的umem client 即未在官网开放的接口 -type PrivateUMemClient = pumem.UMemClient - -//PrivatePathxClient 私有模块的pathx client 即未在官网开放的接口 -type PrivatePathxClient = ppathx.PathXClient - -//Client aggregate client for business -type Client struct { - uaccount.UAccountClient - uhost.UHostClient - unet.UNetClient - vpc.VPCClient - udpn.UDPNClient - pathx.PathXClient - udisk.UDiskClient - ulb.ULBClient - udb.UDBClient - umem.UMemClient - uphost.UPHostClient - PrivateUHostClient - PrivateUDBClient - PrivateUMemClient - PrivatePathxClient -} - -// NewClient will return a aggregate client -func NewClient(config *sdk.Config, credential *auth.Credential) *Client { - var handler sdk.RequestHandler = func(c *sdk.Client, req request.Common) (request.Common, error) { - err := req.SetProjectId(PickResourceID(req.GetProjectId())) - return req, err - } - var ( - uaccountClient = *uaccount.NewClient(config, credential) - uhostClient = *uhost.NewClient(config, credential) - unetClient = *unet.NewClient(config, credential) - vpcClient = *vpc.NewClient(config, credential) - udpnClient = *udpn.NewClient(config, credential) - pathxClient = *pathx.NewClient(config, credential) - udiskClient = *udisk.NewClient(config, credential) - ulbClient = *ulb.NewClient(config, credential) - udbClient = *udb.NewClient(config, credential) - umemClient = *umem.NewClient(config, credential) - uphostClient = *uphost.NewClient(config, credential) - puhostClient = *puhost.NewClient(config, credential) - pudbClient = *pudb.NewClient(config, credential) - pumemClient = *pumem.NewClient(config, credential) - ppathxClient = *ppathx.NewClient(config, credential) - ) - - uaccountClient.Client.AddRequestHandler(handler) - uhostClient.Client.AddRequestHandler(handler) - unetClient.Client.AddRequestHandler(handler) - vpcClient.Client.AddRequestHandler(handler) - udpnClient.Client.AddRequestHandler(handler) - pathxClient.Client.AddRequestHandler(handler) - udiskClient.Client.AddRequestHandler(handler) - ulbClient.Client.AddRequestHandler(handler) - udbClient.Client.AddRequestHandler(handler) - umemClient.Client.AddRequestHandler(handler) - uphostClient.Client.AddRequestHandler(handler) - puhostClient.Client.AddRequestHandler(handler) - pudbClient.Client.AddRequestHandler(handler) - pumemClient.Client.AddRequestHandler(handler) - ppathxClient.Client.AddRequestHandler(handler) - - return &Client{ - uaccountClient, - uhostClient, - unetClient, - vpcClient, - udpnClient, - pathxClient, - udiskClient, - ulbClient, - udbClient, - umemClient, - uphostClient, - puhostClient, - pudbClient, - pumemClient, - ppathxClient, - } -} diff --git a/base/config.go b/base/config.go deleted file mode 100644 index f7323b11dc..0000000000 --- a/base/config.go +++ /dev/null @@ -1,675 +0,0 @@ -package base - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "os" - "strings" - "time" - - "github.com/ucloud/ucloud-sdk-go/services/uaccount" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - "github.com/ucloud/ucloud-sdk-go/ucloud/auth" - "github.com/ucloud/ucloud-sdk-go/ucloud/log" -) - -//ConfigFilePath path of config.json -var ConfigFilePath = fmt.Sprintf("%s/%s", GetConfigDir(), "config.json") - -//CredentialFilePath path of credential.json -var CredentialFilePath = fmt.Sprintf("%s/%s", GetConfigDir(), "credential.json") - -//LocalFileMode file mode of $HOME/ucloud/* -const LocalFileMode os.FileMode = 0600 - -//DefaultTimeoutSec default timeout for requesting api, 15s -const DefaultTimeoutSec = 15 - -//DefaultMaxRetryTimes default timeout for requesting api, 15s -const DefaultMaxRetryTimes = 3 - -//DefaultBaseURL location of api server -const DefaultBaseURL = "https://api.ucloud.cn/" - -//DefaultProfile name of default profile -const DefaultProfile = "default" - -//Version 版本号 -const Version = "0.1.29" - -//ConfigIns 配置实例, 程序加载时生成 -var ConfigIns = &AggConfig{ - Profile: DefaultProfile, - BaseURL: DefaultBaseURL, - Timeout: DefaultTimeoutSec, - MaxRetryTimes: sdk.Int(DefaultMaxRetryTimes), -} - -//AggConfigListIns 配置列表, 进程启动时从本地文件加载 -var AggConfigListIns = &AggConfigManager{} - -//ClientConfig 创建sdk client参数 -var ClientConfig *sdk.Config - -//AuthCredential 创建sdk client参数 -var AuthCredential *auth.Credential - -//BizClient 用于调用业务接口 -var BizClient *Client - -//Global 全局flag -var Global GlobalFlag - -//GlobalFlag 几乎所有接口都需要的参数,例如 region zone projectID -type GlobalFlag struct { - Debug bool - JSON bool - Version bool - Completion bool - Config bool - Signup bool - Profile string - PublicKey string - PrivateKey string - BaseURL string - Timeout int - MaxRetryTimes int -} - -//CLIConfig cli_config element -type CLIConfig struct { - ProjectID string `json:"project_id"` - Region string `json:"region"` - Zone string `json:"zone"` - BaseURL string `json:"base_url"` - Timeout int `json:"timeout_sec"` - Profile string `json:"profile"` - Active bool `json:"active"` //是否生效 - MaxRetryTimes *int `json:"max_retry_times"` - AgreeUploadLog bool `json:"agree_upload_log"` -} - -//CredentialConfig credential element -type CredentialConfig struct { - PublicKey string `json:"public_key"` - PrivateKey string `json:"private_key"` - Profile string `json:"profile"` -} - -//AggConfig 聚合配置 config+credential -type AggConfig struct { - Profile string `json:"profile"` - Active bool `json:"active"` - ProjectID string `json:"project_id"` - Region string `json:"region"` - Zone string `json:"zone"` - BaseURL string `json:"base_url"` - Timeout int `json:"timeout_sec"` - PublicKey string `json:"public_key"` - PrivateKey string `json:"private_key"` - MaxRetryTimes *int `json:"max_retry_times"` - AgreeUploadLog bool `json:"agree_upload_log"` -} - -//ConfigPublicKey 输入公钥 -func (p *AggConfig) ConfigPublicKey() error { - Cxt.Print("Your public-key:") - _, err := fmt.Scanf("%s\n", &p.PublicKey) - if err != nil { - Cxt.Println(err) - return err - } - p.PublicKey = strings.TrimSpace(p.PublicKey) - AuthCredential.PublicKey = p.PublicKey - return nil -} - -//ConfigPrivateKey 输入私钥 -func (p *AggConfig) ConfigPrivateKey() error { - Cxt.Print("Your private-key:") - _, err := fmt.Scanf("%s\n", &p.PrivateKey) - if err != nil { - Cxt.Println(err) - return err - } - p.PrivateKey = strings.TrimSpace(p.PrivateKey) - AuthCredential.PrivateKey = p.PrivateKey - return nil -} - -//ConfigBaseURL 输入BaseURL -func (p *AggConfig) ConfigBaseURL() error { - fmt.Printf("Default base-url(%s):", DefaultBaseURL) - _, err := fmt.Scanf("%s\n", &p.BaseURL) - if err != nil { - return err - } - p.BaseURL = strings.TrimSpace(p.BaseURL) - if len(p.BaseURL) == 0 { - p.BaseURL = DefaultBaseURL - } - return nil -} - -//ConfigUploadLog agree upload log or not -func (p *AggConfig) ConfigUploadLog() error { - var input string - fmt.Print("Do you agree to upload log in local file ~/.ucloud/cli.log to help ucloud-cli get better(yes|no):") - _, err := fmt.Scanf("%s\n", &input) - if err != nil { - HandleError(err) - return err - } - - if str := strings.ToLower(input); str == "y" || str == "ye" || str == "yes" { - p.AgreeUploadLog = true - } - return nil -} - -//GetClientConfig 用来生成sdkClient -func (p *AggConfig) GetClientConfig(isDebug bool) *sdk.Config { - clientConfig := &sdk.Config{ - Region: p.Region, - ProjectId: p.ProjectID, - BaseUrl: ClientConfig.BaseUrl, - Timeout: ClientConfig.Timeout, - UserAgent: ClientConfig.UserAgent, - LogLevel: ClientConfig.LogLevel, - } - if isDebug == true { - clientConfig.LogLevel = log.DebugLevel - } - return clientConfig -} - -//GetCredential 用来生成SDkClient -func (p *AggConfig) GetCredential() *auth.Credential { - return &auth.Credential{ - PublicKey: p.PublicKey, - PrivateKey: p.PrivateKey, - } -} - -func (p *AggConfig) copyToCLIConfig(target *CLIConfig) { - target.Profile = p.Profile - target.BaseURL = p.BaseURL - target.Timeout = p.Timeout - target.ProjectID = p.ProjectID - target.Region = p.Region - target.Zone = p.Zone - target.Active = p.Active - target.MaxRetryTimes = p.MaxRetryTimes - target.AgreeUploadLog = p.AgreeUploadLog -} - -func (p *AggConfig) copyToCredentialConfig(target *CredentialConfig) { - target.Profile = p.Profile - target.PrivateKey = p.PrivateKey - target.PublicKey = p.PublicKey -} - -//AggConfigManager 配置管理 -type AggConfigManager struct { - activeProfile string - configs map[string]*AggConfig - configFile *os.File - credFile *os.File -} - -//NewAggConfigManager create instance -func NewAggConfigManager(cfgFile, credFile *os.File) (*AggConfigManager, error) { - manager := &AggConfigManager{ - configs: make(map[string]*AggConfig), - configFile: cfgFile, - credFile: credFile, - } - - err := manager.Load() - if err != nil { - if !os.IsNotExist(err) { - return manager, err - } - - aerr := adaptOldConfig() - if aerr != nil { - HandleError(fmt.Errorf("adapt to old config failed: %v", aerr)) - return manager, aerr - } - - err := manager.Load() - if err != nil { - HandleError(fmt.Errorf("retry to load cli config failed: %v", err)) - return manager, err - } - } - return manager, nil -} - -//Append config to list, override if already exist the same profile -func (p *AggConfigManager) Append(config *AggConfig) error { - if _, ok := p.configs[config.Profile]; ok { - return fmt.Errorf("profile [%s] exists already", config.Profile) - } - - if config.Active && config.Profile != p.activeProfile { - if ac, ok := p.configs[p.activeProfile]; ok { - ac.Active = false - } - p.activeProfile = config.Profile - } - p.configs[config.Profile] = config - return p.Save() -} - -//UpdateAggConfig update AggConfig append if not exist -func (p *AggConfigManager) UpdateAggConfig(config *AggConfig) error { - if _, ok := p.configs[config.Profile]; !ok { - return p.Append(config) - } - - if config.Active && config.Profile != p.activeProfile { - if ac, ok := p.configs[p.activeProfile]; ok { - ac.Active = false - } - p.activeProfile = config.Profile - } - return p.Save() -} - -//Load AggConfigList from local file $HOME/.ucloud/config.json+credential.json -func (p *AggConfigManager) Load() error { - configs, err := p.parseCLIConfigs() - if err != nil { - return fmt.Errorf("read config failed: %v", err) - } - credentials, err := p.parseCredentials() - if err != nil { - return fmt.Errorf("read credential failed: %v", err) - } - - //key: profile , value: CLIConfig - configMap := make(map[string]*CLIConfig) - for _, config := range configs { - c := config - configMap[config.Profile] = &c - if config.Active { - p.activeProfile = config.Profile - } - } - credMap := make(map[string]*CredentialConfig) - for _, cred := range credentials { - c := cred - credMap[cred.Profile] = &c - } - - for profile, config := range configMap { - cred, ok := credMap[profile] - if !ok { - LogError("profile: %s don't exist in credential") - continue - } - - p.configs[profile] = &AggConfig{ - PrivateKey: cred.PrivateKey, - PublicKey: cred.PublicKey, - Profile: config.Profile, - ProjectID: config.ProjectID, - Region: config.Region, - Zone: config.Zone, - BaseURL: config.BaseURL, - Timeout: config.Timeout, - Active: config.Active, - MaxRetryTimes: config.MaxRetryTimes, - AgreeUploadLog: config.AgreeUploadLog, - } - } - - if p.activeProfile == "" && len(configMap) > 0 { - return fmt.Errorf("no active config found, run 'ucloud config list' to check") - } - if _, ok := credMap[p.activeProfile]; p.activeProfile != "" && !ok { - return fmt.Errorf("profile %s's credential don't exist, run 'ucloud config list' to check", p.activeProfile) - } - - return nil -} - -//Save configs to local file -func (p *AggConfigManager) Save() error { - clics := []*CLIConfig{} - credcs := []*CredentialConfig{} - for _, aggConfig := range p.configs { - cliConfig := &CLIConfig{} - aggConfig.copyToCLIConfig(cliConfig) - clics = append(clics, cliConfig) - - credConfig := &CredentialConfig{} - aggConfig.copyToCredentialConfig(credConfig) - credcs = append(credcs, credConfig) - } - aerr := WriteJSONFile(clics, p.configFile.Name()) - berr := WriteJSONFile(credcs, p.credFile.Name()) - - if aerr != nil && berr != nil { - return fmt.Errorf("save cli config failed: %v | save credentail failed: %v", aerr, berr) - } - if aerr != nil { - return fmt.Errorf("save cli config failed: %v", aerr) - } - if berr != nil { - return fmt.Errorf("save cerdentail failed: %v", berr) - } - return nil -} - -//DeleteByProfile 从AggConfigList和本地文件中删除此配置 -func (p *AggConfigManager) DeleteByProfile(profile string) error { - if _, ok := p.configs[profile]; !ok { - return fmt.Errorf("profile: %s is not exist", profile) - } - - ac := p.configs[profile] - if ac.Active { - return fmt.Errorf("can't delete active profile") - } - - delete(p.configs, profile) - - err := p.Save() - if err != nil { - return fmt.Errorf("delete profile %s failed: %v", profile, err) - } - return nil -} - -//GetProfileNameList 获取所有profiles 用于ucloud config --profile 补全 -func (p *AggConfigManager) GetProfileNameList() []string { - profiles := []string{} - for _, item := range p.configs { - profiles = append(profiles, item.Profile) - } - return profiles -} - -//GetAggConfigList get all profile config -func (p *AggConfigManager) GetAggConfigList() []AggConfig { - configs := []AggConfig{} - for _, cfg := range p.configs { - configs = append(configs, *cfg) - } - return configs -} - -//GetAggConfigByProfile get config of specify profile -func (p *AggConfigManager) GetAggConfigByProfile(profile string) (*AggConfig, bool) { - if ac, ok := p.configs[profile]; ok { - return ac, true - } - return nil, false -} - -//GetActiveAggConfig get active agg config -func (p *AggConfigManager) GetActiveAggConfig() (*AggConfig, error) { - if ac, ok := p.configs[p.activeProfile]; ok { - return ac, nil - } - return nil, fmt.Errorf("active profile not found. see 'ucloud config list'") -} - -//GetActiveAggConfigName get active config name -func (p *AggConfigManager) GetActiveAggConfigName() string { - if ac, ok := p.configs[p.activeProfile]; ok { - return ac.Profile - } - return "" -} - -func (p *AggConfigManager) parseCLIConfigs() ([]CLIConfig, error) { - var configs []CLIConfig - rawConfig, err := ioutil.ReadAll(p.configFile) - if err != nil { - return nil, err - } - if len(rawConfig) == 0 { - return nil, nil - } - - err = json.Unmarshal(rawConfig, &configs) - if err != nil { - return nil, fmt.Errorf("parse cli config faild: %v", err) - } - //特殊处理未配置max_retry_times的情况,v0.1.21之前硬编码重试次数为3 - for idx := range configs { - if configs[idx].MaxRetryTimes == nil { - configs[idx].MaxRetryTimes = sdk.Int(DefaultMaxRetryTimes) - } - } - return configs, nil -} - -func (p *AggConfigManager) parseCredentials() ([]CredentialConfig, error) { - var credentials []CredentialConfig - rawCred, err := ioutil.ReadAll(p.credFile) - if err != nil { - return nil, err - } - - if len(rawCred) == 0 { - return nil, nil - } - - err = json.Unmarshal(rawCred, &credentials) - if err != nil { - return nil, fmt.Errorf("parse credential failed: %v", err) - } - return credentials, nil -} - -//ListAggConfig ucloud --config + ucloud config list -func ListAggConfig(json bool) { - aggConfigs := AggConfigListIns.GetAggConfigList() - for idx, ac := range aggConfigs { - aggConfigs[idx].PrivateKey = MosaicString(ac.PrivateKey, 8, 5) - aggConfigs[idx].PublicKey = MosaicString(ac.PublicKey, 8, 5) - } - if json { - PrintJSON(aggConfigs, os.Stdout) - } else { - PrintTableS(aggConfigs) - } -} - -//LoadUserInfo 从~/.ucloud/user.json加载用户信息 -func LoadUserInfo() (*uaccount.UserInfo, error) { - filePath := GetConfigDir() + "/user.json" - if _, err := os.Stat(filePath); os.IsNotExist(err) { - return nil, fmt.Errorf("user.json is not exist") - } - content, err := ioutil.ReadFile(filePath) - if err != nil { - return nil, err - } - var user uaccount.UserInfo - err = json.Unmarshal(content, &user) - if err != nil { - return nil, err - } - return &user, nil -} - -//GetUserInfo from local file and remote api -func GetUserInfo() (*uaccount.UserInfo, error) { - user, err := LoadUserInfo() - if err == nil { - return user, nil - } - - req := BizClient.NewGetUserInfoRequest() - resp, err := BizClient.GetUserInfo(req) - - if err != nil { - return nil, err - } - - if len(resp.DataSet) == 1 { - user = &resp.DataSet[0] - bytes, err := json.Marshal(user) - if err != nil { - return nil, err - } - fileFullPath := GetConfigDir() + "/user.json" - err = ioutil.WriteFile(fileFullPath, bytes, 0600) - if err != nil { - return nil, err - } - } else { - return nil, fmt.Errorf("GetUserInfo DataSet length: %d", len(resp.DataSet)) - } - return user, nil -} - -//OldConfig 0.1.7以及之前版本的配置struct -type OldConfig struct { - PublicKey string `json:"public_key"` - PrivateKey string `json:"private_key"` - Region string `json:"region"` - Zone string `json:"zone"` - ProjectID string `json:"project_id"` -} - -//Load 从本地文件加载配置 -func (p *OldConfig) Load() error { - if _, err := os.Stat(ConfigFilePath); os.IsNotExist(err) { - p = new(OldConfig) - } else { - content, err := ioutil.ReadFile(ConfigFilePath) - if err != nil { - return err - } - json.Unmarshal(content, p) - } - return nil -} - -func adaptOldConfig() error { - oc := &OldConfig{} - err := oc.Load() - if err != nil { - return err - } - ac := &AggConfig{ - Profile: DefaultProfile, - ProjectID: oc.ProjectID, - Region: oc.Region, - Zone: oc.Zone, - BaseURL: DefaultBaseURL, - Timeout: DefaultTimeoutSec, - Active: true, - PrivateKey: oc.PrivateKey, - PublicKey: oc.PublicKey, - MaxRetryTimes: sdk.Int(DefaultMaxRetryTimes), - } - err = os.Rename(ConfigFilePath, ConfigFilePath+".old") - if err != nil { - return err - } - return AggConfigListIns.Append(ac) -} - -func init() { - bc, err := GetBizClient(ConfigIns) - if err != nil { - HandleError(err) - } - BizClient = bc -} - -//GetBizClient 初始化BizClient -func GetBizClient(ac *AggConfig) (*Client, error) { - timeout, err := time.ParseDuration(fmt.Sprintf("%ds", ac.Timeout)) - if err != nil { - err = fmt.Errorf("parse timeout %ds failed: %v", ac.Timeout, err) - } - ClientConfig = &sdk.Config{ - BaseUrl: ac.BaseURL, - Timeout: timeout, - UserAgent: fmt.Sprintf("UCloud-CLI/%s", Version), - LogLevel: log.FatalLevel, - Region: ac.Region, - ProjectId: ac.ProjectID, - MaxRetries: *ac.MaxRetryTimes, - } - AuthCredential = &auth.Credential{ - PublicKey: ac.PublicKey, - PrivateKey: ac.PrivateKey, - } - return NewClient(ClientConfig, AuthCredential), err -} - -//InitConfig 初始化配置 -func InitConfig() { - configFile, err := os.OpenFile(ConfigFilePath, os.O_CREATE|os.O_RDONLY, LocalFileMode) - if err != nil && !os.IsNotExist(err) { - HandleError(err) - } - credFile, err := os.OpenFile(CredentialFilePath, os.O_CREATE|os.O_RDONLY, LocalFileMode) - if err != nil && !os.IsNotExist(err) { - HandleError(err) - } - - AggConfigListIns, err = NewAggConfigManager(configFile, credFile) - if err != nil { - LogError(err.Error()) - } else { - var ins *AggConfig - if Global.Profile == "" { - ins, err = AggConfigListIns.GetActiveAggConfig() - if err != nil && len(AggConfigListIns.GetAggConfigList()) != 0 { - HandleError(err) - } - } else { - ins, _ = AggConfigListIns.GetAggConfigByProfile(Global.Profile) - } - - if ins != nil { - ConfigIns = ins - } - - mergeConfigIns(ConfigIns) - logCmd() - - bc, err := GetBizClient(ConfigIns) - if err != nil { - HandleError(err) - } else { - BizClient = bc - } - } -} - -func mergeConfigIns(ins *AggConfig) { - if Global.BaseURL != "" { - ins.BaseURL = Global.BaseURL - } - if Global.Timeout != 0 { - ins.Timeout = Global.Timeout - } - if Global.MaxRetryTimes != -1 { - ins.MaxRetryTimes = sdk.Int(Global.MaxRetryTimes) - } - - if Global.PublicKey != "" && Global.PrivateKey != "" { - ins.PrivateKey = Global.PrivateKey - ins.PublicKey = Global.PublicKey - } -} - -func init() { - //配置日志 - err := initLog() - if err != nil { - fmt.Println(err) - } -} diff --git a/base/config_test.go b/base/config_test.go deleted file mode 100644 index 1bb244ee8b..0000000000 --- a/base/config_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package base - -import ( - "io/ioutil" - "os" - "testing" -) - -const cliConfigJSON = `[ - {"project_id":"org-bdks4e","region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"profile":"uweb","active":true}, - {"project_id":"org-oxjwoi","region":"hk","zone":"hk-02","base_url":"https://api.ucloud.cn/","timeout_sec":15,"profile":"test","active":false} -]` - -const credentialJSON = `[ - {"public_key":"4E9UU*****3ZAPWQ==","private_key":"6945*****a0d45","profile":"uweb"}, - {"public_key":"YSQG*****zgnCRQ=","private_key":"jtma*****Avms","profile":"test"} -]` - -func TestAggConfigManager(t *testing.T) { - os.MkdirAll(".ucloud", 0700) - err := ioutil.WriteFile(".ucloud/config.json", []byte(cliConfigJSON), LocalFileMode) - if err != nil { - t.Error(err) - } - err = ioutil.WriteFile(".ucloud/credential.json", []byte(credentialJSON), LocalFileMode) - if err != nil { - t.Error(err) - } - defer func() { - err := os.RemoveAll(".ucloud") - if err != nil { - t.Error(err) - } - }() - - configFile, err := os.OpenFile(".ucloud/config.json", os.O_CREATE|os.O_RDONLY, LocalFileMode) - if err != nil { - t.Error(err) - } - - credFile, err := os.OpenFile(".ucloud/credential.json", os.O_CREATE|os.O_RDONLY, LocalFileMode) - if err != nil { - t.Error(err) - } - - acManager, err := NewAggConfigManager(configFile, credFile) - if err != nil { - t.Error(err) - } - - if len(acManager.configs) != 2 { - t.Errorf("expect length of configs is 2, accpet %d", len(acManager.configs)) - } - -} - -func TestEmptyAggConfigManager(t *testing.T) { - os.MkdirAll(".ucloud", 0700) - defer func() { - err := os.RemoveAll(".ucloud") - if err != nil { - t.Error(err) - } - }() - - configFile, err := os.OpenFile(".ucloud/config.json", os.O_CREATE|os.O_RDONLY, LocalFileMode) - if err != nil { - t.Error(err) - } - - credFile, err := os.OpenFile(".ucloud/credential.json", os.O_CREATE|os.O_RDONLY, LocalFileMode) - if err != nil { - t.Error(err) - } - - acManager, err := NewAggConfigManager(configFile, credFile) - if err != nil { - t.Error(err) - } - - err = acManager.Load() - if err != nil { - t.Fatal(err) - } - - if len(acManager.configs) != 0 { - t.Errorf("expect length of configs is 2, accpet %d", len(acManager.configs)) - } -} diff --git a/base/log.go b/base/log.go deleted file mode 100644 index d41774aa16..0000000000 --- a/base/log.go +++ /dev/null @@ -1,294 +0,0 @@ -package base - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "os" - "runtime" - "strings" - "sync" - "time" - - uuid "github.com/satori/go.uuid" - log "github.com/sirupsen/logrus" - - "github.com/ucloud/ucloud-sdk-go/ucloud/request" - "github.com/ucloud/ucloud-sdk-go/ucloud/version" -) - -const DefaultDasURL = "https://das-rpt.ucloud.cn/log" - -//Logger 日志 -var logger *log.Logger -var mu sync.Mutex -var out = Cxt.GetWriter() -var tracer = Tracer{DefaultDasURL} - -func initConfigDir() { - if _, err := os.Stat(GetLogFileDir()); os.IsNotExist(err) { - err := os.MkdirAll(GetLogFileDir(), LocalFileMode) - if err != nil { - panic(err) - } - } -} - -func initLog() error { - initConfigDir() - file, err := os.OpenFile(GetLogFilePath(), os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - return fmt.Errorf("open log file failed: %v", err) - } - logger = log.New() - logger.SetNoLock() - logger.AddHook(NewLogRotateHook(file)) - logger.SetOutput(file) - - return nil -} - -func logCmd() { - args := make([]string, len(os.Args)) - copy(args, os.Args) - for idx, arg := range args { - for _, word := range []string{"password", "private-key", "public-key"} { - if strings.Contains(arg, word) && idx <= len(args)-2 { - args[idx+1] = strings.Repeat("*", 8) - } - } - } - LogInfo(fmt.Sprintf("command: %s", strings.Join(args, " "))) -} - -//GetLogger return point of logger -func GetLogger() *log.Logger { - return logger -} - -//GetLogFileDir 获取日志文件路径 -func GetLogFileDir() string { - return GetHomePath() + fmt.Sprintf("/%s", ConfigPath) -} - -//GetLogFilePath 获取日志文件路径 -func GetLogFilePath() string { - return GetHomePath() + fmt.Sprintf("/%s/cli.log", ConfigPath) -} - -//LogInfo 记录日志 -func LogInfo(logs ...string) { - _, ok := os.LookupEnv("COMP_LINE") - if ok { - return - } - mu.Lock() - defer mu.Unlock() - goID := curGoroutineID() - for _, line := range logs { - logger.WithField("goroutine_id", goID).Info(line) - } - if ConfigIns.AgreeUploadLog { - UploadLogs(logs, "info", goID) - } -} - -//LogPrint 记录日志 -func LogPrint(logs ...string) { - _, ok := os.LookupEnv("COMP_LINE") - if ok { - return - } - mu.Lock() - defer mu.Unlock() - goID := curGoroutineID() - for _, line := range logs { - logger.WithField("goroutine_id", goID).Print(line) - fmt.Fprintln(out, line) - } - if ConfigIns.AgreeUploadLog { - UploadLogs(logs, "print", goID) - } -} - -//LogWarn 记录日志 -func LogWarn(logs ...string) { - _, ok := os.LookupEnv("COMP_LINE") - if ok { - return - } - mu.Lock() - defer mu.Unlock() - goID := curGoroutineID() - for _, line := range logs { - logger.WithField("goroutine_id", goID).Warn(line) - fmt.Fprintln(out, line) - } - if ConfigIns.AgreeUploadLog { - UploadLogs(logs, "warn", goID) - } -} - -//LogError 记录日志 -func LogError(logs ...string) { - _, ok := os.LookupEnv("COMP_LINE") - if ok { - return - } - mu.Lock() - defer mu.Unlock() - goID := curGoroutineID() - for _, line := range logs { - logger.WithField("goroutine_id", goID).Error(line) - fmt.Fprintln(out, line) - } - if ConfigIns.AgreeUploadLog { - UploadLogs(logs, "error", goID) - } -} - -//UploadLogs send logs to das server -func UploadLogs(logs []string, level string, goID int64) { - var lines []string - for _, log := range logs { - line := fmt.Sprintf("time=%s level=%s goroutine_id=%d msg=%s", time.Now().Format(time.RFC3339Nano), level, goID, log) - lines = append(lines, line) - } - tracer.Send(lines) -} - -//LogRotateHook rotate log file -type LogRotateHook struct { - MaxSize int64 - Cut float32 - LogFile *os.File - mux sync.Mutex -} - -//Levels fires hook -func (hook *LogRotateHook) Levels() []log.Level { - return log.AllLevels -} - -//Fire do someting when hook is triggered -func (hook *LogRotateHook) Fire(entry *log.Entry) error { - hook.mux.Lock() - defer hook.mux.Unlock() - info, err := hook.LogFile.Stat() - if err != nil { - return err - } - - if info.Size() <= hook.MaxSize { - return nil - } - hook.LogFile.Sync() - offset := int64(float32(hook.MaxSize) * hook.Cut) - buf := make([]byte, info.Size()-offset) - _, err = hook.LogFile.ReadAt(buf, offset) - if err != nil { - return err - } - - nfile, err := os.Create(GetLogFilePath() + ".tmp") - if err != nil { - return err - } - nfile.Write(buf) - nfile.Close() - - err = os.Rename(GetLogFilePath()+".tmp", GetLogFilePath()) - if err != nil { - return err - } - - mfile, err := os.OpenFile(GetLogFilePath(), os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - fmt.Println("open log file failed: ", err) - return err - } - entry.Logger.SetOutput(mfile) - return nil -} - -//NewLogRotateHook create a LogRotateHook -func NewLogRotateHook(file *os.File) *LogRotateHook { - return &LogRotateHook{ - MaxSize: 1024 * 1024, //1MB - Cut: 0.2, - LogFile: file, - } -} - -//ToQueryMap tranform request to map -func ToQueryMap(req request.Common) map[string]string { - reqMap, err := request.ToQueryMap(req) - if err != nil { - return nil - } - delete(reqMap, "Password") - return reqMap -} - -//Tracer upload log to server if allowed -type Tracer struct { - DasUrl string -} - -func (t Tracer) wrapLogs(log []string) ([]byte, error) { - dataSet := make([]map[string]interface{}, 0) - dataItem := map[string]interface{}{ - "level": "info", - "topic": "api", - "log": log, - } - dataSet = append(dataSet, dataItem) - reqUUID := uuid.NewV4() - sessionID := uuid.NewV4() - user, err := GetUserInfo() - if err != nil { - return nil, err - } - payload := map[string]interface{}{ - "aid": "iywtleaa", - "uuid": reqUUID, - "sid": sessionID, - "ds": dataSet, - "cs": map[string]interface{}{ - "uname": user.UserEmail, - }, - } - marshaled, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("cannot to marshal log: %s", err) - } - return marshaled, nil -} - -//Send logs to server -func (t Tracer) Send(logs []string) error { - body, err := t.wrapLogs(logs) - if err != nil { - return err - } - for i := 0; i < len(body); i++ { - body[i] = ^body[i] - } - - client := &http.Client{} - ua := fmt.Sprintf("GO/%s GO-SDK/%s %s", runtime.Version(), version.Version, ClientConfig.UserAgent) - req, err := http.NewRequest("POST", t.DasUrl, bytes.NewReader(body)) - req.Header.Add("Origin", "https://sdk.ucloud.cn") - req.Header.Add("User-Agent", ua) - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return fmt.Errorf("send logs failed: status %d %s", resp.StatusCode, resp.Status) - } - - return nil -} diff --git a/base/util.go b/base/util.go deleted file mode 100644 index 34ef15da03..0000000000 --- a/base/util.go +++ /dev/null @@ -1,647 +0,0 @@ -package base - -import ( - "bufio" - "encoding/json" - "fmt" - "io" - "io/ioutil" - "os" - "reflect" - "runtime" - "strconv" - "strings" - "time" - "unicode" - - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" - "github.com/ucloud/ucloud-sdk-go/ucloud/helpers/waiter" - "github.com/ucloud/ucloud-sdk-go/ucloud/log" - "github.com/ucloud/ucloud-sdk-go/ucloud/response" - - "github.com/ucloud/ucloud-cli/model" - "github.com/ucloud/ucloud-cli/ux" -) - -//ConfigPath 配置文件路径 -const ConfigPath = ".ucloud" - -//GAP 表格列直接的间隔字符数 -const GAP = 2 - -//Cxt 上下文 -var Cxt = model.GetContext(os.Stdout) - -//SdkClient 用于上报数据 -var SdkClient *sdk.Client - -//GetHomePath 获取家目录 -func GetHomePath() string { - if runtime.GOOS == "windows" { - home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") - if home == "" { - home = os.Getenv("USERPROFILE") - } - return home - } - return os.Getenv("HOME") -} - -//MosaicString 对字符串敏感部分打马赛克 如公钥私钥 -func MosaicString(str string, beginChars, lastChars int) string { - r := len(str) - lastChars - beginChars - if r > 5 { - return str[:beginChars] + strings.Repeat("*", 5) + str[(r+beginChars):] - } - return strings.Repeat("*", len(str)) -} - -//AppendToFile 添加到文件中 -func AppendToFile(name string, content string) error { - f, err := os.OpenFile(name, os.O_RDWR|os.O_APPEND, 0) - if err != nil { - return err - } - defer f.Close() - _, err = f.WriteString(fmt.Sprintf("\n%s\n", content)) - return err -} - -//LineInFile 检查某一行是否在某文件中 -func LineInFile(fileName string, lookFor string) bool { - f, err := os.Open(fileName) - if err != nil { - return false - } - defer f.Close() - r := bufio.NewReader(f) - prefix := []byte{} - for { - line, isPrefix, err := r.ReadLine() - if err == io.EOF { - return false - } - if err != nil { - return false - } - if isPrefix { - prefix = append(prefix, line...) - continue - } - line = append(prefix, line...) - if string(line) == lookFor { - return true - } - prefix = prefix[:0] - } -} - -//GetConfigDir 获取配置文件所在目录 -func GetConfigDir() string { - path := GetHomePath() + "/" + ConfigPath - if _, err := os.Stat(path); os.IsNotExist(err) { - err = os.MkdirAll(path, 0755) - if err != nil { - panic(err) - } - } - return path -} - -//HandleBizError 处理RetCode != 0 的业务异常 -func HandleBizError(resp response.Common) error { - format := "Something wrong. RetCode:%d. Message:%s\n" - LogError(fmt.Sprintf(format, resp.GetRetCode(), resp.GetMessage())) - return fmt.Errorf(format, resp.GetRetCode(), resp.GetMessage()) -} - -//HandleError 处理错误,业务错误 和 HTTP错误 -func HandleError(err error) { - if uErr, ok := err.(uerr.Error); ok && uErr.Code() != 0 { - format := "Something wrong. RetCode:%d. Message:%s\n" - LogError(fmt.Sprintf(format, uErr.Code(), uErr.Message())) - } else { - LogError(fmt.Sprintf("%v", err)) - } -} - -//ParseError 解析错误为字符串 -func ParseError(err error) string { - if uErr, ok := err.(uerr.Error); ok && uErr.Code() != 0 { - format := "Something wrong. RetCode:%d. Message:%s" - message := uErr.Message() - if uErr.Code() == -1 || uErr.Code() == -2 { - message = "request timeout, retry later please" - } - return fmt.Sprintf(format, uErr.Code(), message) - } - return fmt.Sprintf("Error:%v", err) -} - -//PrintJSON 以JSON格式打印数据集合 -func PrintJSON(dataSet interface{}, out io.Writer) error { - bytes, err := json.MarshalIndent(dataSet, "", " ") - if err != nil { - return err - } - fmt.Fprintln(out, string(bytes)) - return nil -} - -//PrintTableS 简化版表格打印,无需传表头,根据结构体反射解析 -func PrintTableS(dataSet interface{}) { - dataSetVal := reflect.ValueOf(dataSet) - fieldNameList := make([]string, 0) - if dataSetVal.Len() > 0 { - elemType := dataSetVal.Index(0).Type() - for i := 0; i < elemType.NumField(); i++ { - fieldNameList = append(fieldNameList, elemType.Field(i).Name) - } - } - if kind := dataSetVal.Kind(); kind == reflect.Slice || kind == reflect.Array { - displaySlice(dataSetVal, fieldNameList) - } else { - panic(fmt.Sprintf("Internal error, PrintTableS expect array or slice, accept %T", dataSet)) - } -} - -//PrintList 打印表格或者JSON -func PrintList(dataSet interface{}, out io.Writer) { - if Global.JSON { - PrintJSON(dataSet, out) - } else { - PrintTableS(dataSet) - } -} - -//PrintDescribe 打印详情 -func PrintDescribe(attrs []DescribeTableRow, json bool) { - if json { - PrintJSON(attrs, os.Stdout) - } else { - for _, attr := range attrs { - fmt.Println(attr.Attribute) - fmt.Println(attr.Content) - fmt.Println() - } - } -} - -//PrintTable 以表格方式打印数据集合 -func PrintTable(dataSet interface{}, fieldList []string) { - dataSetVal := reflect.ValueOf(dataSet) - switch dataSetVal.Kind() { - case reflect.Slice, reflect.Array: - displaySlice(dataSetVal, fieldList) - default: - panic(fmt.Sprintf("PrintTable expect array,slice or map, accept %T", dataSet)) - } -} - -func displaySlice(listVal reflect.Value, fieldList []string) { - showFieldMap := make(map[string]int) - for _, field := range fieldList { - showFieldMap[field] = len([]rune(field)) - } - rowList := make([]map[string]interface{}, 0) - for i := 0; i < listVal.Len(); i++ { - elemVal := listVal.Index(i) - elemType := elemVal.Type() - rows := []map[string]interface{}{} - for j := 0; j < elemVal.NumField(); j++ { - field := elemVal.Field(j) - fieldName := elemType.Field(j).Name - if _, ok := showFieldMap[fieldName]; ok { - if field.Kind() == reflect.Ptr { - field = field.Elem() - } - text := fmt.Sprintf("%v", field.Interface()) - cells := strings.Split(text, "\n") - for i, cell := range cells { - width := calcWidth(cell) - if showFieldMap[fieldName] < width { - showFieldMap[fieldName] = width - } - if len(rows) == i { - rows = append(rows, make(map[string]interface{})) - } - rows[i][fieldName] = cell - } - } - } - rowList = append(rowList, rows...) - } - printTable(rowList, fieldList, showFieldMap) -} - -func printTable(rowList []map[string]interface{}, fieldList []string, fieldWidthMap map[string]int) { - //打印表头 - for _, field := range fieldList { - tmpl := "%-" + strconv.Itoa(fieldWidthMap[field]+GAP) + "s" - fmt.Printf(tmpl, field) - } - if len(fieldList) != 0 { - fmt.Printf("\n") - } - - //打印数据 - for _, row := range rowList { - for _, field := range fieldList { - cutWidth := calcCutWidth(fmt.Sprintf("%v", row[field])) - tmpl := "%-" + strconv.Itoa(fieldWidthMap[field]-cutWidth+GAP) + "v" - if row[field] != nil { - fmt.Printf(tmpl, row[field]) - } else { - fmt.Printf(tmpl, "") - } - } - fmt.Printf("\n") - } -} - -//DescribeTableRow 详情表格通用表格行 -type DescribeTableRow struct { - Attribute string - Content string -} - -func calcCutWidth(text string) int { - set := []*unicode.RangeTable{unicode.Han, unicode.Punct} - width := 0 - for _, r := range text { - if unicode.IsOneOf(set, r) && r > unicode.MaxLatin1 { - width++ - } - } - return width -} - -func calcWidth(text string) int { - set := []*unicode.RangeTable{unicode.Han, unicode.Punct} - width := 0 - for _, r := range text { - if unicode.IsOneOf(set, r) && r > unicode.MaxLatin1 { - width += 2 - } else { - width++ - } - } - return width -} - -//FormatDate 格式化时间,把以秒为单位的时间戳格式化未年月日 -func FormatDate(seconds int) string { - return time.Unix(int64(seconds), 0).Format("2006-01-02") -} - -//DateTimeLayout 时间格式 -const DateTimeLayout = "2006-01-02/15:04:05" - -//FormatDateTime 格式化时间,把以秒为单位的时间戳格式化未年月日/时分秒 -func FormatDateTime(seconds int) string { - return time.Unix(int64(seconds), 0).Format("2006-01-02/15:04:05") -} - -//RegionLabel regionlable -var RegionLabel = map[string]string{ - "cn-bj1": "Beijing1", - "cn-bj2": "Beijing2", - "cn-sh2": "Shanghai2", - "cn-gd": "Guangzhou", - "cn-qz": "Quanzhou", - "hk": "Hongkong", - "us-ca": "LosAngeles", - "us-ws": "Washington", - "ge-fra": "Frankfurt", - "th-bkk": "Bangkok", - "kr-seoul": "Seoul", - "sg": "Singapore", - "tw-kh": "Kaohsiung", - "rus-mosc": "Moscow", - "jpn-tky": "Tokyo", - "tw-tp": "TaiPei", - "uae-dubai": "Dubai", - "idn-jakarta": "Jakarta", - "ind-mumbai": "Bombay", - "bra-saopaulo": "SaoPaulo", - "uk-london": "London", - "afr-nigeria": "Lagos", -} - -//Poller 轮询器 -type Poller struct { - stateFields []string - DescribeFunc func(string, string, string, string) (interface{}, error) - Out io.Writer - Timeout time.Duration - SdescribeFunc func(string) (interface{}, error) -} - -type pollResult struct { - Done bool - Timeout bool - Err error -} - -//Sspoll 简化版, 支持并发 -func (p *Poller) Sspoll(resourceID, pollText string, targetStates []string, block *ux.Block) *pollResult { - w := waiter.StateWaiter{ - Pending: []string{"pending"}, - Target: []string{"avaliable"}, - Refresh: func() (interface{}, string, error) { - inst, err := p.SdescribeFunc(resourceID) - if err != nil { - return nil, "", err - } - - if inst == nil { - return nil, "pending", nil - } - instValue := reflect.ValueOf(inst) - instValue = reflect.Indirect(instValue) - instType := instValue.Type() - if instValue.Kind() != reflect.Struct { - return nil, "", fmt.Errorf("Instance is not struct") - } - state := "" - for i := 0; i < instValue.NumField(); i++ { - for _, sf := range p.stateFields { - if instType.Field(i).Name == sf { - state = instValue.Field(i).String() - } - } - } - if state != "" { - for _, t := range targetStates { - if t == state { - return inst, "avaliable", nil - } - } - } - return nil, "pending", nil - - }, - Timeout: p.Timeout, - } - - pollRetChan := make(chan pollResult) - go func() { - ret := pollResult{ - Done: true, - } - if _, err := w.Wait(); err != nil { - ret.Done = false - ret.Err = err - if _, ok := err.(*waiter.TimeoutError); ok { - ret.Timeout = true - } - } - pollRetChan <- ret - }() - - spin := ux.NewDotSpin(p.Out, pollText) - block.SetSpin(spin) - - ret := <-pollRetChan - - if ret.Timeout { - spin.Timeout() - } else { - spin.Stop() - } - return &ret -} - -//Spoll 简化版 -func (p *Poller) Spoll(resourceID, pollText string, targetStates []string) { - w := waiter.StateWaiter{ - Pending: []string{"pending"}, - Target: []string{"avaliable"}, - Refresh: func() (interface{}, string, error) { - inst, err := p.SdescribeFunc(resourceID) - if err != nil { - return nil, "", err - } - - if inst == nil { - return nil, "pending", nil - } - instValue := reflect.ValueOf(inst) - instValue = reflect.Indirect(instValue) - instType := instValue.Type() - if instValue.Kind() != reflect.Struct { - return nil, "", fmt.Errorf("Instance is not struct") - } - state := "" - for i := 0; i < instValue.NumField(); i++ { - for _, sf := range p.stateFields { - if instType.Field(i).Name == sf { - state = instValue.Field(i).String() - } - } - } - if state != "" { - for _, t := range targetStates { - if t == state { - return inst, "avaliable", nil - } - } - } - return nil, "pending", nil - - }, - Timeout: p.Timeout, - } - - done := make(chan bool) - go func() { - if _, err := w.Wait(); err != nil { - log.Error(err) - if _, ok := err.(*waiter.TimeoutError); ok { - done <- false - return - } - } - done <- true - }() - - spinner := ux.NewDotSpinner(p.Out) - spinner.Start(pollText) - ret := <-done - if ret { - spinner.Stop() - } else { - spinner.Timeout() - } -} - -//Poll function -func (p *Poller) Poll(resourceID, projectID, region, zone, pollText string, targetState []string) bool { - w := waiter.StateWaiter{ - Pending: []string{"pending"}, - Target: []string{"avaliable"}, - Refresh: func() (interface{}, string, error) { - inst, err := p.DescribeFunc(resourceID, projectID, region, zone) - if err != nil { - return nil, "", err - } - - if inst == nil { - return nil, "pending", nil - } - instValue := reflect.ValueOf(inst) - instValue = reflect.Indirect(instValue) - instType := instValue.Type() - if instValue.Kind() != reflect.Struct { - return nil, "", fmt.Errorf("Instance is not struct") - } - state := "" - for i := 0; i < instValue.NumField(); i++ { - for _, sf := range p.stateFields { - if instType.Field(i).Name == sf { - state = instValue.Field(i).String() - } - } - } - if state != "" { - for _, t := range targetState { - if t == state { - return inst, "avaliable", nil - } - } - } - return nil, "pending", nil - - }, - Timeout: p.Timeout, - } - - var err error - done := make(chan bool) - go func() { - if _, err = w.Wait(); err != nil { - done <- false - return - } - done <- true - }() - - spinner := ux.NewDotSpinner(p.Out) - spinner.Start(pollText) - ret := <-done - if err != nil { - spinner.Fail(err) - } else { - spinner.Stop() - } - return ret -} - -//NewSpoller simple -func NewSpoller(describeFunc func(string) (interface{}, error), out io.Writer) *Poller { - return &Poller{ - SdescribeFunc: describeFunc, - Out: out, - stateFields: []string{"State", "Status"}, - Timeout: 10 * time.Minute, - } -} - -//NewPoller 轮询 -func NewPoller(describeFunc func(string, string, string, string) (interface{}, error), out io.Writer) *Poller { - return &Poller{ - DescribeFunc: describeFunc, - Out: out, - stateFields: []string{"State", "Status"}, - Timeout: 10 * time.Minute, - } -} - -//PickResourceID uhost-xxx/uhost-name => uhost-xxx -func PickResourceID(str string) string { - if strings.Index(str, "/") > -1 { - return strings.SplitN(str, "/", 2)[0] - } - return str -} - -//WriteJSONFile 写json文件 -func WriteJSONFile(list interface{}, filePath string) error { - byts, err := json.Marshal(list) - if err != nil { - return err - } - err = ioutil.WriteFile(filePath, byts, 0600) - if err != nil { - return err - } - return nil -} - -//GetFileList 补全文件名 -func GetFileList(suffix string) []string { - cmdLine := strings.TrimSpace(os.Getenv("COMP_LINE")) - words := strings.Split(cmdLine, " ") - last := words[len(words)-1] - pathPrefix := "." - - if !strings.HasPrefix(last, "-") { - pathPrefix = last - } - hasTilde := false - //https://tiswww.case.edu/php/chet/bash/bashref.html#Tilde-Expansion - if strings.HasPrefix(pathPrefix, "~") { - pathPrefix = strings.Replace(pathPrefix, "~", GetHomePath(), 1) - hasTilde = true - } - files, err := ioutil.ReadDir(pathPrefix) - if err != nil { - return nil - } - names := []string{} - for _, f := range files { - name := f.Name() - if !strings.HasSuffix(name, suffix) { - continue - } - if hasTilde { - pathPrefix = strings.Replace(pathPrefix, GetHomePath(), "~", 1) - } - if strings.HasSuffix(pathPrefix, "/") { - names = append(names, pathPrefix+name) - } else { - names = append(names, pathPrefix+"/"+name) - } - } - return names -} - -//Confirm 二次确认 -func Confirm(yes bool, text string) bool { - if yes { - return true - } - sure, err := ux.Prompt(text) - if err != nil { - LogError(err.Error()) - return false - } - return sure -} - -func curGoroutineID() int64 { - var ( - buf [64]byte - n = runtime.Stack(buf[:], false) - stk = strings.TrimPrefix(string(buf[:n]), "goroutine ") - ) - - idField := strings.Fields(stk)[0] - id, err := strconv.Atoi(idField) - if err != nil { - panic(fmt.Errorf("can not get goroutine id: %v", err)) - } - - return int64(id) -} diff --git a/base/util_test.go b/base/util_test.go deleted file mode 100644 index e5cd939564..0000000000 --- a/base/util_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package base - -import "testing" - -func TestGetHomePath(t *testing.T) { - home := GetHomePath() - if home == "" { - t.Errorf("base.GetHomePath(), home shoud not be empty. Got :%q", home) - } -} diff --git a/cmd/api.go b/cmd/api.go new file mode 100644 index 0000000000..bd44fdefe0 --- /dev/null +++ b/cmd/api.go @@ -0,0 +1,288 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "strconv" + "strings" + "sync" + "time" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/cmd/internal/platform" + "github.com/ucloud/ucloud-cli/model/status" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/ui" +) + +type RepeatsConfig struct { + Poller cli.Poller + IDInResp string +} + +type repeatResult struct { + success bool + err error +} + +func repeatsSupportedAPI(out io.Writer) map[string]RepeatsConfig { + return map[string]RepeatsConfig{ + "CreateULHostInstance": {Poller: newULHostPoller(out), IDInResp: "ULHostId"}, + } +} + +const ActionField = "Action" +const RepeatsField = "repeats" +const ConcurrentField = "concurrent" +const DefaultConcurrent = 20 +const HelpField = "help" +const HelpInfo = `Usage: ucloud api [options] --Action actionName --param1 value1 --param2 value2 ... +Options: + --local-file string the path of the local file which contains the api parameters + --repeats string the number of repeats + --concurrent string the number of concurrent + --help show help` + +// NewCmdAPI ucloud api --xkey xvalue +func NewCmdAPI(out io.Writer) *cobra.Command { + return &cobra.Command{ + Use: "api", + Short: "Call API", + Long: "Call API", + RunE: func(c *cobra.Command, args []string) error { + if containHelp(args) { + fmt.Fprintln(out, HelpInfo) + return nil + } + params, err := parseParamsFromCmdLine(args) + if err != nil { + fmt.Fprintln(out, err) + return err + } + + if params["local-file"] != nil { + file, ok := params["local-file"].(string) + if !ok { + err := fmt.Errorf("local-file should be a string") + fmt.Fprintln(out, err) + return err + } + params, err = parseParamsFromJSONFile(file) + if err != nil { + fmt.Fprintln(out, err) + return err + } + } + if action, actionOK := params[ActionField].(string); actionOK { + if repeatsConfig, repeatsSupported := repeatsSupportedAPI(out)[action]; repeatsSupported { + if repeats, repeatsOK := params[RepeatsField].(string); repeatsOK { + var repeatsNum int + var concurrentNum int + repeatsNum, err = strconv.Atoi(repeats) + if err != nil { + fmt.Fprintf(out, "error: %v\n", err) + return err + } + if concurrent, concurrentOK := params[ConcurrentField].(string); concurrentOK { + concurrentNum, err = strconv.Atoi(concurrent) + if err != nil { + fmt.Fprintf(out, "error: %v\n", err) + return err + } + } else { + concurrentNum = DefaultConcurrent + } + delete(params, RepeatsField) + delete(params, ConcurrentField) + err = genericInvokeRepeatWrapper(&repeatsConfig, params, action, repeatsNum, concurrentNum, out) + if err != nil { + fmt.Fprintf(out, "error: %v\n", err) + return err + } + return nil + } + } + } + client := newServiceClient(uaccount.NewClient) + req := client.NewGenericRequest() + err = req.SetPayload(params) + if err != nil { + fmt.Fprintf(out, "error: %v\n", err) + return err + } + + resp, err := client.GenericInvoke(req) + if err != nil { + fmt.Fprintf(out, "error: %v\n", err) + return err + } + + data, err := json.MarshalIndent(resp.GetPayload(), "", " ") + if err != nil { + fmt.Fprintf(out, "error: %v\n", err) + return err + } + fmt.Fprintln(out, string(data)) + return nil + }, + } +} + +func parseParamsFromJSONFile(path string) (map[string]interface{}, error) { + content, err := ioutil.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file error: %w", err) + } + params := make(map[string]interface{}) + err = json.Unmarshal(content, ¶ms) + if err != nil { + return nil, fmt.Errorf("parse json error: %w", err) + } + return params, err +} + +func parseParamsFromCmdLine(args []string) (map[string]interface{}, error) { + if len(args)%2 != 0 { + return nil, errors.New("the key value pairs of api parameters do not match") + } + params := make(map[string]interface{}) + for i := 0; i < len(args)-1; i += 2 { + if strings.HasPrefix(args[i], "--") { + args[i] = args[i][2:] + } + params[args[i]] = args[i+1] + } + return params, nil +} + +func genericInvokeRepeatWrapper(repeatsConfig *RepeatsConfig, params map[string]interface{}, action string, repeats int, concurrent int, out io.Writer) error { + if repeatsConfig == nil { + return fmt.Errorf("error: repeatsConfig is nil") + } + if repeats <= 0 { + return fmt.Errorf("error: repeats should be a positive integer") + } + if concurrent <= 0 { + return fmt.Errorf("error: concurrent should be a positive integer") + } + wg := &sync.WaitGroup{} + tokens := make(chan struct{}, concurrent) + retCh := make(chan repeatResult, repeats) + + wg.Add(repeats) + doc := ui.NewDocument(out) + refresh := ui.NewRefresh(out) + printBlockLine := func(block *ui.Block, line string) { + block.Append(line) + if !ui.IsTTY(out) { + fmt.Fprintln(out, line) + } + } + + client := newServiceClient(uaccount.NewClient) + req := client.NewGenericRequest() + err := req.SetPayload(params) + if err != nil { + return fmt.Errorf("fail to set payload: %w", err) + } + + for i := 0; i < repeats; i++ { + go func(req request.GenericRequest, idx int) { + tokens <- struct{}{} + defer func() { + <-tokens + //设置延时,使报错能渲染出来 + time.Sleep(time.Second / 5) + wg.Done() + }() + success := true + var resultErr error + resp, err := client.GenericInvoke(req) + block := ui.NewBlock() + doc.Append(block) + logs := []string{"=================================================="} + logs = append(logs, fmt.Sprintf("api:%v, request:%v", action, platform.ToQueryMap(req))) + if err != nil { + logs = append(logs, fmt.Sprintf("err:%v", err)) + printBlockLine(block, platform.ParseError(err)) + success = false + resultErr = err + } else { + logs = append(logs, fmt.Sprintf("resp:%#v", resp)) + resourceId, ok := resp.GetPayload()[repeatsConfig.IDInResp].(string) + if !ok { + resultErr = fmt.Errorf("expect %v in response, but not found", repeatsConfig.IDInResp) + printBlockLine(block, resultErr.Error()) + success = false + } else { + text := fmt.Sprintf("the resource[%s] is initializing", resourceId) + result := repeatsConfig.Poller.Sspoll(resourceId, text, []string{status.HOST_RUNNING, status.HOST_FAIL}, block, &request.CommonBase{ + Region: ucloud.String(req.GetRegion()), + Zone: ucloud.String(req.GetZone()), + ProjectId: ucloud.String(req.GetProjectId()), + }) + if result.Err != nil { + success = false + resultErr = result.Err + printBlockLine(block, result.Err.Error()) + } + } + } + retCh <- repeatResult{success: success, err: resultErr} + logs = append(logs, fmt.Sprintf("index:%d, result:%t", idx, success)) + platform.LogInfo(logs...) + }(req, i) + } + + var success, fail int + var firstErr error + block := ui.NewBlock() + doc.Append(block) + block.Append(fmt.Sprintf("creating, total:%d, success:%d, fail:%d", repeats, success, fail)) + blockCount := doc.GetBlockCount() + for i := 0; i < repeats; i++ { + ret := <-retCh + if ret.success { + success++ + } else { + fail++ + if firstErr == nil { + firstErr = ret.err + } + } + text := fmt.Sprintf("creating, total:%d, success:%d, fail:%d", repeats, success, fail) + if blockCount != doc.GetBlockCount() { + block = ui.NewBlock() + doc.Append(block) + block.Append(text) + blockCount = doc.GetBlockCount() + } else { + block.Update(text, 0) + } + } + wg.Wait() + if fail > 0 { + fmt.Fprintf(out, "Check logs in %s\n", platform.GetLogFilePath()) + } + refresh.Do(fmt.Sprintf("finally, total:%d, success:%d, fail:%d", repeats, success, fail)) + if firstErr != nil { + return fmt.Errorf("repeat API %s failed: %w", action, firstErr) + } + return nil +} + +func containHelp(args []string) bool { + for _, arg := range args { + if arg == "--help" { + return true + } + } + return false +} diff --git a/cmd/api_repeats_ulhost.go b/cmd/api_repeats_ulhost.go new file mode 100644 index 0000000000..ef694054ba --- /dev/null +++ b/cmd/api_repeats_ulhost.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "io" + + "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newULHostPoller supports `ucloud api --Action CreateULHostInstance --repeats`. +func newULHostPoller(out io.Writer) cli.Poller { + return cli.NewPoller(sdescribeULHostByID, out) +} + +func sdescribeULHostByID(ulhostID string, common *request.CommonBase) (interface{}, error) { + client := newServiceClient(ucompshare.NewClient) + req := client.NewDescribeULHostInstanceRequest() + req.ULHostIds = []string{ulhostID} + if common != nil { + req.CommonBase = *common + } + resp, err := client.DescribeULHostInstance(req) + if err != nil { + return nil, err + } + if len(resp.ULHostInstanceSets) < 1 { + return nil, nil + } + + return &resp.ULHostInstanceSets[0], nil +} diff --git a/cmd/api_test.go b/cmd/api_test.go new file mode 100644 index 0000000000..a12cebe3a7 --- /dev/null +++ b/cmd/api_test.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ucloud/ucloud-cli/cmd/internal/platform" +) + +func testIntPtr(i int) *int { return &i } + +func withTestRuntime(t *testing.T, baseURL string) { + t.Helper() + t.Setenv("COMP_LINE", "1") + + oldRuntime, oldAutoStub := activeRuntime, runtimeAutoStub + oldConfig, oldClientConfig, oldCredential := platform.ConfigIns, platform.ClientConfig, platform.AuthCredential + t.Cleanup(func() { + activeRuntime, runtimeAutoStub = oldRuntime, oldAutoStub + platform.ConfigIns, platform.ClientConfig, platform.AuthCredential = oldConfig, oldClientConfig, oldCredential + }) + + ac := &platform.AggConfig{ + Profile: "test", + Active: true, + ProjectID: "org-test", + Region: "cn-bj2", + Zone: "cn-bj2-03", + BaseURL: baseURL, + Timeout: platform.DefaultTimeoutSec, + MaxRetryTimes: testIntPtr(0), + PublicKey: "pub", + PrivateKey: "pri", + } + sdkConfig, credConfig, err := platform.BuildClientRuntime(ac) + if err != nil { + t.Fatalf("BuildClientRuntime returned error: %v", err) + } + activeRuntime = &runtimeState{ + Config: ac, + SDKConfig: sdkConfig, + Credential: credConfig, + } + runtimeAutoStub = false +} + +func TestGenericInvokeRepeatWrapperReturnsErrorOnCreateFailure(t *testing.T) { + gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"Action":"CreateULHostInstanceResponse","RetCode":8010,"Message":"boom"}`) + })) + t.Cleanup(gateway.Close) + withTestRuntime(t, gateway.URL) + + var out bytes.Buffer + err := genericInvokeRepeatWrapper(&RepeatsConfig{IDInResp: "ULHostId"}, map[string]interface{}{ + ActionField: "CreateULHostInstance", + "Region": "cn-bj2", + }, "CreateULHostInstance", 1, 1, &out) + if err == nil { + t.Fatalf("expected repeat wrapper to return an error on create failure, output: %s", out.String()) + } + if !strings.Contains(out.String(), "boom") { + t.Fatalf("expected output to include API error detail, got: %s", out.String()) + } + if !strings.Contains(out.String(), "finally, total:1, success:0, fail:1") { + t.Fatalf("expected final summary to report one failure, got: %s", out.String()) + } +} + +func TestNewCmdAPIReturnsErrorOnRepeatFailure(t *testing.T) { + gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"Action":"CreateULHostInstanceResponse","RetCode":8010,"Message":"boom"}`) + })) + t.Cleanup(gateway.Close) + withTestRuntime(t, gateway.URL) + + var out bytes.Buffer + cmd := NewCmdAPI(&out) + if cmd.RunE == nil { + t.Fatal("api command must expose RunE so direct-run callers can preserve a non-zero exit code") + } + err := cmd.RunE(cmd, []string{ + "--Action", "CreateULHostInstance", + "--Region", "cn-bj2", + "--repeats", "1", + "--concurrent", "1", + }) + if err == nil { + t.Fatalf("expected api RunE to return repeat failure, output: %s", out.String()) + } + if !strings.Contains(out.String(), "boom") { + t.Fatalf("expected output to include API error detail, got: %s", out.String()) + } +} diff --git a/cmd/backup.go b/cmd/backup.go deleted file mode 100644 index b31a400bd0..0000000000 --- a/cmd/backup.go +++ /dev/null @@ -1,528 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - "strconv" - "time" - - "github.com/spf13/cobra" - - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" -) - -//NewCmdUDBBackup ucloud udb backup -func NewCmdUDBBackup() *cobra.Command { - cmd := &cobra.Command{ - Use: "backup", - Short: "List and manipulate backups of MySQL instance", - Long: "List and manipulate backups of MySQL instance", - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdUDBBackupCreate(out)) - cmd.AddCommand(NewCmdUDBBackupList(out)) - cmd.AddCommand(NewCmdUDBBackupDelete(out)) - cmd.AddCommand(NewCmdUDBBackupGetDownloadURL(out)) - return cmd -} - -//NewCmdUDBBackupCreate ucloud udb backup create -func NewCmdUDBBackupCreate(out io.Writer) *cobra.Command { - req := base.BizClient.NewBackupUDBInstanceRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create backups for MySQL instance manually", - Long: "Create backups for MySQL instance manually", - Run: func(c *cobra.Command, args []string) { - *req.DBId = base.PickResourceID(*req.DBId) - _, err := base.BizClient.BackupUDBInstance(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "udb[%s] backuped\n", *req.DBId) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.DBId = flags.String("udb-id", "", "Required. Resource ID of UDB instnace to backup") - req.BackupName = flags.String("name", "", "Required. Name of backup") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - - cmd.MarkFlagRequired("udb-id") - cmd.MarkFlagRequired("name") - - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "sql", *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -type udbBackupRow struct { - BackupID int - BackupName string - DB string - BackupSize string - BackupType string - Status string - AvailabilityZone string - BackupBeginTime string - BackupEndTime string -} - -//NewCmdUDBBackupList ucloud udb backup list -func NewCmdUDBBackupList(out io.Writer) *cobra.Command { - var bpType, dbType, beginTime, endTime, backupID string - bpTypeMap := map[string]int{ - "manual": 1, - "auto": 0, - } - reverseBpTypeMap := map[int]string{ - 1: "manual", - 0: "auto", - } - req := base.BizClient.NewDescribeUDBBackupRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List backups of MySQL instance", - Long: "List backups of MySQL instance", - Run: func(c *cobra.Command, args []string) { - if v, ok := bpTypeMap[bpType]; ok { - req.BackupType = &v - } - if v, ok := dbTypeMap[dbType]; ok { - req.ClassType = &v - } - if *req.DBId != "" { - *req.DBId = base.PickResourceID(*req.DBId) - } - if backupID != "" { - id, err := strconv.Atoi(base.PickResourceID(backupID)) - if err != nil { - base.HandleError(err) - return - } - req.BackupId = &id - } - if beginTime != "" { - bt, err := time.Parse("2006-01-02/15:04:05", beginTime) - if err != nil { - base.HandleError(err) - return - } - req.BeginTime = sdk.Int(int(bt.Unix())) - } - if endTime != "" { - bt, err := time.Parse("2006-01-02/15:04:05", endTime) - if err != nil { - base.HandleError(err) - return - } - req.EndTime = sdk.Int(int(bt.Unix())) - } - resp, err := base.BizClient.DescribeUDBBackup(req) - if err != nil { - base.HandleError(err) - return - } - list := []udbBackupRow{} - for _, ins := range resp.DataSet { - row := udbBackupRow{ - BackupID: ins.BackupId, - BackupName: ins.BackupName, - AvailabilityZone: ins.Zone, - DB: fmt.Sprintf("%s|%s", ins.DBName, ins.DBId), - BackupSize: fmt.Sprintf("%dB", ins.BackupSize), - BackupType: reverseBpTypeMap[ins.BackupType], - Status: ins.State, - BackupBeginTime: base.FormatDateTime(ins.BackupTime), - BackupEndTime: base.FormatDateTime(ins.BackupEndTime), - } - list = append(list, row) - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.DBId = flags.String("udb-id", "", "Optional. Resource ID of UDB for list the backups of the specifid UDB") - flags.StringVar(&backupID, "backup-id", "", "Optional. Resource ID of backup. List the specified backup only") - flags.StringVar(&bpType, "backup-type", "", "Optional. Backup type. Accept values:auto or manual") - flags.StringVar(&dbType, "db-type", "", "Optional. Only list backups of the UDB of the specified DB type") - flags.StringVar(&beginTime, "begin-time", "", "Optional. Begin time of backup. For example, 2019-02-26/11:21:39") - flags.StringVar(&endTime, "end-time", "", "Optional. End time of backup. For example, 2019-02-26/11:31:39") - - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - bindOffset(req, flags) - bindLimit(req, flags) - - flags.SetFlagValues("backup-type", "auto", "manual") - flags.SetFlagValues("db-type", dbTypeList...) - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "sql", *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -//NewCmdUDBBackupDelete ucloud udb backup delete -func NewCmdUDBBackupDelete(out io.Writer) *cobra.Command { - ids := []int{} - req := base.BizClient.NewDeleteUDBBackupRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete backups of MySQL instance", - Long: "Delete backups of MySQL instance", - Example: "ucloud udb backup delete --backup-id 65534,65535", - Run: func(c *cobra.Command, args []string) { - for _, id := range ids { - req.BackupId = sdk.Int(id) - _, err := base.BizClient.DeleteUDBBackup(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "backup[%d] deleted\n", id) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.IntSliceVar(&ids, "backup-id", nil, "Required. BackupID of backups to delete") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - - cmd.MarkFlagRequired("backup-id") - return cmd -} - -//NewCmdUDBBackupGetDownloadURL ucloud udb backup get-download-url -func NewCmdUDBBackupGetDownloadURL(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeUDBInstanceBackupURLRequest() - cmd := &cobra.Command{ - Use: "download", - Short: "Display download url of backup", - Long: "Display download url of backup", - Run: func(c *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeUDBInstanceBackupURL(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintln(out, resp.BackupPath) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.BackupId = flags.Int("backup-id", -1, "Required. BackupID of backup to delete") - req.DBId = flags.String("udb-id", "", "Required. Resource ID of udb which the backup belongs to") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - - cmd.MarkFlagRequired("udb-id") - cmd.MarkFlagRequired("backup-id") - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "sql", *req.ProjectId, *req.Region, *req.Zone) - }) - return cmd -} - -//NewCmdUDBLog ucloud udb log -func NewCmdUDBLog() *cobra.Command { - cmd := &cobra.Command{ - Use: "logs", - Short: "List and manipulate logs of MySQL instance", - Long: "List and manipulate logs of MySQL instance", - } - - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdUDBLogArchiveCreate(out)) - cmd.AddCommand(NewCmdUDBLogArchiveList(out)) - cmd.AddCommand(NewCmdUDBLogArchiveGetDownloadURL(out)) - cmd.AddCommand(NewCmdUDBLogArchiveDelete(out)) - - return cmd -} - -//NewCmdUDBLogArchiveCreate ucloud udb log archive create -func NewCmdUDBLogArchiveCreate(out io.Writer) *cobra.Command { - var region, zone, project, udbID string - var name, logType, beginTime, endTime string - cmd := &cobra.Command{ - Use: "archive", - Short: "Archive the log of mysql as a compressed file", - Long: "Archive the log of mysql as a compressed file", - Example: "ucloud mysql logs archive --name test.cli2 --udb-id udb-xxx/test.cli1 --log-type slow_query --begin-time 2019-02-23/15:30:00 --end-time 2019-02-24/15:31:00", - Run: func(c *cobra.Command, args []string) { - udbID = base.PickResourceID(udbID) - if logType == "slow_query" { - if beginTime == "" || endTime == "" { - fmt.Fprintln(out, "Error. Both begin-time and end-time can not be empty") - return - } - bt, err := time.Parse(base.DateTimeLayout, beginTime) - if err != nil { - base.HandleError(err) - return - } - et, err := time.Parse(base.DateTimeLayout, endTime) - if err != nil { - base.HandleError(err) - return - } - - req := base.BizClient.NewBackupUDBInstanceSlowLogRequest() - req.BeginTime = sdk.Int(int(bt.Unix())) - req.EndTime = sdk.Int(int(et.Unix())) - req.DBId = &udbID - req.BackupName = &name - req.Region = ®ion - req.ProjectId = &project - - _, err = base.BizClient.BackupUDBInstanceSlowLog(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "mysql log archive[%s] created\n", name) - } else if logType == "error" { - req := base.BizClient.NewBackupUDBInstanceErrorLogRequest() - req.DBId = &udbID - req.BackupName = &name - req.Region = ®ion - req.Zone = &zone - req.ProjectId = &project - - _, err := base.BizClient.BackupUDBInstanceErrorLog(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "mysql log archive[%s] created\n", name) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVar(&udbID, "udb-id", "", "Required. Resource ID of UDB instance which we fetch logs from") - flags.StringVar(&name, "name", "", "Required. Name of compressed file") - flags.StringVar(&logType, "log-type", "", "Required. Type of log to package. Accept values: slow_query, error") - flags.StringVar(&beginTime, "begin-time", "", "Optional. Required when log-type is slow. For example 2019-01-02/15:04:05") - flags.StringVar(&endTime, "end-time", "", "Optional. Required when log-type is slow. For example 2019-01-02/15:04:05") - bindRegionS(®ion, flags) - bindZoneS(&zone, ®ion, flags) - bindProjectIDS(&project, flags) - - cmd.MarkFlagRequired("udb-id") - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("log-type") - - flags.SetFlagValues("log-type", "slow_query", "error") - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "sql", project, region, base.ConfigIns.Zone) - }) - return cmd -} - -type udbArchiveRow struct { - ArchiveID int - Name string - LogType string - DB string - Size string - Status string - CreateTime string -} - -//NewCmdUDBLogArchiveList ucloud udb log archive list -func NewCmdUDBLogArchiveList(out io.Writer) *cobra.Command { - var beginTime, endTime string - logTypes := []string{} - logTypeMap := map[string]int{ - "binlog": 2, - "slow_query": 3, - "error": 4, - } - rLogTypeMap := map[int]string{ - 2: "binlog", - 3: "slow_query", - 4: "error", - } - req := base.BizClient.NewDescribeUDBLogPackageRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List mysql log archives(log files)", - Long: "List mysql log archives(log files)", - Run: func(c *cobra.Command, args []string) { - if beginTime != "" { - bt, err := time.Parse(base.DateTimeLayout, beginTime) - if err != nil { - base.HandleError(err) - return - } - req.BeginTime = sdk.Int(int(bt.Unix())) - } - if endTime != "" { - et, err := time.Parse(base.DateTimeLayout, endTime) - if err != nil { - base.HandleError(err) - return - } - req.EndTime = sdk.Int(int(et.Unix())) - } - - if *req.DBId != "" { - *req.DBId = base.PickResourceID(*req.DBId) - } - - for _, s := range logTypes { - if v, ok := logTypeMap[s]; ok { - req.Types = append(req.Types, v) - } else { - fmt.Fprintln(out, "Error, log-type should be one of 'binlog', 'slow_query' or 'error'") - } - } - - resp, err := base.BizClient.DescribeUDBLogPackage(req) - if err != nil { - base.HandleError(err) - return - } - list := []udbArchiveRow{} - for _, ins := range resp.DataSet { - row := udbArchiveRow{ - ArchiveID: ins.BackupId, - Name: ins.BackupName, - LogType: rLogTypeMap[ins.BackupType], - DB: fmt.Sprintf("%s|%s", ins.DBId, ins.DBName), - Size: fmt.Sprintf("%dB", ins.BackupSize), - Status: ins.State, - CreateTime: base.FormatDateTime(ins.BackupTime), - } - list = append(list, row) - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&logTypes, "log-type", nil, "Optional. Type of log. Accept Values: binlog, slow_query and error") - req.DBId = flags.String("udb-id", "", "Optional. Resource ID of UDB instance which the listed logs belong to") - flags.StringVar(&beginTime, "begin-time", "", "Optional. For example 2019-01-02/15:04:05") - flags.StringVar(&endTime, "end-time", "", "Optional. For example 2019-01-02/15:04:05") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - bindLimit(req, flags) - bindOffset(req, flags) - - flags.SetFlagValues("log-type", "binlog", "slow_query", "error") - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "sql", *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -//NewCmdUDBLogArchiveGetDownloadURL ucloud udb log archive get-download-url -func NewCmdUDBLogArchiveGetDownloadURL(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeUDBBinlogBackupURLRequest() - cmd := &cobra.Command{ - Use: "download", - Short: "Display url of an archive(log file)", - Long: "Display url of an archive(log file)", - Example: "ucloud mysql logs download --udb-id udb-urixxx/test.cli1 --archive-id 35044", - Run: func(c *cobra.Command, args []string) { - *req.DBId = base.PickResourceID(*req.DBId) - resp, err := base.BizClient.DescribeUDBBinlogBackupURL(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintln(out, resp.BackupPath) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.BackupId = flags.Int("archive-id", 0, "Required. ArchiveID of archive to download") - req.DBId = flags.String("udb-id", "", "Required. Resource ID of UDB which the archive belongs to") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("archive-id") - cmd.MarkFlagRequired("udb-id") - - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "sql", *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -//NewCmdUDBLogArchiveDelete ucloud udb log archive delete -func NewCmdUDBLogArchiveDelete(out io.Writer) *cobra.Command { - var ids []int - req := base.BizClient.NewDeleteUDBLogPackageRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete log archives(log files)", - Long: "Delete log archives(log files)", - Example: "ucloud mysql logs delete --archive-id 35025", - Run: func(c *cobra.Command, args []string) { - for _, id := range ids { - req.BackupId = sdk.Int(id) - _, err := base.BizClient.DeleteUDBLogPackage(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "archive[%d] deleted\n", id) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.IntSliceVar(&ids, "archive-id", nil, "Optional. ArchiveID of log archives to delete") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("archive-id") - - return cmd -} diff --git a/cmd/bandwidth.go b/cmd/bandwidth.go deleted file mode 100644 index 0d99e873fc..0000000000 --- a/cmd/bandwidth.go +++ /dev/null @@ -1,405 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - "strconv" - "strings" - "time" - - "github.com/spf13/cobra" - - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/model/status" -) - -//NewCmdBandwidth ucloud bw -func NewCmdBandwidth() *cobra.Command { - cmd := &cobra.Command{ - Use: "bw", - Short: "Manipulate bandwidth package and shared bandwidth", - Long: "Manipulate bandwidth package and shared bandwidth", - } - cmd.AddCommand(NewCmdBandwidthPkg()) - cmd.AddCommand(NewCmdSharedBW()) - return cmd -} - -//NewCmdSharedBW ucloud shared-bw -func NewCmdSharedBW() *cobra.Command { - cmd := &cobra.Command{ - Use: "shared", - Short: "Create and manipulate shared bandwidth instances", - Long: "Create and manipulate shared bandwidth instances", - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdSharedBWCreate()) - cmd.AddCommand(NewCmdSharedBWList(out)) - cmd.AddCommand(NewCmdSharedBWResize()) - cmd.AddCommand(NewCmdSharedBWDelete()) - return cmd -} - -//NewCmdSharedBWCreate ucloud shared-bw create -func NewCmdSharedBWCreate() *cobra.Command { - req := base.BizClient.NewAllocateShareBandwidthRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create shared bandwidth instance", - Long: "Create shared bandwidth instance", - Run: func(c *cobra.Command, args []string) { - if *req.ShareBandwidth < 20 || *req.ShareBandwidth > 5000 { - base.Cxt.Printf("bandwidth should be between 20 and 5000. received %d\n", *req.ShareBandwidth) - return - } - resp, err := base.BizClient.AllocateShareBandwidth(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("shared bandwidth[%s] created\n", resp.ShareBandwidthId) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - req.Name = flags.String("name", "", "Required. Name of the shared bandwidth instance") - req.ShareBandwidth = flags.Int("bandwidth-mb", 20, "Optional. Unit:Mb. Bandwidth of the shared bandwidth. Range [20,5000]") - bindRegion(req, flags) - bindProjectID(req, flags) - req.ChargeType = flags.String("charge-type", "Month", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") - req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") - flags.SetFlagValues("charge-type", "Month", "Year", "Dynamic") - - cmd.MarkFlagRequired("name") - - return cmd -} - -//SharedBWRow 表格行 -type SharedBWRow struct { - Name string - ResourceID string - ChargeType string - Bandwidth string - EIP string - ExpirationTime string -} - -//NewCmdSharedBWList ucloud shared-bw list -func NewCmdSharedBWList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeShareBandwidthRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List shared bandwidth instances", - Long: "List shared bandwidth instances", - Run: func(c *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeShareBandwidth(req) - if err != nil { - base.HandleError(err) - return - } - list := []SharedBWRow{} - for _, sb := range resp.DataSet { - row := SharedBWRow{} - row.Name = sb.Name - row.ResourceID = sb.ShareBandwidthId - row.ChargeType = sb.ChargeType - row.Bandwidth = strconv.Itoa(sb.ShareBandwidth) + "Mb" - row.ExpirationTime = base.FormatDate(sb.ExpireTime) - eipList := []string{} - for _, eip := range sb.EIPSet { - eipText := "" - eipText += eip.EIPId - for _, ip := range eip.EIPAddr { - eipText += fmt.Sprintf("/%s/%s", ip.IP, ip.OperatorName) - } - eipList = append(eipList, eipText) - } - row.EIP = strings.Join(eipList, "\n") - list = append(list, row) - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - flags.StringSliceVar(&req.ShareBandwidthIds, "shared-bw-id", nil, "Resource ID of shared bandwidth instances to list") - - return cmd -} - -//NewCmdSharedBWResize ucloud shared-bw resize -func NewCmdSharedBWResize() *cobra.Command { - req := base.BizClient.NewResizeShareBandwidthRequest() - cmd := &cobra.Command{ - Use: "resize", - Short: "Resize shared bandwidth instance's bandwidth", - Long: "Resize shared bandwidth instance's bandwidth", - Run: func(c *cobra.Command, args []string) { - if *req.ShareBandwidth < 20 || *req.ShareBandwidth > 5000 { - base.Cxt.Printf("bandwidth should be between 20 and 5000. received %d\n", *req.ShareBandwidth) - return - } - req.ShareBandwidthId = sdk.String(base.PickResourceID(*req.ShareBandwidthId)) - _, err := base.BizClient.ResizeShareBandwidth(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("shared bandwidth[%s] resized to %dMb\n", *req.ShareBandwidthId, *req.ShareBandwidth) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.ShareBandwidthId = flags.String("shared-bw-id", "", "Required. Resource ID of shared bandwidth instance to resize") - req.ShareBandwidth = flags.Int("bandwidth-mb", 0, "Required. Unit:Mb. resize to bandwidth value") - bindRegion(req, flags) - bindProjectID(req, flags) - - flags.SetFlagValuesFunc("shared-bw-id", func() []string { - list, _ := getAllSharedBW(*req.ProjectId, *req.Region) - return list - }) - - cmd.MarkFlagRequired("shared-bw-id") - cmd.MarkFlagRequired("bandwidth-mb") - - return cmd -} - -//NewCmdSharedBWDelete ucloud shared-bw delete -func NewCmdSharedBWDelete() *cobra.Command { - req := base.BizClient.NewReleaseShareBandwidthRequest() - ids := []string{} - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete shared bandwidth instance", - Long: "Delete shared bandwidth instance", - Run: func(c *cobra.Command, args []string) { - for _, id := range ids { - req.ShareBandwidthId = sdk.String(base.PickResourceID(id)) - _, err := base.BizClient.ReleaseShareBandwidth(req) - if err != nil { - base.HandleError(err) - continue - } - base.Cxt.Printf("shared bandwidth[%s] deleted\n", id) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&ids, "shared-bw-id", nil, "Required. Resource ID of shared bandwidth instances to delete") - req.EIPBandwidth = flags.Int("eip-bandwidth-mb", 1, "Optional. Bandwidth of the joined EIPs,after deleting the shared bandwidth instance") - req.PayMode = flags.String("traffic-mode", "", "Optional. The charge mode of joined EIPs after deleting the shared bandwidth. Accept values:Bandwidth,Traffic") - bindRegion(req, flags) - bindProjectID(req, flags) - flags.SetFlagValuesFunc("shared-bw-id", func() []string { - list, _ := getAllSharedBW(*req.ProjectId, *req.Region) - return list - }) - flags.SetFlagValues("traffic-mode", "Bandwidth", "Traffic") - - cmd.MarkFlagRequired("shared-bw-id") - - return cmd -} - -func getAllSharedBW(project, region string) ([]string, error) { - req := base.BizClient.NewDescribeShareBandwidthRequest() - req.ProjectId = &project - req.Region = ®ion - resp, err := base.BizClient.DescribeShareBandwidth(req) - if err != nil { - return nil, err - } - list := []string{} - for _, item := range resp.DataSet { - list = append(list, item.ShareBandwidthId+"/"+item.Name) - } - return list, nil -} - -//NewCmdBandwidthPkg ucloud bw-pkg -func NewCmdBandwidthPkg() *cobra.Command { - cmd := &cobra.Command{ - Use: "pkg", - Short: "List, create and delete bandwidth package instances", - Long: "List, create and delete bandwidth package instances", - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdBandwidthPkgCreate()) - cmd.AddCommand(NewCmdBandwidthPkgList(out)) - cmd.AddCommand(NewCmdBandwidthPkgDelete()) - return cmd -} - -//NewCmdBandwidthPkgCreate ucloud bw-pkg create -func NewCmdBandwidthPkgCreate() *cobra.Command { - var start, end *string - timeLayout := "2006-01-02/15:04:05" - ids := []string{} - req := base.BizClient.NewCreateBandwidthPackageRequest() - loc, _ := time.LoadLocation("Local") - cmd := &cobra.Command{ - Use: "create", - Short: "Create bandwidth package", - Long: "Create bandwidth package", - Example: "ucloud bw pkg create --eip-id eip-xxx --bandwidth-mb 20 --start-time 2018-12-15/09:20:00 --end-time 2018-12-16/09:20:00", - Run: func(c *cobra.Command, args []string) { - st, err := time.ParseInLocation(timeLayout, *start, loc) - if err != nil { - base.HandleError(err) - return - } - et, err := time.ParseInLocation(timeLayout, *end, loc) - if err != nil { - base.HandleError(err) - return - } - if st.Sub(time.Now()) < 0 { - base.Cxt.Println("start-time must be after the current time") - return - } - du := et.Unix() - st.Unix() - if du <= 0 { - base.Cxt.Println("end-time must be after the start-time") - return - } - req.EnableTime = sdk.Int(int(st.Unix())) - req.TimeRange = sdk.Int(int(du)) - - for _, id := range ids { - id = base.PickResourceID(id) - req.EIPId = &id - resp, err := base.BizClient.CreateBandwidthPackage(req) - if err != nil { - base.HandleError(err) - continue - } - base.Cxt.Printf("bandwidth package[%s] created for eip[%s]\n", resp.BandwidthPackageId, id) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - flags.StringSliceVar(&ids, "eip-id", nil, "Required. Resource ID of eip to be bound with created bandwidth package") - start = flags.String("start-time", "", "Required. The time to enable bandwidth package. Local time, for example '2018-12-25/08:30:00'") - end = flags.String("end-time", "", "Required. The time to disable bandwidth package. Local time, for example '2018-12-26/08:30:00'") - req.Bandwidth = flags.Int("bandwidth-mb", 0, "Required. bandwidth of the bandwidth package to create.Range [1,800]. Unit:'Mb'.") - bindRegion(req, flags) - bindProjectID(req, flags) - - cmd.Flags().SetFlagValuesFunc("eip-id", func() []string { - return getAllEip(*req.ProjectId, *req.Region, []string{status.EIP_USED}, []string{status.EIP_CHARGE_BANDWIDTH}) - }) - - cmd.MarkFlagRequired("eip-id") - cmd.MarkFlagRequired("start-time") - cmd.MarkFlagRequired("end-time") - cmd.MarkFlagRequired("bandwidth-mb") - return cmd -} - -//BandwidthPkgRow 表格行 -type BandwidthPkgRow struct { - ResourceID string - EIP string - Bandwidth string - StartTime string - EndTime string -} - -//NewCmdBandwidthPkgList ucloud bw-pkg list -func NewCmdBandwidthPkgList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeBandwidthPackageRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List bandwidth packages", - Long: "List bandwidth packages", - Run: func(c *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeBandwidthPackage(req) - if err != nil { - base.HandleError(err) - return - } - list := []BandwidthPkgRow{} - for _, bp := range resp.DataSets { - row := BandwidthPkgRow{ - ResourceID: bp.BandwidthPackageId, - Bandwidth: strconv.Itoa(bp.Bandwidth) + "MB", - StartTime: base.FormatDateTime(bp.EnableTime), - EndTime: base.FormatDateTime(bp.DisableTime), - } - eip := bp.EIPId - for _, addr := range bp.EIPAddr { - eip += "/" + addr.IP + "/" + addr.OperatorName - } - row.EIP = eip - list = append(list, row) - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - bindRegion(req, flags) - bindProjectID(req, flags) - req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset") - req.Limit = cmd.Flags().Int("limit", 50, "Optional. Limit range [0,10000000]") - - return cmd -} - -//NewCmdBandwidthPkgDelete ucloud bw-pkg delete -func NewCmdBandwidthPkgDelete() *cobra.Command { - ids := []string{} - req := base.BizClient.NewDeleteBandwidthPackageRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete bandwidth packages", - Long: "Delete bandwidth packages", - Example: "ucloud bw pkg delete --resource-id bwpack-xxx", - Run: func(c *cobra.Command, args []string) { - for _, id := range ids { - id := base.PickResourceID(id) - req.BandwidthPackageId = &id - _, err := base.BizClient.DeleteBandwidthPackage(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("bandwidth package[%s] deleted\n", id) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - flags.StringSliceVar(&ids, "resource-id", nil, "Required, Resource ID of bandwidth package to delete") - bindRegion(req, flags) - bindProjectID(req, flags) - - return cmd -} diff --git a/cmd/callback.go b/cmd/callback.go new file mode 100644 index 0000000000..9142099198 --- /dev/null +++ b/cmd/callback.go @@ -0,0 +1,85 @@ +// cmd/callback.go +package cmd + +import ( + "fmt" + "net" + "net/http" + "sync" + + "github.com/ucloud/ucloud-cli/cmd/internal/platform" +) + +// allocateLoopbackListener 在回环地址上取一个内核分配的空闲端口(>=1024),返回 listener 与端口。 +func allocateLoopbackListener() (net.Listener, int, error) { + ln, err := net.Listen("tcp", platform.LoopbackListenHost+":0") + if err != nil { + return nil, 0, fmt.Errorf("cannot open a local callback port: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + return ln, port, nil +} + +type callbackResult struct { + code string + err error +} + +const callbackSuccessHTML = ` + +Login successful + +

Login successful

+

You can close this tab and return to the terminal.

+

登录成功,可关闭此页面返回终端。

+ +` + +// startCallbackServer 在给定 listener 上起一个临时 HTTP server,只处理 GET /authorization。 +// 结果通过返回的 channel(缓冲1)投递,sync.Once 保证只投递一次。投递规则: +// - error 参数(如 access_denied)→ 回 400 并投递错误(中止登录); +// - code + state 匹配 → 回成功页并投递 code; +// - 缺 code 或 state 不匹配(本地探针/陈旧标签页等噪音请求)→ 仅回 400、不投递, +// 继续等待真正的回调(上层 loginCallbackTimeout 兜底)。 +func startCallbackServer(ln net.Listener, expectState string) (*http.Server, <-chan callbackResult) { + ch := make(chan callbackResult, 1) + var once sync.Once + + srv := &http.Server{} + mux := http.NewServeMux() + mux.HandleFunc(platform.OAuthRedirectPath, func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + if e := q.Get("error"); e != "" { + var err error + if e == "access_denied" { + err = fmt.Errorf("authorization was denied in the browser. Run 'ucloud auth login' to try again") + } else { + err = fmt.Errorf("oauth server returned error %q. Run 'ucloud auth login' to try again", e) + } + http.Error(w, "Login failed. Return to the terminal for details.", http.StatusBadRequest) + once.Do(func() { + ch <- callbackResult{err: err} + }) + return + } + + code := q.Get("code") + if code == "" || q.Get("state") != expectState { + // 噪音请求:不消耗 once,登录继续等待真正的回调 + http.Error(w, "Login failed. Return to the terminal for details.", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, callbackSuccessHTML) + once.Do(func() { + ch <- callbackResult{code: code} + }) + }) + srv.Handler = mux + + go srv.Serve(ln) + return srv, ch +} diff --git a/cmd/callback_test.go b/cmd/callback_test.go new file mode 100644 index 0000000000..577ce131b8 --- /dev/null +++ b/cmd/callback_test.go @@ -0,0 +1,126 @@ +// cmd/callback_test.go +package cmd + +import ( + "fmt" + "net/http" + "testing" + "time" +) + +// setupCallback 在已分配 listener 上起 callback server,返回端口与结果 channel。 +func setupCallback(t *testing.T, expectState string) (int, <-chan callbackResult) { + t.Helper() + ln, port, err := allocateLoopbackListener() + if err != nil { + t.Fatalf("allocate listener: %v", err) + } + srv, ch := startCallbackServer(ln, expectState) + t.Cleanup(func() { srv.Close() }) + return port, ch +} + +// get 向 callback server 发一个回调请求,返回 HTTP 状态码。 +func get(t *testing.T, port int, query string) int { + t.Helper() + url := fmt.Sprintf("http://127.0.0.1:%d/authorization?%s", port, query) + resp, err := http.Get(url) + if err != nil { + t.Fatalf("GET callback: %v", err) + } + resp.Body.Close() + return resp.StatusCode +} + +// drive 起 callback server,发一个回调请求,返回投递的结果(仅用于必然投递的场景)。 +func drive(t *testing.T, expectState, query string) callbackResult { + t.Helper() + port, ch := setupCallback(t, expectState) + get(t, port, query) + + select { + case res := <-ch: + return res + case <-time.After(3 * time.Second): + t.Fatal("callback result not delivered") + return callbackResult{} + } +} + +// assertNoDelivery 断言 channel 在短窗口内保持为空(噪音请求不得投递)。 +func assertNoDelivery(t *testing.T, ch <-chan callbackResult) { + t.Helper() + select { + case res := <-ch: + t.Fatalf("noise request must not deliver a result, got %+v", res) + case <-time.After(100 * time.Millisecond): + } +} + +func TestCallbackSuccess(t *testing.T) { + res := drive(t, "st", "code=abc&state=st") + if res.err != nil { + t.Fatalf("expected success, got err %v", res.err) + } + if res.code != "abc" { + t.Errorf("code = %q, want abc", res.code) + } +} + +// 噪音请求(state 不匹配,如陈旧标签页的旧回调):回 400 但不投递,继续等待真正的回调。 +func TestCallbackStateMismatch(t *testing.T) { + port, ch := setupCallback(t, "st") + if status := get(t, port, "code=x&state=WRONG"); status != http.StatusBadRequest { + t.Errorf("state-mismatch noise status = %d, want 400", status) + } + assertNoDelivery(t, ch) + + // 同一 server 上真正的回调仍然成功 + if status := get(t, port, "code=real&state=st"); status != http.StatusOK { + t.Errorf("genuine callback status = %d, want 200", status) + } + select { + case res := <-ch: + if res.err != nil { + t.Fatalf("genuine callback after noise: unexpected err %v", res.err) + } + if res.code != "real" { + t.Errorf("code = %q, want real", res.code) + } + case <-time.After(3 * time.Second): + t.Fatal("genuine callback not delivered after noise request") + } +} + +// 噪音请求(有 state 无 code,如本地探针):回 400 但不投递,继续等待真正的回调。 +func TestCallbackStateWithoutCode(t *testing.T) { + port, ch := setupCallback(t, "st") + if status := get(t, port, "state=st"); status != http.StatusBadRequest { + t.Errorf("missing-code noise status = %d, want 400", status) + } + assertNoDelivery(t, ch) + + // 同一 server 上真正的回调仍然成功 + if status := get(t, port, "code=abc&state=st"); status != http.StatusOK { + t.Errorf("genuine callback status = %d, want 200", status) + } + select { + case res := <-ch: + if res.err != nil { + t.Fatalf("genuine callback after noise: unexpected err %v", res.err) + } + if res.code != "abc" { + t.Errorf("code = %q, want abc", res.code) + } + case <-time.After(3 * time.Second): + t.Fatal("genuine callback not delivered after noise request") + } +} + +// error 参数(用户在浏览器里拒绝授权)是明确的失败信号:必须投递并中止登录。 +func TestCallbackAccessDenied(t *testing.T) { + res := drive(t, "st", "error=access_denied&state=st") + if res.err == nil { + t.Fatal("expected access_denied error") + } +} diff --git a/cmd/completion.go b/cmd/completion.go index 969e070c82..d70fd3c6aa 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -17,17 +17,16 @@ package cmd import ( "fmt" "os" - "os/exec" - "regexp" - "runtime" "strings" "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-cli/base" ) -// NewCmdCompletion ucloud completion +// NewCmdCompletion prints how to enable shell auto completion. +// +// The actual completion scripts are generated by upstream cobra's built-in +// `ucloud completion {bash|zsh|fish|powershell}` subcommand. This command +// (reachable via `ucloud --completion`) only tells the user how to install it. func NewCmdCompletion() *cobra.Command { var completionCmd = &cobra.Command{ Use: "completion", @@ -52,44 +51,21 @@ func NewCmdCompletion() *cobra.Command { } func bashCompletion(cmd *cobra.Command) { - platform := runtime.GOOS - if platform == "darwin" { - fmt.Println(`Please append 'complete -C $(which ucloud) ucloud' to file '~/.bash_profile'`) + fmt.Println(`To load ucloud completions for the current bash session, run: - } else if platform == "linux" { - fmt.Println(`Please append 'complete -C $(which ucloud) ucloud' to file '~/.bashrc'`) - } + source <(ucloud completion bash) + +To load them for every new session, add that line to '~/.bashrc' (Linux) or +'~/.bash_profile' (macOS). This requires the 'bash-completion' package. +Run 'ucloud completion bash --help' for details.`) } func zshCompletion(cmd *cobra.Command) { - fmt.Println(`Please append the following scripts to file '~/.zshrc'. + fmt.Println(`To load ucloud completions for the current zsh session, run: -autoload -U +X bashcompinit && bashcompinit -complete -F $(which ucloud) ucloud`) -} + source <(ucloud completion zsh) -func getBashVersion() (version string, err error) { - lookupBashVersion := exec.Command("bash", "-version") - out, err := lookupBashVersion.Output() - if err != nil { - base.Cxt.PrintErr(err) - } - - // Example - // $ bash -version - // GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin17) - // Copyright (C) 2007 Free Software Foundation, Inc. - versionStr := string(out) - re := regexp.MustCompile("(\\d)\\.\\d\\.") - strs := re.FindAllStringSubmatch(versionStr, -1) - if len(strs) >= 1 { - result := strs[0] - if len(result) >= 2 { - version = result[1] - } - } - if version == "" { - err = fmt.Errorf("lookup bash version failed") - } - return +To load them for every new session, add that line to '~/.zshrc'. If completion +is not yet enabled in your environment, also add 'autoload -U compinit && compinit'. +Run 'ucloud completion zsh --help' for details.`) } diff --git a/cmd/configure.go b/cmd/configure.go index 6f04c3165c..8b4785e976 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -15,6 +15,7 @@ package cmd import ( + "errors" "fmt" "strconv" @@ -23,10 +24,11 @@ import ( sdk "github.com/ucloud/ucloud-sdk-go/ucloud" uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" + "github.com/ucloud/ucloud-cli/pkg/command" ) -const configDesc = `Public-key and private-key could be acquired from https://console.ucloud.cn/uapi/apikey.` +const configDesc = `Public-key and private-key could be acquired from https://console.ucloud.cn/uaccount/api_manage` const helloUcloud = ` _ _ _ _ _ _ _____ _ _ @@ -36,27 +38,41 @@ const helloUcloud = ` | | | | __/ | | (_) | | |_| | \__/\ | (_) | |_| | (_| | \_| |_/\___|_|_|\___/ \___/ \____/_|\___/ \__,_|\__,_| -If you want add or modify your configurations, run 'ucloud config add/update' -` +If you want add or modify your configurations, run 'ucloud config add/update'` -//NewCmdInit ucloud init +// NewCmdInit ucloud init func NewCmdInit() *cobra.Command { cmd := &cobra.Command{ Use: "init", Short: "Initialize UCloud CLI options", Long: `Initialize UCloud CLI options such as private-key,public-key,default region,zone and project.`, Run: func(cmd *cobra.Command, args []string) { - if base.ConfigIns.PrivateKey != "" && base.ConfigIns.PublicKey != "" { + fromOAuth := platform.ConfigIns.AuthMode == platform.AuthModeOAuth + if fromOAuth { + ok := platform.Confirm(false, fmt.Sprintf("Profile '%s' currently uses OAuth login (auth_mode=oauth). Continue with AK/SK setup and switch this profile to key-based auth? (y/n):", platform.ConfigIns.Profile)) + if !ok { + return + } + clearOAuthState(platform.ConfigIns) + } + + if platform.ConfigIns.PrivateKey != "" && platform.ConfigIns.PublicKey != "" { + if fromOAuth { + if err := switchProfileToAKSK(platform.ConfigIns); err != nil { + platform.HandleError(err) + return + } + } printHello() return } fmt.Println(configDesc) - base.ConfigIns.ConfigPublicKey() - base.ConfigIns.ConfigPrivateKey() - base.ConfigIns.ConfigBaseURL() + platform.ConfigIns.ConfigPublicKey() + platform.ConfigIns.ConfigPrivateKey() + platform.ConfigIns.ConfigBaseURL() - region, err := fetchRegionWithConfig(base.ConfigIns) + region, err := fetchRegionWithConfig(platform.ConfigIns) if err != nil { if uErr, ok := err.(uerr.Error); ok { if uErr.Code() == 172 { @@ -67,30 +83,34 @@ func NewCmdInit() *cobra.Command { fmt.Println(err) return } - base.ConfigIns.Region = region.DefaultRegion - base.ConfigIns.Zone = region.DefaultZone + platform.ConfigIns.Region = region.DefaultRegion + platform.ConfigIns.Zone = region.DefaultZone fmt.Printf("Configured default region:%s zone:%s\n", region.DefaultRegion, region.DefaultZone) - projectID, projectName, err := getDefaultProject() - if err != nil { - base.HandleError(err) + projectID, projectName, err := getDefaultProjectWithConfig(platform.ConfigIns) + if err != nil && !errors.Is(err, errNoDefaultProject) { + platform.HandleError(err) return } - base.ConfigIns.ProjectID = projectID - fmt.Printf("Configured default project:%s %s\n", projectID, projectName) - base.ConfigIns.Timeout = base.DefaultTimeoutSec - base.ConfigIns.BaseURL = base.DefaultBaseURL - base.ConfigIns.MaxRetryTimes = sdk.Int(base.DefaultMaxRetryTimes) - base.ConfigIns.Active = true - fmt.Printf("Configured default base url:%s\n", base.ConfigIns.BaseURL) - fmt.Printf("Configured default timeout_sec:%ds\n", base.ConfigIns.Timeout) - fmt.Printf("Active profile name:%s\n", base.ConfigIns.Profile) + if projectID != "" && projectName != "" { + platform.ConfigIns.ProjectID = projectID + fmt.Printf("Configured default project:%s %s\n", projectID, projectName) + } else { + fmt.Println("No default project, skip.") + } + platform.ConfigIns.Timeout = platform.DefaultTimeoutSec + platform.ConfigIns.MaxRetryTimes = sdk.Int(platform.DefaultMaxRetryTimes) + platform.ConfigIns.Active = true + fmt.Printf("Configured default base url:%s\n", platform.ConfigIns.BaseURL) + fmt.Printf("Configured default timeout_sec:%ds\n", platform.ConfigIns.Timeout) + fmt.Printf("Active profile name:%s\n", platform.ConfigIns.Profile) fmt.Println("You can change the default settings by running 'ucloud config update'") - base.ConfigIns.ConfigUploadLog() - err = base.AggConfigListIns.Append(base.ConfigIns) + platform.ConfigIns.ConfigUploadLog() + err = saveInitProfile(platform.ConfigIns) if err != nil { - base.HandleError(fmt.Errorf("Error: %v", err)) + platform.HandleError(fmt.Errorf("Error: %v", err)) } else { + platform.InitConfig() printHello() } }, @@ -98,22 +118,42 @@ func NewCmdInit() *cobra.Command { return cmd } +// saveInitProfile 持久化 init 完整配置流程的结果;profile 已存在时(OAuth-only profile +// 切回 AK/SK 的场景)覆盖保存——依赖 ConfigIns 即 manager map 内的同一指针(InitConfig 保证) +func saveInitProfile(cfg *platform.AggConfig) error { + return platform.AggConfigListIns.UpdateAggConfig(cfg) +} + +// clearOAuthState 清除 profile 的 oauth 状态(口径与 'ucloud auth logout' 一致),不落盘 +func clearOAuthState(cfg *platform.AggConfig) { + cfg.AuthMode = "" + cfg.AccessToken = "" + cfg.RefreshToken = "" + cfg.ExpiresAt = 0 +} + +// switchProfileToAKSK 把 OAuth profile 切回 AK/SK:清除 oauth 状态并落盘 +func switchProfileToAKSK(cfg *platform.AggConfig) error { + clearOAuthState(cfg) + return platform.AggConfigListIns.UpdateAggConfig(cfg) +} + func printHello() { userInfo, err := getUserInfo() if err != nil { - base.Cxt.PrintErr(err) + platform.Cxt.PrintErr(err) return } - base.Cxt.Printf("You are logged in as: [%s]\n", userInfo.UserEmail) + platform.Cxt.Printf("You are logged in as: [%s]\n", userInfo.UserEmail) certified := isUserCertified(userInfo) if !certified { - base.Cxt.Println("\nWarning: Please authenticate the account with your valid documentation at 'https://accountv2.ucloud.cn/authentication'.") + platform.Cxt.Println("\nWarning: Please authenticate the account with your valid documentation at 'https://accountv2.ucloud.cn/authentication'.") } - base.Cxt.Println(helloUcloud) + platform.Cxt.Println(helloUcloud) } -//根据用户设置的region和zone,检查其合法性,补上缺失的部分,给出一个合理的符合用户本意设置的region和zone -func getReasonableRegionZone(cfg *base.AggConfig) (string, string, error) { +// 根据用户设置的region和zone,检查其合法性,补上缺失的部分,给出一个合理的符合用户本意设置的region和zone +func getReasonableRegionZone(cfg *platform.AggConfig) (string, string, error) { userRegion := cfg.Region userZone := cfg.Zone //如果zone设置了,region不能为空,因为这种情况较难判断给出一个合理的region @@ -153,10 +193,10 @@ func getReasonableRegionZone(cfg *base.AggConfig) (string, string, error) { return userRegion, userZone, nil } -//NewCmdConfig ucloud config +// NewCmdConfig ucloud config func NewCmdConfig() *cobra.Command { var active, upload string - cfg := base.AggConfig{} + cfg := platform.AggConfig{} cmd := &cobra.Command{ Use: "config", Short: "add or update configurations", @@ -169,19 +209,20 @@ func NewCmdConfig() *cobra.Command { } if cfg.Timeout < 0 { - base.HandleError(fmt.Errorf("timeout_sec must be greater than 0, accept %d", cfg.Timeout)) + platform.HandleError(fmt.Errorf("timeout_sec must be greater than 0, accept %d", cfg.Timeout)) return } //cacheConfig AggConfig read from $HOME/.ucloud/config.json+credential.json or empty shell - cacheConfig, ok := base.AggConfigListIns.GetAggConfigByProfile(cfg.Profile) + cacheConfig, ok := platform.AggConfigListIns.GetAggConfigByProfile(cfg.Profile) //如果配置文件中找不到该profile 则添加配置 if !ok { - cacheConfig = &base.AggConfig{ + cacheConfig = &platform.AggConfig{ PrivateKey: cfg.PrivateKey, PublicKey: cfg.PublicKey, Profile: cfg.Profile, BaseURL: cfg.BaseURL, + ChannelKey: cfg.ChannelKey, Timeout: cfg.Timeout, Active: cfg.Active, Region: cfg.Region, @@ -200,15 +241,23 @@ func NewCmdConfig() *cobra.Command { if cfg.BaseURL == "" { if cacheConfig.BaseURL == "" { - cacheConfig.BaseURL = base.DefaultBaseURL + cacheConfig.BaseURL = platform.DefaultBaseURL } } else { cacheConfig.BaseURL = cfg.BaseURL } + //channel-key 属连接类参数,与 base-url 同批应用:必须早于下方 region/project + //远程校验,否则校验请求不带 key,专属云 profile 在配置时即报错而配不上。 + //用 Changed() 而非空值判断:空是合法值(专属云切回主站需清除它), + //--channel-key "" 应能清空,这与 base-url「空=不改」的既有局限不同。 + if c.Flags().Changed("channel-key") { + cacheConfig.ChannelKey = cfg.ChannelKey + } + if cfg.Timeout == 0 { if cacheConfig.Timeout == 0 { - cacheConfig.Timeout = base.DefaultTimeoutSec + cacheConfig.Timeout = platform.DefaultTimeoutSec } } else { cacheConfig.Timeout = cfg.Timeout @@ -216,7 +265,7 @@ func NewCmdConfig() *cobra.Command { if *cfg.MaxRetryTimes == 0 { if *cacheConfig.MaxRetryTimes == 0 { - cacheConfig.MaxRetryTimes = sdk.Int(base.DefaultMaxRetryTimes) + cacheConfig.MaxRetryTimes = sdk.Int(platform.DefaultMaxRetryTimes) } } else { cacheConfig.MaxRetryTimes = cfg.MaxRetryTimes @@ -232,7 +281,7 @@ func NewCmdConfig() *cobra.Command { //确保设置的Region和Zone真实存在 region, zone, err := getReasonableRegionZone(cacheConfig) if err != nil { - base.HandleError(fmt.Errorf("verify region failed: %v", err)) + platform.HandleError(fmt.Errorf("verify region failed: %v", err)) } else { cacheConfig.Region = region cacheConfig.Zone = zone @@ -244,13 +293,13 @@ func NewCmdConfig() *cobra.Command { if cacheConfig.ProjectID == "" { id, _, err := getDefaultProjectWithConfig(cacheConfig) if err != nil { - base.HandleError(fmt.Errorf("fetch default project failed: %v", err)) + platform.HandleError(fmt.Errorf("fetch default project failed: %v", err)) } else { cacheConfig.ProjectID = id } } } else { - cfg.ProjectID = base.PickResourceID(cfg.ProjectID) + cfg.ProjectID = platform.PickResourceID(cfg.ProjectID) projects, err := fetchProjectWithConfig(cacheConfig) if err != nil { cacheConfig.ProjectID = cfg.ProjectID @@ -258,9 +307,9 @@ func NewCmdConfig() *cobra.Command { if ok := projects[cfg.ProjectID]; ok { cacheConfig.ProjectID = cfg.ProjectID } else { - base.HandleError(fmt.Errorf("project %s you assigned not exists", cfg.ProjectID)) + platform.HandleError(fmt.Errorf("project %s you assigned not exists", cfg.ProjectID)) if ok := projects[cacheConfig.ProjectID]; !ok { - base.HandleError(fmt.Errorf("project %s not exists, assign another one please", cacheConfig.ProjectID)) + platform.HandleError(fmt.Errorf("project %s not exists, assign another one please", cacheConfig.ProjectID)) } } } @@ -272,7 +321,7 @@ func NewCmdConfig() *cobra.Command { } else if active == "false" { cacheConfig.Active = false } else { - base.HandleError(fmt.Errorf("flag active should be true or false. received %s", active)) + platform.HandleError(fmt.Errorf("flag active should be true or false. received %s", active)) } } @@ -282,13 +331,13 @@ func NewCmdConfig() *cobra.Command { } else if upload == "false" { cacheConfig.AgreeUploadLog = false } else { - base.HandleError(fmt.Errorf("flag agree-upload-log should be true or false. received %s", active)) + platform.HandleError(fmt.Errorf("flag agree-upload-log should be true or false. received %s", active)) } } - err = base.AggConfigListIns.UpdateAggConfig(cacheConfig) + err = platform.AggConfigListIns.UpdateAggConfig(cacheConfig) if err != nil { - base.HandleError(err) + platform.HandleError(err) } }, } @@ -301,17 +350,18 @@ func NewCmdConfig() *cobra.Command { flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'") flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list") flags.StringVar(&cfg.BaseURL, "base-url", "", "Optional. Set default base url. For instance 'https://api.ucloud.cn/'") + flags.StringVar(&cfg.ChannelKey, "channel-key", "", "Optional. Set channel-key for a dedicated cloud channel that reuses the main-site domain. For instance 'ch_xxx'. Leave empty for the main site or a channel with its own domain") flags.IntVar(&cfg.Timeout, "timeout-sec", 0, "Optional. Set default timeout for requesting API. Unit: seconds") cfg.MaxRetryTimes = flags.Int("max-retry-times", 0, "Optional. Set default max-retry-times for idempotent APIs which can be called many times without side effect, for example 'ReleaseEIP'") flags.StringVar(&active, "active", "", "Optional. Mark the profile to be effective or not. Accept valeus: true or false") flags.StringVar(&upload, "agree-upload-log", "false", "Optional. Agree to upload log in local file ~/.ucloud/cli.log or not. Accept valeus: true or false") - flags.SetFlagValues("active", "true", "false") - flags.SetFlagValues("agree-upload-log", "true", "false") - flags.SetFlagValuesFunc("profile", func() []string { return base.AggConfigListIns.GetProfileNameList() }) - flags.SetFlagValuesFunc("region", getRegionList) - flags.SetFlagValuesFunc("project-id", getProjectList) - flags.SetFlagValuesFunc("zone", func() []string { + command.SetFlagValues(cmd, "active", "true", "false") + command.SetFlagValues(cmd, "agree-upload-log", "true", "false") + command.SetCompletion(cmd, "profile", func() []string { return platform.AggConfigListIns.GetProfileNameList() }) + command.SetCompletion(cmd, "region", getRegionList) + command.SetCompletion(cmd, "project-id", getProjectList) + command.SetCompletion(cmd, "zone", func() []string { return getZoneList(cfg.Region) }) @@ -322,10 +372,10 @@ func NewCmdConfig() *cobra.Command { return cmd } -//NewCmdConfigAdd ucloud config add +// NewCmdConfigAdd ucloud config add func NewCmdConfigAdd() *cobra.Command { var active, upload string - cfg := &base.AggConfig{} + cfg := &platform.AggConfig{} cmd := &cobra.Command{ Use: "add", Short: "add configuration", @@ -333,19 +383,19 @@ func NewCmdConfigAdd() *cobra.Command { Run: func(c *cobra.Command, args []string) { region, zone, err := getReasonableRegionZone(cfg) if err != nil { - base.HandleError(err) + platform.HandleError(err) } cfg.Region = region cfg.Zone = zone project, err := getReasonableProject(cfg) if err != nil { - base.HandleError(err) + platform.HandleError(err) } cfg.ProjectID = project if cfg.Timeout <= 0 { - base.HandleError(fmt.Errorf("timeout_sec must be greater than 0, accept %d", cfg.Timeout)) + platform.HandleError(fmt.Errorf("timeout_sec must be greater than 0, accept %d", cfg.Timeout)) return } @@ -365,9 +415,9 @@ func NewCmdConfigAdd() *cobra.Command { fmt.Printf("agree-upload-log should be true or false, received %s\n", active) } - err = base.AggConfigListIns.Append(cfg) + err = platform.AggConfigListIns.Append(cfg) if err != nil { - base.HandleError(err) + platform.HandleError(err) return } }, @@ -381,18 +431,19 @@ func NewCmdConfigAdd() *cobra.Command { flags.StringVar(&cfg.Region, "region", "", "Optional. Set default region. For instance 'cn-bj2' See 'ucloud region'") flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'") flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list") - flags.StringVar(&cfg.BaseURL, "base-url", base.DefaultBaseURL, "Optional. Set default base url. For instance 'https://api.ucloud.cn/'") - flags.IntVar(&cfg.Timeout, "timeout-sec", base.DefaultTimeoutSec, "Optional. Set default timeout for requesting API. Unit: seconds") - cfg.MaxRetryTimes = flags.Int("max-retry-times", base.DefaultMaxRetryTimes, "Optional. Set default max-retry-times for idempotent APIs which can be called many times without side effect, for example 'ReleaseEIP'") + flags.StringVar(&cfg.BaseURL, "base-url", platform.DefaultBaseURL, "Optional. Set default base url. For instance 'https://api.ucloud.cn/'") + flags.StringVar(&cfg.ChannelKey, "channel-key", "", "Optional. Set channel-key for a dedicated cloud channel that reuses the main-site domain. For instance 'ch_xxx'. Leave empty for the main site or a channel with its own domain") + flags.IntVar(&cfg.Timeout, "timeout-sec", platform.DefaultTimeoutSec, "Optional. Set default timeout for requesting API. Unit: seconds") + cfg.MaxRetryTimes = flags.Int("max-retry-times", platform.DefaultMaxRetryTimes, "Optional. Set default max-retry-times for idempotent APIs which can be called many times without side effect, for example 'ReleaseEIP'") flags.StringVar(&active, "active", "false", "Optional. Mark the profile to be effective or not. Accept valeus: true or false") flags.StringVar(&upload, "agree-upload-log", "false", "Optional. Agree to upload log in local file ~/.ucloud/cli.log or not. Accept valeus: true or false") - flags.SetFlagValues("active", "true", "false") - flags.SetFlagValues("agree-upload-log", "true", "false") - flags.SetFlagValuesFunc("profile", func() []string { return base.AggConfigListIns.GetProfileNameList() }) - flags.SetFlagValuesFunc("region", getRegionList) - flags.SetFlagValuesFunc("project-id", getProjectList) - flags.SetFlagValuesFunc("zone", func() []string { + command.SetFlagValues(cmd, "active", "true", "false") + command.SetFlagValues(cmd, "agree-upload-log", "true", "false") + command.SetCompletion(cmd, "profile", func() []string { return platform.AggConfigListIns.GetProfileNameList() }) + command.SetCompletion(cmd, "region", getRegionList) + command.SetCompletion(cmd, "project-id", getProjectList) + command.SetCompletion(cmd, "zone", func() []string { return getZoneList(cfg.Region) }) @@ -403,19 +454,19 @@ func NewCmdConfigAdd() *cobra.Command { return cmd } -//NewCmdConfigUpdate ucloud config update +// NewCmdConfigUpdate ucloud config update func NewCmdConfigUpdate() *cobra.Command { var timeout, active, maxRetries, upload string - cfg := &base.AggConfig{} + cfg := &platform.AggConfig{} cmd := &cobra.Command{ Use: "update", Short: "update configurations", Long: "update configurations", Run: func(c *cobra.Command, args []string) { //cacheConfig AggConfig read from $HOME/.ucloud/config.json+credential.json or empty shell - cacheConfig, ok := base.AggConfigListIns.GetAggConfigByProfile(cfg.Profile) + cacheConfig, ok := platform.AggConfigListIns.GetAggConfigByProfile(cfg.Profile) if !ok { - base.HandleError(fmt.Errorf("profile %s not exist", cfg.Profile)) + platform.HandleError(fmt.Errorf("profile %s not exist", cfg.Profile)) return } @@ -428,67 +479,76 @@ func NewCmdConfigUpdate() *cobra.Command { //如果配置了公私钥,则先更新让其生效, 为接下来拉取Region,Zone做准备 if cfg.PrivateKey != "" || cfg.PublicKey != "" { - base.AggConfigListIns.UpdateAggConfig(cacheConfig) - } - - //如有设置Region和Zone,确保设置的Region和Zone真实存在 - if cfg.Region != "" { - cacheConfig.Region = cfg.Region - } - if cfg.Zone != "" { - cacheConfig.Zone = cfg.Zone - } - - region, zone, err := getReasonableRegionZone(cacheConfig) - if err != nil { - base.HandleError(err) - return + platform.AggConfigListIns.UpdateAggConfig(cacheConfig) } - cacheConfig.Region = region - cacheConfig.Zone = zone - - if cfg.ProjectID != "" { - cacheConfig.ProjectID = base.PickResourceID(cfg.ProjectID) + //先应用连接类参数(base-url/channel-key/timeout-sec/max-retry-times),确保接下来的远程校验 + //打到新网关而不是旧的(可能已不可用的)网关,避免旧base-url坏掉后无法改回的死锁 + if cfg.BaseURL != "" { + cacheConfig.BaseURL = cfg.BaseURL } - project, err := getReasonableProject(cacheConfig) - if err != nil { - base.HandleError(err) + //channel-key 同属连接类参数:专属云 profile 的远程校验请求必须带上它, + //否则网关报 174 而校验失败,profile 永远配不上。 + //Changed() 使 --channel-key "" 可清除(专属云切回主站的场景)。 + if c.Flags().Changed("channel-key") { + cacheConfig.ChannelKey = cfg.ChannelKey } - cacheConfig.ProjectID = project if timeout != "" { seconds, err := strconv.Atoi(timeout) if err != nil { - base.HandleError(fmt.Errorf("parse timeout-sec failed: %v", err)) + platform.HandleError(fmt.Errorf("parse timeout-sec failed: %v", err)) return } cacheConfig.Timeout = seconds } if cacheConfig.Timeout <= 0 { - base.HandleError(fmt.Errorf("timeout-sec must be greater than 0, accept %d", cfg.Timeout)) + platform.HandleError(fmt.Errorf("timeout-sec must be greater than 0, accept %d", cfg.Timeout)) return } if maxRetries != "" { times, err := strconv.Atoi(maxRetries) if err != nil { - base.HandleError(fmt.Errorf("parse max-retry-times failed: %v", err)) + platform.HandleError(fmt.Errorf("parse max-retry-times failed: %v", err)) return } cacheConfig.MaxRetryTimes = × } if *cacheConfig.MaxRetryTimes < 0 { - base.HandleError(fmt.Errorf("max-retry-timesc must be greater than or equal to 0, accept %d", cfg.MaxRetryTimes)) + platform.HandleError(fmt.Errorf("max-retry-timesc must be greater than or equal to 0, accept %d", cfg.MaxRetryTimes)) return } - if cfg.BaseURL != "" { - cacheConfig.BaseURL = cfg.BaseURL + //如有设置Region和Zone,确保设置的Region和Zone真实存在 + if cfg.Region != "" { + cacheConfig.Region = cfg.Region } + if cfg.Zone != "" { + cacheConfig.Zone = cfg.Zone + } + + region, zone, err := getReasonableRegionZone(cacheConfig) + if err != nil { + platform.HandleError(err) + return + } + + cacheConfig.Region = region + cacheConfig.Zone = zone + + if cfg.ProjectID != "" { + cacheConfig.ProjectID = platform.PickResourceID(cfg.ProjectID) + } + + project, err := getReasonableProject(cacheConfig) + if err != nil { + platform.HandleError(err) + } + cacheConfig.ProjectID = project if active == "true" { cacheConfig.Active = true @@ -502,9 +562,9 @@ func NewCmdConfigUpdate() *cobra.Command { cacheConfig.AgreeUploadLog = false } - err = base.AggConfigListIns.UpdateAggConfig(cacheConfig) + err = platform.AggConfigListIns.UpdateAggConfig(cacheConfig) if err != nil { - base.HandleError(err) + platform.HandleError(err) } }, } @@ -518,39 +578,40 @@ func NewCmdConfigUpdate() *cobra.Command { flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'") flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list") flags.StringVar(&cfg.BaseURL, "base-url", "", "Optional. Set default base url. For instance 'https://api.ucloud.cn/'") + flags.StringVar(&cfg.ChannelKey, "channel-key", "", "Optional. Set channel-key for a dedicated cloud channel that reuses the main-site domain. For instance 'ch_xxx'. Pass an empty value to clear it") flags.StringVar(&timeout, "timeout-sec", "", "Optional. Set default timeout for requesting API. Unit: seconds") flags.StringVar(&maxRetries, "max-retry-times", "", "Optional. Set default max retry times for idempotent APIs which can be called many times without side effect, for example 'ReleaseEIP'") flags.StringVar(&active, "active", "", "Optional. Mark the profile to be effective") flags.StringVar(&upload, "agree-upload-log", "", "Optional. Agree to upload log in local file ~/.ucloud/cli.log or not. Accept valeus: true or false") - flags.SetFlagValuesFunc("profile", func() []string { return base.AggConfigListIns.GetProfileNameList() }) - flags.SetFlagValuesFunc("region", getRegionList) - flags.SetFlagValuesFunc("project-id", getProjectList) - flags.SetFlagValuesFunc("zone", func() []string { + command.SetCompletion(cmd, "profile", func() []string { return platform.AggConfigListIns.GetProfileNameList() }) + command.SetCompletion(cmd, "region", getRegionList) + command.SetCompletion(cmd, "project-id", getProjectList) + command.SetCompletion(cmd, "zone", func() []string { return getZoneList(cfg.Region) }) - flags.SetFlagValues("active", "true", "false") - flags.SetFlagValues("agree-upload-log", "true", "false") + command.SetFlagValues(cmd, "active", "true", "false") + command.SetFlagValues(cmd, "agree-upload-log", "true", "false") cmd.MarkFlagRequired("profile") return cmd } -//NewCmdConfigList ucloud config list +// NewCmdConfigList ucloud config list func NewCmdConfigList() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "list all configurations", Long: `list all configurations`, Run: func(c *cobra.Command, args []string) { - base.ListAggConfig(global.JSON) + platform.ListAggConfig(global.JSON) }, } return cmd } -//NewCmdConfigDelete ucloud config Delete +// NewCmdConfigDelete ucloud config Delete func NewCmdConfigDelete() *cobra.Command { var profileList []string cmd := &cobra.Command{ @@ -559,7 +620,7 @@ func NewCmdConfigDelete() *cobra.Command { Long: "delete configurations by profile name", Example: "ucloud config delete --profile test", Run: func(c *cobra.Command, args []string) { - profiles := base.AggConfigListIns.GetProfileNameList() + profiles := platform.AggConfigListIns.GetProfileNameList() allProfileMap := make(map[string]bool) for _, p := range profiles { allProfileMap[p] = true @@ -567,18 +628,18 @@ func NewCmdConfigDelete() *cobra.Command { for _, p := range profileList { if allProfileMap[p] { - err := base.AggConfigListIns.DeleteByProfile(p) + err := platform.AggConfigListIns.DeleteByProfile(p) if err != nil { - base.HandleError(err) + platform.HandleError(err) } } else { - base.HandleError(fmt.Errorf("profile %s does not exist", p)) + platform.HandleError(fmt.Errorf("profile %s does not exist", p)) } } }, } cmd.Flags().StringSliceVar(&profileList, "profile", nil, "Required. Name of settings item") cmd.MarkFlagRequired("profile") - cmd.Flags().SetFlagValuesFunc("profile", func() []string { return base.AggConfigListIns.GetProfileNameList() }) + command.SetCompletion(cmd, "profile", func() []string { return platform.AggConfigListIns.GetProfileNameList() }) return cmd } diff --git a/cmd/configure_test.go b/cmd/configure_test.go new file mode 100644 index 0000000000..58b7b3f0ce --- /dev/null +++ b/cmd/configure_test.go @@ -0,0 +1,302 @@ +package cmd + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/cmd/internal/platform" +) + +// 回归:oauth profile 已存 AK/SK 时(auth login 保留密钥的常见形态), +// init 确认切回 AK/SK 后必须把 auth_mode/token 清除并落盘,否则下次启动仍走 OAuth +func TestSwitchProfileToAKSKPersistsToDisk(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + credPath := filepath.Join(dir, "credential.json") + cliJSON := `[{"profile":"oa","active":true,"region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"max_retry_times":3}]` + credJSON := `[{"public_key":"pub","private_key":"pri","profile":"oa","auth_mode":"oauth","access_token":"at","refresh_token":"rt","expires_at":1234567890}]` + if err := ioutil.WriteFile(cfgPath, []byte(cliJSON), platform.LocalFileMode); err != nil { + t.Fatal(err) + } + if err := ioutil.WriteFile(credPath, []byte(credJSON), platform.LocalFileMode); err != nil { + t.Fatal(err) + } + + m, err := platform.NewAggConfigManager(cfgPath, credPath) + if err != nil { + t.Fatal(err) + } + cfg, ok := m.GetAggConfigByProfile("oa") + if !ok { + t.Fatal("profile oa missing") + } + + oldM, oldC := platform.AggConfigListIns, platform.ConfigIns + platform.AggConfigListIns, platform.ConfigIns = m, cfg + defer func() { platform.AggConfigListIns, platform.ConfigIns = oldM, oldC }() + + if err := switchProfileToAKSK(cfg); err != nil { + t.Fatal(err) + } + + // 重新读盘验证持久化,而非只看内存 + m2, err := platform.NewAggConfigManager(cfgPath, credPath) + if err != nil { + t.Fatal(err) + } + got, ok := m2.GetAggConfigByProfile("oa") + if !ok { + t.Fatal("profile oa missing after reload") + } + if got.AuthMode != "" || got.AccessToken != "" || got.RefreshToken != "" || got.ExpiresAt != 0 { + t.Errorf("oauth state must be cleared on disk, got auth_mode=%q access_token=%q refresh_token=%q expires_at=%d", + got.AuthMode, got.AccessToken, got.RefreshToken, got.ExpiresAt) + } + if got.PublicKey != "pub" || got.PrivateKey != "pri" { + t.Errorf("AK/SK must survive the switch, got public_key=%q private_key=%q", got.PublicKey, got.PrivateKey) + } +} + +// fakeGatewayServer 模拟业务网关:响应远程校验所需的 GetRegion/GetProjectList +func fakeGatewayServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + payload := r.URL.RawQuery + string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(payload, "GetRegion"): + fmt.Fprint(w, `{"RetCode":0,"Action":"GetRegionResponse","Regions":[{"Region":"cn-bj2","Zone":"cn-bj2-04","IsDefault":true}]}`) + case strings.Contains(payload, "GetProjectList"): + fmt.Fprint(w, `{"RetCode":0,"Action":"GetProjectListResponse","ProjectSet":[{"ProjectId":"org-123","ProjectName":"Default","IsDefault":true}]}`) + default: + fmt.Fprint(w, `{"RetCode":230,"Message":"unexpected action"}`) + } + })) +} + +// 回归:config update --base-url 必须在远程校验(getReasonableRegionZone 等)之前生效, +// 否则旧 base_url 指向坏网关时校验永远打到坏网关,新地址无法保存(鸡生蛋死锁)。 +func TestConfigUpdateAppliesBaseURLBeforeValidation(t *testing.T) { + gateway := fakeGatewayServer(t) + defer gateway.Close() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + credPath := filepath.Join(dir, "credential.json") + // 存量 base_url 指向必然连不通的地址,复现坏网关现场 + cliJSON := `[{"profile":"up","active":true,"project_id":"org-123","region":"cn-bj2","zone":"cn-bj2-04","base_url":"http://127.0.0.1:1/","timeout_sec":3,"max_retry_times":0}]` + credJSON := `[{"public_key":"pub","private_key":"pri","profile":"up"}]` + if err := ioutil.WriteFile(cfgPath, []byte(cliJSON), platform.LocalFileMode); err != nil { + t.Fatal(err) + } + if err := ioutil.WriteFile(credPath, []byte(credJSON), platform.LocalFileMode); err != nil { + t.Fatal(err) + } + + m, err := platform.NewAggConfigManager(cfgPath, credPath) + if err != nil { + t.Fatal(err) + } + + // GetBizClient 会改写包级全局 ClientConfig/AuthCredential,恢复现场避免测试顺序耦合 + oldM, oldCC, oldAC := platform.AggConfigListIns, platform.ClientConfig, platform.AuthCredential + platform.AggConfigListIns = m + defer func() { + platform.AggConfigListIns, platform.ClientConfig, platform.AuthCredential = oldM, oldCC, oldAC + }() + + cmd := NewCmdConfigUpdate() + if err := cmd.Flags().Set("profile", "up"); err != nil { + t.Fatal(err) + } + if err := cmd.Flags().Set("base-url", gateway.URL); err != nil { + t.Fatal(err) + } + cmd.Run(cmd, nil) + + // 重新读盘验证持久化,而非只看内存 + m2, err := platform.NewAggConfigManager(cfgPath, credPath) + if err != nil { + t.Fatal(err) + } + got, ok := m2.GetAggConfigByProfile("up") + if !ok { + t.Fatal("profile up missing after reload") + } + if got.BaseURL != gateway.URL { + t.Errorf("base_url on disk = %q, want new gateway %q (remote validation must run against the NEW base-url)", got.BaseURL, gateway.URL) + } +} + +// 回归:OAuth-only profile(auth_mode=oauth 且未存 AK/SK,auth login 直接创建的形态) +// 执行 init 确认切回 AK/SK 并走完整配置流程后,末尾持久化不能因 profile 已存在而失败, +// 否则整套新配置(密钥、region、project)全部不落盘 +func TestInitSaveOverwritesExistingOAuthOnlyProfile(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + credPath := filepath.Join(dir, "credential.json") + cliJSON := `[{"profile":"oa","active":true,"base_url":"https://api.ucloud.cn/","timeout_sec":15,"max_retry_times":3}]` + credJSON := `[{"public_key":"","private_key":"","profile":"oa","auth_mode":"oauth","access_token":"at","refresh_token":"rt","expires_at":1234567890}]` + if err := ioutil.WriteFile(cfgPath, []byte(cliJSON), platform.LocalFileMode); err != nil { + t.Fatal(err) + } + if err := ioutil.WriteFile(credPath, []byte(credJSON), platform.LocalFileMode); err != nil { + t.Fatal(err) + } + + m, err := platform.NewAggConfigManager(cfgPath, credPath) + if err != nil { + t.Fatal(err) + } + cfg, ok := m.GetAggConfigByProfile("oa") + if !ok { + t.Fatal("profile oa missing") + } + + oldM, oldC := platform.AggConfigListIns, platform.ConfigIns + platform.AggConfigListIns, platform.ConfigIns = m, cfg + defer func() { platform.AggConfigListIns, platform.ConfigIns = oldM, oldC }() + + // 模拟 NewCmdInit Run 完整配置路径对 ConfigIns(即 manager map 内同一指针)的写入 + clearOAuthState(cfg) + cfg.PublicKey = "newpub" + cfg.PrivateKey = "newpri" + cfg.Region = "cn-bj2" + cfg.Zone = "cn-bj2-04" + cfg.ProjectID = "org-new" + cfg.Timeout = platform.DefaultTimeoutSec + cfg.BaseURL = platform.DefaultBaseURL + cfg.Active = true + + if err := saveInitProfile(cfg); err != nil { + t.Fatalf("save must overwrite existing profile instead of failing, got: %v", err) + } + + // 重新读盘验证持久化,而非只看内存 + m2, err := platform.NewAggConfigManager(cfgPath, credPath) + if err != nil { + t.Fatal(err) + } + got, ok := m2.GetAggConfigByProfile("oa") + if !ok { + t.Fatal("profile oa missing after reload") + } + if got.PublicKey != "newpub" || got.PrivateKey != "newpri" { + t.Errorf("new AK/SK must land on disk, got public_key=%q private_key=%q", got.PublicKey, got.PrivateKey) + } + if got.Region != "cn-bj2" || got.Zone != "cn-bj2-04" || got.ProjectID != "org-new" { + t.Errorf("region/zone/project must land on disk, got region=%q zone=%q project_id=%q", got.Region, got.Zone, got.ProjectID) + } + if got.AuthMode != "" { + t.Errorf("auth_mode must be cleared on disk, got %q", got.AuthMode) + } + // 切回 AK/SK 后 token 必须清除,口径与 switchProfileToAKSK / 'ucloud auth logout' 一致 + if got.AccessToken != "" || got.RefreshToken != "" || got.ExpiresAt != 0 { + t.Errorf("oauth tokens must be cleared on disk, got access_token=%q refresh_token=%q expires_at=%d", + got.AccessToken, got.RefreshToken, got.ExpiresAt) + } +} + +// stubStdin 把 os.Stdin 换成预置内容的临时文件,驱动 init 流程里的 fmt.Scanf 交互 +func stubStdin(t *testing.T, input string) { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "stdin") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(input); err != nil { + t.Fatal(err) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + old := os.Stdin + os.Stdin = f + t.Cleanup(func() { + os.Stdin = old + f.Close() + }) +} + +// 回归:init 交互中用户输入的自定义 base-url 必须落盘。曾在流程末尾无条件回填 +// DefaultBaseURL,导致输入的专属云域名只在远程校验期生效、存盘的却是主站默认值, +// 且回显的也是覆盖后的值,用户无从察觉。 +func TestInitPersistsUserSuppliedBaseURL(t *testing.T) { + gateway := fakeGatewayServer(t) + defer gateway.Close() + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + credPath := filepath.Join(dir, "credential.json") + + m, err := platform.NewAggConfigManager(cfgPath, credPath) + if err != nil { + t.Fatal(err) + } + // 全新 init 现场:profile 尚未落盘,BaseURL 为包级默认值。timeout/max-retry 取小值让 + // 远程校验打本地假网关时快速收敛(init 末尾仍会把二者重置为各自默认值) + cfg := &platform.AggConfig{ + Profile: platform.DefaultProfile, + BaseURL: platform.DefaultBaseURL, + Timeout: 3, + MaxRetryTimes: sdk.Int(0), + } + + oldCfgPath, oldCredPath := platform.ConfigFilePath, platform.CredentialFilePath + oldM, oldC := platform.AggConfigListIns, platform.ConfigIns + oldCC, oldAC, oldRT := platform.ClientConfig, platform.AuthCredential, activeRuntime + // Run 末尾的 InitConfig 按包级路径读写配置,指向临时目录避免污染真实 ~/.ucloud + platform.ConfigFilePath, platform.CredentialFilePath = cfgPath, credPath + platform.AggConfigListIns, platform.ConfigIns = m, cfg + // ConfigPublicKey/ConfigPrivateKey 直接写 AuthCredential,为 nil 会 panic + platform.AuthCredential = &platform.CredentialConfig{} + // 末尾 printHello 经 activeRuntime 取 client:钉到假网关,避免测试真的打外网 + platform.ClientConfig = &sdk.Config{BaseUrl: gateway.URL, Timeout: 3 * time.Second} + setActiveRuntimeFromBaseGlobals() + defer func() { + platform.ConfigFilePath, platform.CredentialFilePath = oldCfgPath, oldCredPath + platform.AggConfigListIns, platform.ConfigIns = oldM, oldC + platform.ClientConfig, platform.AuthCredential, activeRuntime = oldCC, oldAC, oldRT + }() + + // init 依次 Scanf 读取:public-key、private-key、base-url、是否上传日志 + stubStdin(t, fmt.Sprintf("pub\npri\n%s\nno\n", gateway.URL)) + + cmd := NewCmdInit() + cmd.Run(cmd, nil) + + // 重新读盘验证持久化,而非只看内存 + m2, err := platform.NewAggConfigManager(cfgPath, credPath) + if err != nil { + t.Fatal(err) + } + got, ok := m2.GetAggConfigByProfile(platform.DefaultProfile) + if !ok { + t.Fatal("profile default missing after reload") + } + if got.BaseURL != gateway.URL { + t.Errorf("base_url on disk = %q, want user supplied %q (init must not overwrite it with the default %q)", + got.BaseURL, gateway.URL, platform.DefaultBaseURL) + } + // 同一流程里 init 本就该落的默认值,不能被本次修复带偏 + if got.Timeout != platform.DefaultTimeoutSec { + t.Errorf("timeout_sec on disk = %d, want default %d", got.Timeout, platform.DefaultTimeoutSec) + } + if got.MaxRetryTimes == nil || *got.MaxRetryTimes != platform.DefaultMaxRetryTimes { + t.Errorf("max_retry_times on disk = %v, want default %d", got.MaxRetryTimes, platform.DefaultMaxRetryTimes) + } + if !got.Active { + t.Error("active on disk = false, want true") + } +} diff --git a/cmd/disk.go b/cmd/disk.go deleted file mode 100644 index 10e10da296..0000000000 --- a/cmd/disk.go +++ /dev/null @@ -1,789 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - "strings" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-sdk-go/private/services/uhost" - "github.com/ucloud/ucloud-sdk-go/services/udisk" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/model/status" - "github.com/ucloud/ucloud-cli/ux" -) - -//NewCmdDisk ucloud disk -func NewCmdDisk() *cobra.Command { - cmd := &cobra.Command{ - Use: "udisk", - Short: "Read and manipulate udisk instances", - Long: "Read and manipulate udisk instances", - } - writer := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdDiskCreate(writer)) - cmd.AddCommand(NewCmdDiskList(writer)) - cmd.AddCommand(NewCmdDiskAttach(writer)) - cmd.AddCommand(NewCmdDiskDetach(writer)) - cmd.AddCommand(NewCmdDiskDelete()) - cmd.AddCommand(NewCmdDiskClone(writer)) - cmd.AddCommand(NewCmdDiskExpand()) - cmd.AddCommand(NewCmdDiskSnapshot(writer)) - cmd.AddCommand(NewCmdDiskRestore(writer)) - cmd.AddCommand(NewCmdSnapshotList(writer)) - cmd.AddCommand(NewCmdSnapshotDelete(writer)) - return cmd -} - -//NewCmdDiskCreate ucloud udisk create -func NewCmdDiskCreate(out io.Writer) *cobra.Command { - var async *bool - var count *int - var enableDataArk *string - var snapshotID *string - req := base.BizClient.NewCreateUDiskRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create udisk instance", - Long: "Create udisk instance", - Run: func(cmd *cobra.Command, args []string) { - if *count > 10 || *count < 1 { - base.Cxt.Printf("Error, count should be between 1 and 10\n") - return - } - if *enableDataArk == "true" { - req.UDataArkMode = sdk.String("Yes") - } else { - req.UDataArkMode = sdk.String("No") - } - - if *req.DiskType == "Oridinary" { - *req.DiskType = "DataDisk" - } else if *req.DiskType == "SSD" { - *req.DiskType = "SSDDataDisk" - } - if *snapshotID != "" { - cloneReq := base.BizClient.NewCloneUDiskSnapshotRequest() - cloneReq.UDataArkMode = req.UDataArkMode - cloneReq.SourceId = snapshotID - cloneReq.ProjectId = req.ProjectId - cloneReq.Region = req.Region - cloneReq.Zone = req.Zone - cloneReq.Name = req.Name - cloneReq.Size = req.Size - cloneReq.ChargeType = req.ChargeType - cloneReq.Quantity = req.Quantity - for i := 0; i < *count; i++ { - resp, err := base.BizClient.CloneUDiskSnapshot(cloneReq) - if err != nil { - base.HandleError(err) - return - } - if count := len(resp.UDiskId); count == 1 { - text := fmt.Sprintf("udisk:%v is initializing", resp.UDiskId) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewSpoller(describeUdiskByID, out) - poller.Spoll(resp.UDiskId[0], text, []string{status.DISK_AVAILABLE, status.DISK_FAILED}) - } - } else if count > 1 { - base.Cxt.Printf("udisk:%v created\n", resp.UDiskId) - } else { - base.Cxt.PrintErr(fmt.Errorf("none udisk created")) - } - } - } else { - for i := 0; i < *count; i++ { - resp, err := base.BizClient.CreateUDisk(req) - if err != nil { - base.HandleError(err) - return - } - if count := len(resp.UDiskId); count == 1 { - text := fmt.Sprintf("udisk:%v is initializing", resp.UDiskId) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewSpoller(describeUdiskByID, out) - poller.Spoll(resp.UDiskId[0], text, []string{status.DISK_AVAILABLE, status.DISK_FAILED}) - } - } else if count > 1 { - base.Cxt.Printf("udisk:%v created\n", resp.UDiskId) - } else { - base.Cxt.PrintErr(fmt.Errorf("none udisk created")) - } - } - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - req.Name = flags.String("name", "", "Required. Name of the udisk to create") - req.Size = flags.Int("size-gb", 10, "Required. Size of the udisk to create. Unit:GB. Normal udisk [1,8000]; SSD udisk [1,4000] ") - snapshotID = flags.String("snapshot-id", "", "Optional. Resource ID of a snapshot, which will apply to the udisk being created. If you set this option, 'udisk-type' will be omitted.") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - req.ChargeType = flags.String("charge-type", "Dynamic", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") - req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") - enableDataArk = flags.String("enable-data-ark", "false", "Optional. DataArk supports real-time backup, which can restore the udisk back to any moment within the last 12 hours.") - req.Tag = flags.String("group", "Default", "Optional. Business group") - req.DiskType = flags.String("udisk-type", "Oridinary", "Optional. 'Ordinary' or 'SSD'") - async = flags.Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") - count = flags.Int("count", 1, "Optional. The count of udisk to create. Range [1,10]") - - flags.SetFlagValues("charge-type", "Month", "Year", "Dynamic", "Trial") - flags.SetFlagValues("enable-data-ark", "true", "false") - flags.SetFlagValues("udisk-type", "Oridinary", "SSD") - - cmd.MarkFlagRequired("size-gb") - cmd.MarkFlagRequired("name") - - return cmd -} - -//DiskRow TableRow -type DiskRow struct { - ResourceID string - Name string - Group string - Size string - Type string - MountUHost string - MountPoint string - EnableDataArk string - State string - CreationTime string - ExpirationTime string -} - -//NewCmdDiskList ucloud disk list -func NewCmdDiskList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeUDiskRequest() - typeMap := map[string]string{ - "DataDisk": "Oridinary-Data-Disk", - "SystemDisk": "Oridinary-System-Disk", - "SSDDataDisk": "SSD-Data-Disk", - } - arkModeMap := map[string]string{ - "Yes": "true", - "No": "false", - } - cmd := &cobra.Command{ - Use: "list", - Short: "List udisk instance", - Long: "List udisk instance", - Run: func(cmd *cobra.Command, args []string) { - for key, val := range typeMap { - if *req.DiskType == val { - *req.DiskType = key - } - } - resp, err := base.BizClient.DescribeUDisk(req) - if err != nil { - base.HandleError(err) - return - } - list := []DiskRow{} - for _, disk := range resp.DataSet { - row := DiskRow{ - ResourceID: disk.UDiskId, - Name: disk.Name, - Group: disk.Tag, - Size: fmt.Sprintf("%dGB", disk.Size), - Type: typeMap[disk.DiskType], - EnableDataArk: arkModeMap[disk.UDataArkMode], - MountUHost: fmt.Sprintf("%s/%s", disk.UHostName, disk.UHostIP), - MountPoint: disk.DeviceName, - State: disk.Status, - CreationTime: base.FormatDate(disk.CreateTime), - ExpirationTime: base.FormatDate(disk.ExpiredTime), - } - if disk.UHostIP == "" { - row.MountUHost = "" - } - list = append(list, row) - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - req.UDiskId = flags.String("udisk-id", "", "Optional. Resource ID of the udisk to search") - req.DiskType = flags.String("udisk-type", "", "Optional. Optional. Type of the udisk to search. 'Oridinary-Data-Disk','Oridinary-System-Disk' or 'SSD-Data-Disk'") - req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset") - req.Limit = cmd.Flags().Int("limit", 50, "Optional. Limit") - flags.SetFlagValues("udisk-type", "Oridinary-Data-Disk", "Oridinary-System-Disk", "SSD-Data-Disk") - return cmd -} - -//NewCmdDiskAttach ucloud disk attach -func NewCmdDiskAttach(out io.Writer) *cobra.Command { - var async *bool - var udiskIDs *[]string - - req := base.BizClient.NewAttachUDiskRequest() - cmd := &cobra.Command{ - Use: "attach", - Short: "Attach udisk instances to an uhost", - Long: "Attach udisk instances to an uhost", - Example: "ucloud udisk attach --uhost-id uhost-xxxx --udisk-id bs-xxx1,bs-xxx2", - Run: func(cmd *cobra.Command, args []string) { - for _, id := range *udiskIDs { - id = base.PickResourceID(id) - req.UDiskId = &id - *req.UHostId = base.PickResourceID(*req.UHostId) - resp, err := base.BizClient.AttachUDisk(req) - if err != nil { - base.HandleError(err) - return - } - text := fmt.Sprintf("udisk[%s] is attaching to uhost uhost[%s]", *req.UDiskId, *req.UHostId) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewSpoller(describeUdiskByID, out) - poller.Spoll(resp.UDiskId, text, []string{status.DISK_INUSE, status.DISK_FAILED}) - } - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - req.UHostId = flags.String("uhost-id", "", "Required. Resource ID of the uhost instance which you want to attach the disk") - udiskIDs = flags.StringSlice("udisk-id", nil, "Required. Resource ID of the udisk instances to attach") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - async = flags.Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") - - flags.SetFlagValuesFunc("udisk-id", func() []string { - return getDiskList([]string{status.DISK_AVAILABLE}, *req.ProjectId, *req.Region, *req.Zone) - }) - flags.SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_RUNNING, status.HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) - }) - - cmd.MarkFlagRequired("uhost-id") - cmd.MarkFlagRequired("udisk-id") - - return cmd -} - -//NewCmdDiskDetach ucloud udisk detach -func NewCmdDiskDetach(out io.Writer) *cobra.Command { - var async, yes *bool - var udiskIDs *[]string - req := base.BizClient.NewDetachUDiskRequest() - cmd := &cobra.Command{ - Use: "detach", - Short: "Detach udisk instances from an uhost", - Long: "Detach udisk instances from an uhost", - Run: func(cmd *cobra.Command, args []string) { - text := `Please confirm that you have already unmounted file system corresponding to this hard drive,(See "https://docs.ucloud.cn/storage_cdn/udisk/userguide/umount" for help), otherwise it will cause file system damage and UHost cannot be normally shut down. Sure to detach?` - if !*yes { - sure, err := ux.Prompt(text) - if err != nil { - base.Cxt.PrintErr(err) - return - } - if !sure { - return - } - } - for _, id := range *udiskIDs { - id = base.PickResourceID(id) - err := detachUdisk(*async, id, out) - if err != nil { - base.Cxt.Println(err) - continue - } - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - udiskIDs = flags.StringSlice("udisk-id", nil, "Required. Resource ID of the udisk instances to detach") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - async = flags.BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") - yes = flags.BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") - - flags.SetFlagValuesFunc("udisk-id", func() []string { - return getDiskList([]string{status.DISK_INUSE}, *req.ProjectId, *req.Region, *req.Zone) - }) - - cmd.MarkFlagRequired("udisk-id") - return cmd -} - -func detachUdisk(async bool, udiskID string, out io.Writer) error { - any, err := describeUdiskByID(udiskID) - if err != nil { - return err - } - if any == nil { - return fmt.Errorf("udisk[%v] is not exist", any) - } - ins, ok := any.(*udisk.UDiskDataSet) - if !ok { - return fmt.Errorf("%#v convert to udisk failed", any) - } - req := base.BizClient.NewDetachUDiskRequest() - req.UHostId = sdk.String(ins.UHostId) - req.UDiskId = sdk.String(udiskID) - resp, err := base.BizClient.DetachUDisk(req) - if err != nil { - return err - } - text := fmt.Sprintf("udisk[%s] is detaching from uhost[%s]", resp.UDiskId, resp.UHostId) - if async { - fmt.Fprintln(out, text) - } else { - poller := base.NewSpoller(describeUdiskByID, out) - poller.Spoll(udiskID, text, []string{status.DISK_AVAILABLE, status.DISK_FAILED}) - } - return nil -} - -//NewCmdDiskDelete ucloud udisk delete -func NewCmdDiskDelete() *cobra.Command { - var yes *bool - var udiskIDs *[]string - req := base.BizClient.NewDeleteUDiskRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete udisk instances", - Long: "Delete udisk instances", - Run: func(cmd *cobra.Command, args []string) { - if !*yes { - sure, err := ux.Prompt(fmt.Sprintf("Are you sure to delete udisk(s)?")) - if err != nil { - base.Cxt.PrintErr(err) - return - } - if !sure { - return - } - } - for _, id := range *udiskIDs { - id := base.PickResourceID(id) - req.UDiskId = &id - _, err := base.BizClient.DeleteUDisk(req) - if err != nil { - base.HandleError(err) - continue - } else { - base.Cxt.Printf("udisk[%s] deleted\n", *req.UDiskId) - } - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - udiskIDs = flags.StringSlice("udisk-id", nil, "Required. The Resource ID of udisks to delete") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - yes = flags.BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") - - flags.SetFlagValuesFunc("udisk-id", func() []string { - return getDiskList([]string{status.DISK_AVAILABLE, status.DISK_FAILED}, *req.ProjectId, *req.Region, *req.Zone) - }) - - cmd.MarkFlagRequired("udisk-id") - - return cmd -} - -//NewCmdDiskClone ucloud disk clone -func NewCmdDiskClone(out io.Writer) *cobra.Command { - var async *bool - req := base.BizClient.NewCloneUDiskRequest() - enableDataArk := sdk.String("false") - cmd := &cobra.Command{ - Use: "clone", - Short: "Clone an udisk", - Long: "Clone an udisk", - Run: func(cmd *cobra.Command, args []string) { - if *enableDataArk == "true" { - req.UDataArkMode = sdk.String("Yes") - } else { - req.UDataArkMode = sdk.String("No") - } - if strings.Index(*req.SourceId, "/") > -1 { - *req.SourceId = strings.SplitN(*req.SourceId, "/", 2)[0] - } - resp, err := base.BizClient.CloneUDisk(req) - if err != nil { - base.HandleError(err) - return - } - if len(resp.UDiskId) == 1 { - text := fmt.Sprintf("cloned udisk:[%s] is initializing", resp.UDiskId[0]) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewSpoller(describeUdiskByID, out) - poller.Spoll(resp.UDiskId[0], text, []string{status.DISK_AVAILABLE, status.DISK_FAILED}) - } - } else { - base.Cxt.Printf("udisk[%v] cloned", resp.UDiskId) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - req.SourceId = flags.String("source-id", "", "Required. Resource ID of parent udisk") - req.Name = flags.String("name", "", "Required. Name of new udisk") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - req.ChargeType = flags.String("charge-type", "Month", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") - req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") - enableDataArk = flags.String("enable-data-ark", "false", "Optional. DataArk supports real-time backup, which can restore the udisk back to any moment within the last 12 hours.") - req.CouponId = flags.String("coupon-id", "", "Optional. Coupon ID, The Coupon can deduct part of the payment,see https://accountv2.ucloud.cn") - async = flags.Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") - - flags.SetFlagValues("charge-type", "Month", "Year", "Dynamic", "Trial") - flags.SetFlagValues("enable-data-ark", "true", "false") - - flags.SetFlagValuesFunc("source-id", func() []string { - return getDiskList([]string{status.DISK_AVAILABLE}, *req.ProjectId, *req.Region, *req.Zone) - }) - - cmd.MarkFlagRequired("source-id") - cmd.MarkFlagRequired("name") - - return cmd -} - -//NewCmdDiskExpand ucloud udisk expand -func NewCmdDiskExpand() *cobra.Command { - var udiskIDs *[]string - req := base.BizClient.NewResizeUDiskRequest() - cmd := &cobra.Command{ - Use: "expand", - Short: "Expand udisk size", - Long: "Expand udisk size", - Run: func(cmd *cobra.Command, args []string) { - for _, id := range *udiskIDs { - id = base.PickResourceID(id) - req.UDiskId = &id - _, err := base.BizClient.ResizeUDisk(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("udisk:[%s] expanded to %d GB\n", *req.UDiskId, *req.Size) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - udiskIDs = flags.StringSlice("udisk-id", nil, "Required. Resource ID of the udisks to expand") - req.Size = flags.Int("size-gb", 0, "Required. Size of the udisk after expanded. Unit: GB. Range [1,8000]") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - - flags.SetFlagValuesFunc("udisk-id", func() []string { - return getDiskList([]string{status.DISK_AVAILABLE}, *req.ProjectId, *req.Region, *req.Zone) - }) - - cmd.MarkFlagRequired("udisk-id") - cmd.MarkFlagRequired("size-gb") - - return cmd -} - -//NewCmdDiskSnapshot ucloud udisk snapshot -func NewCmdDiskSnapshot(out io.Writer) *cobra.Command { - var async *bool - var udiskIDs *[]string - req := base.BizClient.NewCreateUDiskSnapshotRequest() - cmd := &cobra.Command{ - Use: "snapshot", - Short: "Create shapshots for udisks", - Long: "Create shapshots for udisks", - Run: func(c *cobra.Command, args []string) { - for _, id := range *udiskIDs { - id = base.PickResourceID(id) - req.UDiskId = &id - resp, err := base.BizClient.CreateUDiskSnapshot(req) - if err != nil { - base.HandleError(err) - return - } - if len(resp.SnapshotId) == 1 { - text := fmt.Sprintf("snapshot[%s] is creating", resp.SnapshotId[0]) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewSpoller(describeSnapshotByID, out) - poller.Spoll(resp.SnapshotId[0], text, []string{status.SNAPSHOT_NORMAL}) - } - } else { - fmt.Fprintf(out, "snapshot%v is creating. expect snapshot count 1, accept %d\n", resp.SnapshotId, len(resp.SnapshotId)) - } - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - udiskIDs = flags.StringSlice("udisk-id", nil, "Required. Resource ID of udisks to snapshot") - req.Name = flags.String("name", "", "Required. Name of snapshots") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - req.Comment = flags.String("comment", "", "Optional. Description of snapshots") - async = flags.BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") - flags.SetFlagValuesFunc("udisk-id", func() []string { - return getDiskList([]string{status.DISK_AVAILABLE, status.DISK_INUSE}, *req.ProjectId, *req.Region, *req.Zone) - }) - cmd.MarkFlagRequired("udisk-id") - cmd.MarkFlagRequired("name") - return cmd -} - -//NewCmdDiskRestore ucloud udisk restore -func NewCmdDiskRestore(out io.Writer) *cobra.Command { - var snapshotIDs *[]string - req := base.BizClient.NewRestoreUHostDiskRequest() - cmd := &cobra.Command{ - Use: "restore", - Short: "Restore udisk from snapshot", - Long: "Restore udisk from snapshot", - Run: func(cmd *cobra.Command, args []string) { - for _, snapshotID := range *snapshotIDs { - snapshotID = base.PickResourceID(snapshotID) - any, err := describeSnapshotByID(snapshotID) - if err != nil { - base.HandleError(err) - continue - } - snapshot, ok := any.(*uhost.SnapshotSet) - if !ok { - fmt.Fprintf(out, "snapshot[%s] doesn't exist\n", snapshotID) - continue - } - if snapshot.UHostId != "" { - text := fmt.Sprintf("can we detach udisk[%s] from uhost[%s]?", snapshot.DiskId, snapshot.UHostId) - sure, err := ux.Prompt(text) - if err != nil { - base.HandleError(err) - continue - } - if !sure { - continue - } - detachUdisk(false, snapshot.DiskId, out) - } - req.SnapshotIds = append(req.SnapshotIds, snapshotID) - _, err = base.BizClient.RestoreUHostDisk(req) - - if err != nil { - base.HandleError(err) - return - } - - text := fmt.Sprintf("udisk[%s] has been restored from snapshot[%s]", snapshot.DiskId, snapshot.SnapshotId) - fmt.Fprintln(out, text) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - snapshotIDs = flags.StringSlice("snapshot-id", nil, "Required. Resourece ID of the snapshots to restore from") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - flags.SetFlagValuesFunc("snapshot-id", func() []string { - return getSnapshotList([]string{status.SNAPSHOT_NORMAL}, *req.ProjectId, *req.Region, *req.Zone) - }) - cmd.MarkFlagRequired("snapshot-id") - return cmd -} - -//SnapshotRow 表格行 -type SnapshotRow struct { - Name string - ResourceID string - AvailabilityZone string - BoundUDisk string - Size string - State string - UDiskType string - CreationTime string -} - -//NewCmdSnapshotList ucloud udisk list-snapshot -func NewCmdSnapshotList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeSnapshotRequest() - cmd := &cobra.Command{ - Use: "list-snapshot", - Short: "List snaphosts", - Long: "List snaphosts", - Run: func(c *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeSnapshot(req) - if err != nil { - base.HandleError(err) - return - } - list := []SnapshotRow{} - for _, snapshot := range resp.UHostSnapshotSet { - row := SnapshotRow{ - Name: snapshot.SnapshotName, - ResourceID: snapshot.SnapshotId, - AvailabilityZone: snapshot.Zone, - BoundUDisk: snapshot.DiskId, - Size: fmt.Sprintf("%dGB", snapshot.Size), - State: snapshot.State, - UDiskType: snapshot.DiskType, - CreationTime: base.FormatDate(snapshot.CreateTime), - } - list = append(list, row) - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - req.SnapshotIds = *flags.StringSlice("snaphost-id", nil, "Optional. Resource ID of snapshots to list") - req.UHostId = flags.String("uhost-id", "", "Optional. Snapshots of the uhost") - req.DiskId = flags.String("disk-id", "", "Optional. Snapshots of the udisk") - req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset") - req.Limit = cmd.Flags().Int("limit", 50, "Optional. Limit, length of snaphost list") - - return cmd -} - -//NewCmdSnapshotDelete ucloud udisk delete-snapshot -func NewCmdSnapshotDelete(out io.Writer) *cobra.Command { - var snapshotIds *[]string - req := base.BizClient.NewDeleteSnapshotRequest() - cmd := &cobra.Command{ - Use: "delete-snapshot", - Short: "Delete snapshots", - Long: "Delete snapshots", - Run: func(c *cobra.Command, args []string) { - for _, snapshotID := range *snapshotIds { - req.SnapshotId = sdk.String(base.PickResourceID(snapshotID)) - resp, err := base.BizClient.DeleteSnapshot(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "snapshot[%s] deleted\n", resp.SnapshotId) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - snapshotIds = flags.StringSlice("snaphost-id", nil, "Optional. Resource ID of snapshots to delete") - cmd.MarkFlagRequired("snapshot-id") - return cmd -} - -func getDiskList(states []string, project, region, zone string) []string { - req := base.BizClient.NewDescribeUDiskRequest() - req.ProjectId = sdk.String(project) - req.Region = sdk.String(region) - req.Zone = sdk.String(zone) - req.Limit = sdk.Int(50) - resp, err := base.BizClient.DescribeUDisk(req) - if err != nil { - //todo runtime log - return nil - } - list := []string{} - for _, disk := range resp.DataSet { - for _, s := range states { - if disk.Status == s { - list = append(list, disk.UDiskId+"/"+strings.Replace(disk.Name, " ", "-", -1)) - } - } - } - return list -} - -func describeUdiskByID(udiskID string) (interface{}, error) { - req := base.BizClient.NewDescribeUDiskRequest() - req.UDiskId = sdk.String(udiskID) - req.Limit = sdk.Int(50) - resp, err := base.BizClient.DescribeUDisk(req) - if err != nil { - return nil, err - } - if len(resp.DataSet) < 1 { - return nil, nil - } - return &resp.DataSet[0], nil -} - -func getSnapshotList(states []string, project, region, zone string) []string { - req := base.BizClient.NewDescribeUDiskSnapshotRequest() - req.Limit = sdk.Int(50) - req.ProjectId = &project - req.Region = ®ion - req.Zone = &zone - resp, err := base.BizClient.DescribeUDiskSnapshot(req) - if err != nil { - return nil - } - list := []string{} - for _, snapshot := range resp.DataSet { - for _, s := range states { - if snapshot.Status == s { - list = append(list, snapshot.SnapshotId+"/"+strings.Replace(snapshot.Name, " ", "-", -1)) - } - } - } - return list -} - -func describeSnapshotByID(snapshotID string) (interface{}, error) { - req := base.BizClient.NewDescribeSnapshotRequest() - req.SnapshotIds = append(req.SnapshotIds, snapshotID) - req.Limit = sdk.Int(50) - resp, err := base.BizClient.DescribeSnapshot(req) - if err != nil { - return nil, err - } - if len(resp.UHostSnapshotSet) != 1 { - return nil, nil - } - return &resp.UHostSnapshotSet[0], nil -} diff --git a/cmd/doc_md.go b/cmd/doc_md.go index 271958912c..f689609346 100644 --- a/cmd/doc_md.go +++ b/cmd/doc_md.go @@ -24,10 +24,12 @@ import ( "github.com/ucloud/ucloud-sdk-go/ucloud/log" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/command" ) -//NewCmdDoc ucloud doc +// NewCmdDoc ucloud doc func NewCmdDoc(out io.Writer) *cobra.Command { var dir, format string cmd := &cobra.Command{ @@ -35,9 +37,9 @@ func NewCmdDoc(out io.Writer) *cobra.Command { Short: "Generate documents for all commands", Long: "Generate documents for all commands. Support markdown, rst and douku", Run: func(c *cobra.Command, args []string) { - base.ConfigIns.Region = "" - base.ConfigIns.ProjectID = "" - base.ConfigIns.Zone = "" + platform.ConfigIns.Region = "" + platform.ConfigIns.ProjectID = "" + platform.ConfigIns.Zone = "" rootCmd := NewCmdRoot() addChildren(rootCmd) switch format { @@ -57,9 +59,9 @@ func NewCmdDoc(out io.Writer) *cobra.Command { log.Fatal(err) } case "douku": - prefix := "developer/cli/cmd/" - err := doc.GenDoukuTree(rootCmd, dir, prefix) - printCmdIndex(rootCmd, 0, "developer/cli/cmd") + prefix := "cli/cmd/" + err := genDoukuTree(rootCmd, dir, prefix) + printCmdIndex(rootCmd, 0, "/cli/cmd") if err != nil { log.Fatal(err) } @@ -72,9 +74,9 @@ func NewCmdDoc(out io.Writer) *cobra.Command { cmd.Flags().StringVar(&dir, "dir", "", "Required. The directory where documents of commands are stored") cmd.Flags().StringVar(&format, "format", "douku", "Required. Format of the doucments. Accept values: markdown, rst and douku") - cmd.Flags().SetFlagValues("format", "douku", "markdown", "rst") - cmd.Flags().SetFlagValuesFunc("dir", func() []string { - return base.GetFileList("") + command.SetFlagValues(cmd, "format", "douku", "markdown", "rst") + command.SetCompletion(cmd, "dir", func() []string { + return common.GetFileList("") }) cmd.MarkFlagRequired("dir") diff --git a/cmd/douku.go b/cmd/douku.go new file mode 100644 index 0000000000..900eb3f4f2 --- /dev/null +++ b/cmd/douku.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "io" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" +) + +// genDoukuTreeCustom is the same as GenMarkdownTree, but with custom +// filePrepender and linkHandler. Relocated in-tree from the cobra fork's +// doc.GenDoukuTreeCustom so the fork can be dropped (Task C2). Uses only +// stable upstream cobra/doc API (doc.GenMarkdownCustom). +func genDoukuTreeCustom(index int, cmd *cobra.Command, dir string, filePrepender, linkHandler func(string) string) error { + for i, c := range cmd.Commands() { + if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() { + continue + } + if err := genDoukuTreeCustom(i+1, c, dir, filePrepender, linkHandler); err != nil { + return err + } + } + + basename := strings.Replace(cmd.CommandPath(), " ", "/", -1) + ".md" + filename := filepath.Join(dir, basename) + + fp, _ := filepath.Split(filename) + if _, err := os.Stat(fp); os.IsNotExist(err) { + os.MkdirAll(fp, 0755) + } + + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + if _, err := io.WriteString(f, filePrepender(filename)); err != nil { + return err + } + + if err := doc.GenMarkdownCustom(cmd, f, linkHandler); err != nil { + return err + } + return nil +} + +// genDoukuTree generates a douku wiki page for this command and all +// descendants in the directory given. Relocated in-tree from the cobra fork. +func genDoukuTree(cmd *cobra.Command, dir string, linkPrefix string) error { + doukuLink := func(s string) string { + s = strings.TrimSuffix(s, ".md") + return linkPrefix + strings.Replace(s, "_", "/", -1) + } + emptyStr := func(s string) string { return "" } + return genDoukuTreeCustom(0, cmd, dir, emptyStr, doukuLink) +} diff --git a/cmd/eip.go b/cmd/eip.go deleted file mode 100644 index 3802f8aecd..0000000000 --- a/cmd/eip.go +++ /dev/null @@ -1,651 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - "net" - "strconv" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-sdk-go/services/unet" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/model/status" -) - -//NewCmdEIP ucloud eip -func NewCmdEIP() *cobra.Command { - var cmd = &cobra.Command{ - Use: "eip", - Short: "List,allocate and release EIP", - Long: `Manipulate EIP, such as list,allocate and release`, - Args: cobra.NoArgs, - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdEIPList(out)) - cmd.AddCommand(NewCmdEIPAllocate()) - cmd.AddCommand(NewCmdEIPRelease()) - cmd.AddCommand(NewCmdEIPBind()) - cmd.AddCommand(NewCmdEIPUnbind()) - cmd.AddCommand(NewCmdEIPModifyBandwidth()) - cmd.AddCommand(NewCmdEIPSetChargeMode()) - cmd.AddCommand(NewCmdEIPJoinSharedBW()) - cmd.AddCommand(NewCmdEIPLeaveSharedBW()) - return cmd -} - -//EIPRow 表格行 -type EIPRow struct { - Name string - IP string - ResourceID string - Group string - ChargeMode string - Bandwidth string - BindResource string - Status string - ExpirationTime string -} - -//NewCmdEIPList ucloud eip list -func NewCmdEIPList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeEIPRequest() - fetchAll := false - pageOff := false - cmd := &cobra.Command{ - Use: "list", - Short: "List all EIP instances", - Long: `List all EIP instances`, - Example: "ucloud eip list", - Run: func(cmd *cobra.Command, args []string) { - var eipList []unet.UnetEIPSet - if fetchAll || pageOff { - list, err := fetchAllEip(*req.ProjectId, *req.Region) - if err != nil { - base.HandleError(err) - return - } - eipList = list - } else { - resp, err := base.BizClient.DescribeEIP(req) - if err != nil { - base.HandleError(err) - return - } - eipList = resp.EIPSet - } - - list := make([]EIPRow, 0) - for _, eip := range eipList { - row := EIPRow{} - row.Name = eip.Name - for _, ip := range eip.EIPAddr { - row.IP += ip.IP + " " + ip.OperatorName + " " - } - row.ResourceID = eip.EIPId - row.Group = eip.Tag - row.ChargeMode = eip.PayMode - row.Bandwidth = strconv.Itoa(eip.Bandwidth) + "Mb" - if eip.Resource.ResourceId != "" { - row.BindResource = fmt.Sprintf("%s|%s(%s)", eip.Resource.ResourceName, eip.Resource.ResourceId, eip.Resource.ResourceType) - } - row.Status = eip.Status - row.ExpirationTime = time.Unix(int64(eip.ExpireTime), 0).Format("2006-01-02") - list = append(list, row) - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - bindRegion(req, flags) - bindProjectID(req, flags) - req.Offset = flags.Int("offset", 0, "Optional. Offset default 0") - req.Limit = flags.Int("limit", 50, "Optional. Limit default 50, max value 100") - flags.BoolVar(&fetchAll, "list-all", false, "List all eip") - flags.BoolVar(&pageOff, "page-off", false, "Optional. Paging or not. Accept values: true or false") - flags.SetFlagValues("list-all", "true", "false") - flags.MarkDeprecated("list-all", "please use '--page-off' instead") - - return cmd -} - -func getEIPIDbyIP(ip net.IP, projectID, region string) (string, error) { - eipList, err := fetchAllEip(projectID, region) - if err != nil { - return "", err - } - for _, eip := range eipList { - for _, addr := range eip.EIPAddr { - if addr.IP == ip.String() { - return eip.EIPId, nil - } - } - } - return "", fmt.Errorf("IP[%s] not exist", ip.String()) -} - -func fetchAllEip(projectID, region string) ([]unet.UnetEIPSet, error) { - req := base.BizClient.NewDescribeEIPRequest() - list := []unet.UnetEIPSet{} - req.ProjectId = sdk.String(projectID) - req.Region = sdk.String(region) - for offset, step := 0, 100; ; offset += step { - req.Offset = &offset - req.Limit = &step - resp, err := base.BizClient.DescribeEIP(req) - if err != nil { - return nil, err - } - for i, size := 0, len(resp.EIPSet); i < size; i++ { - list = append(list, resp.EIPSet[i]) - } - if resp.TotalCount <= offset+step { - break - } - } - return list, nil -} - -//states,paymodes 为nil时,不作为过滤条件 -func getAllEip(projectID, region string, states, paymodes []string) []string { - list, err := fetchAllEip(projectID, region) - if err != nil { - return nil - } - strs := []string{} - for _, item := range list { - rightState := false - if states == nil { - rightState = true - } else { - for _, s := range states { - if item.Status == s { - rightState = true - } - } - } - - rightPayMode := false - if paymodes == nil { - rightPayMode = true - } else { - for _, m := range paymodes { - if item.PayMode == m { - rightPayMode = true - } - } - } - if !rightPayMode || !rightState { - continue - } - - ips := []string{} - for _, ip := range item.EIPAddr { - ips = append(ips, ip.IP) - } - strs = append(strs, item.EIPId+"/"+strings.Join(ips, ",")) - } - return strs -} - -func getEIP(eipID string) (*unet.UnetEIPSet, error) { - req := base.BizClient.NewDescribeEIPRequest() - req.EIPIds = append(req.EIPIds, eipID) - resp, err := base.BizClient.DescribeEIP(req) - if err != nil { - return nil, err - } - if len(resp.EIPSet) == 1 { - return &resp.EIPSet[0], nil - } - return nil, fmt.Errorf("eip[%s] may not exist", eipID) -} - -//NewCmdEIPAllocate ucloud eip allocate -func NewCmdEIPAllocate() *cobra.Command { - var count *int - var req = base.BizClient.NewAllocateEIPRequest() - var cmd = &cobra.Command{ - Use: "allocate", - Short: "Allocate EIP", - Long: "Allocate EIP", - Example: "ucloud eip allocate --line BGP --bandwidth-mb 2", - Run: func(cmd *cobra.Command, args []string) { - if *req.OperatorName == "" { - *req.OperatorName = getEIPLine(*req.Region) - } - for i := 0; i < *count; i++ { - resp, err := base.BizClient.AllocateEIP(req) - if err != nil { - base.HandleError(err) - continue - } - for _, eip := range resp.EIPSet { - base.Cxt.Printf("allocate EIP[%s] ", eip.EIPId) - for _, ip := range eip.EIPAddr { - base.Cxt.Printf("IP:%s Line:%s \n", ip.IP, ip.OperatorName) - } - } - } - }, - } - cmd.Flags().SortFlags = false - req.Bandwidth = cmd.Flags().Int("bandwidth-mb", 0, "Required. Bandwidth(Unit:Mbps).The range of value related to network charge mode. By traffic [1, 200]; by bandwidth [1,800] (Unit: Mbps); it could be 0 if the eip belong to the shared bandwidth") - req.OperatorName = cmd.Flags().String("line", "", "Optional. 'BGP' or 'International'. 'BGP' could be set in China mainland regions, such as cn-bj2 etc. 'International' could be set in the regions beyond mainland, such as hk, tw-kh, us-ws etc.") - bindProjectID(req, cmd.Flags()) - bindRegion(req, cmd.Flags()) - req.PayMode = cmd.Flags().String("traffic-mode", "Bandwidth", "Optional. traffic-mode is an enumeration value. 'Traffic','Bandwidth' or 'ShareBandwidth'") - req.ShareBandwidthId = cmd.Flags().String("share-bandwidth-id", "", "Optional. ShareBandwidthId, required only when traffic-mode is 'ShareBandwidth'") - req.Quantity = cmd.Flags().Int("quantity", 1, "Optional. The duration of the instance. N years/months.") - req.ChargeType = cmd.Flags().String("charge-type", "Month", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly(requires permission),'Trial', free trial(need permission)") - req.Tag = cmd.Flags().String("group", "Default", "Optional. Group of your EIP.") - req.Name = cmd.Flags().String("name", "EIP", "Optional. Name of your EIP.") - req.Remark = cmd.Flags().String("remark", "", "Optional. Remark of your EIP.") - count = cmd.Flags().Int("count", 1, "Optional. Count of EIP to allocate") - - cmd.Flags().SetFlagValues("line", "BGP", "International") - cmd.Flags().SetFlagValues("traffic-mode", "Bandwidth", "Traffic", "ShareBandwidth") - cmd.Flags().SetFlagValues("charge-type", "Month", "Year", "Dynamic", "Trial") - cmd.MarkFlagRequired("bandwidth-mb") - return cmd -} - -//NewCmdEIPBind ucloud eip bind -func NewCmdEIPBind() *cobra.Command { - var projectID, region, resourceID, resourceType *string - var eipIDs []string - cmd := &cobra.Command{ - Use: "bind", - Short: "Bind EIP with uhost", - Long: "Bind EIP with uhost", - Example: "ucloud eip bind --eip-id eip-xxx --resource-id uhost-xxx", - Run: func(cmd *cobra.Command, args []string) { - for _, eipID := range eipIDs { - bindEIP(resourceID, resourceType, &eipID, projectID, region) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - cmd.Flags().StringSliceVar(&eipIDs, "eip-id", nil, "Required. EIPId to bind") - resourceID = cmd.Flags().String("resource-id", "", "Required. ResourceID , which is the UHostId of uhost") - resourceType = cmd.Flags().String("resource-type", "uhost", "Requried. ResourceType, type of resource to bind with eip. 'uhost','vrouter','ulb','upm','hadoophost'.eg..") - projectID = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign region") - - cmd.Flags().SetFlagValues("resource-type", "uhost", "vrouter", "ulb", "upm", "hadoophost", "fortresshost", "udockhost", "udhost", "natgw", "udb", "vpngw", "ucdr", "dbaudit") - cmd.Flags().SetFlagValuesFunc("eip-id", func() []string { - return getAllEip(*projectID, *region, []string{status.EIP_FREE}, nil) - }) - - cmd.MarkFlagRequired("eip-id") - cmd.MarkFlagRequired("resource-id") - - return cmd -} - -func bindEIP(resourceID, resourceType, eipID, projectID, region *string) { - ip := net.ParseIP(*eipID) - if ip != nil { - id, err := getEIPIDbyIP(ip, *projectID, *region) - if err != nil { - base.HandleError(err) - } else { - *eipID = id - } - } - req := base.BizClient.NewBindEIPRequest() - req.ResourceId = resourceID - req.ResourceType = resourceType - req.EIPId = sdk.String(base.PickResourceID(*eipID)) - req.ProjectId = sdk.String(base.PickResourceID(*projectID)) - req.Region = region - _, err := base.BizClient.BindEIP(req) - if err != nil { - base.HandleError(err) - } else { - base.Cxt.Printf("bind EIP[%s] with %s[%s]\n", *req.EIPId, *req.ResourceType, *req.ResourceId) - } -} - -func sbindEIP(resourceID, resourceType, eipID, projectID, region *string) ([]string, error) { - logs := make([]string, 0) - ip := net.ParseIP(*eipID) - if ip != nil { - id, err := getEIPIDbyIP(ip, *projectID, *region) - if err != nil { - base.HandleError(err) - } else { - *eipID = id - } - } - req := base.BizClient.NewBindEIPRequest() - req.ResourceId = resourceID - req.ResourceType = resourceType - req.EIPId = sdk.String(base.PickResourceID(*eipID)) - req.ProjectId = sdk.String(base.PickResourceID(*projectID)) - req.Region = region - logs = append(logs, fmt.Sprintf("api: BindEIP, request: %v", base.ToQueryMap(req))) - _, err := base.BizClient.BindEIP(req) - if err != nil { - logs = append(logs, fmt.Sprintf("bind eip failed: %v", err)) - return logs, err - } - logs = append(logs, fmt.Sprintf("bind eip[%s] with %s[%s] successfully", *req.EIPId, *req.ResourceType, *req.ResourceId)) - return logs, nil -} - -//NewCmdEIPUnbind ucloud eip unbind -func NewCmdEIPUnbind() *cobra.Command { - eipIDs := []string{} - req := base.BizClient.NewUnBindEIPRequest() - cmd := &cobra.Command{ - Use: "unbind", - Short: "Unbind EIP with uhost", - Long: "Unbind EIP with uhost", - Example: "ucloud eip unbind --eip-id eip-xxx", - Run: func(cmd *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - for _, eip := range eipIDs { - eipIns, err := getEIP(base.PickResourceID(eip)) - if err != nil { - base.HandleError(err) - return - } - req.EIPId = sdk.String(base.PickResourceID(eip)) - req.ResourceId = sdk.String(eipIns.Resource.ResourceId) - req.ResourceType = sdk.String(eipIns.Resource.ResourceType) - _, err = base.BizClient.UnBindEIP(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("unbind EIP[%s] with %s[%s]\n", *req.EIPId, *req.ResourceType, *req.ResourceId) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&eipIDs, "eip-id", nil, "Required. Resource ID of eips to unbind with some resource") - bindRegion(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("eip-id") - cmd.Flags().SetFlagValuesFunc("eip-id", func() []string { - return getAllEip(*req.ProjectId, *req.Region, []string{status.EIP_USED}, nil) - }) - - return cmd -} - -func unbindEIP(resourceID, resourceType, eipID, projectID, region string) ([]string, error) { - logs := make([]string, 0) - eipID = base.PickResourceID(eipID) - ip := net.ParseIP(eipID) - if ip != nil { - id, err := getEIPIDbyIP(ip, projectID, region) - if err != nil { - base.HandleError(err) - } else { - eipID = id - } - } - req := base.BizClient.NewUnBindEIPRequest() - req.ResourceId = &resourceID - req.ResourceType = &resourceType - req.EIPId = &eipID - req.ProjectId = sdk.String(base.PickResourceID(projectID)) - req.Region = ®ion - logs = append(logs, fmt.Sprintf("api: UnBindEIP, request: %v", base.ToQueryMap(req))) - _, err := base.BizClient.UnBindEIP(req) - if err != nil { - logs = append(logs, fmt.Sprintf("unbind eip failed: %v", err)) - return logs, err - } - logs = append(logs, fmt.Sprintf("unbind eip[%s] with %s[%s] successfully", *req.EIPId, *req.ResourceType, *req.ResourceId)) - return logs, nil -} - -//NewCmdEIPRelease ucloud eip release -func NewCmdEIPRelease() *cobra.Command { - var ids []string - req := base.BizClient.NewReleaseEIPRequest() - cmd := &cobra.Command{ - Use: "release", - Short: "Release EIP", - Long: "Release EIP", - Example: "ucloud eip release --eip-id eip-xx1,eip-xx2", - Run: func(cmd *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - for _, id := range ids { - req.EIPId = sdk.String(base.PickResourceID(id)) - _, err := base.BizClient.ReleaseEIP(req) - if err != nil { - base.HandleError(err) - } else { - base.Cxt.Printf("eip[%s] released\n", *req.EIPId) - } - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - flags.StringSliceVarP(&ids, "eip-id", "", nil, "Required. Resource ID of the EIPs you want to release") - bindProjectID(req, flags) - bindRegion(req, flags) - cmd.MarkFlagRequired("eip-id") - flags.SetFlagValuesFunc("eip-id", func() []string { - return getAllEip(*req.ProjectId, *req.Region, []string{status.EIP_FREE}, nil) - }) - - return cmd -} - -//NewCmdEIPModifyBandwidth ucloud eip modify-bw -func NewCmdEIPModifyBandwidth() *cobra.Command { - ids := []string{} - req := base.BizClient.NewModifyEIPBandwidthRequest() - cmd := &cobra.Command{ - Use: "modify-bw", - Short: "Modify bandwith of EIP instances", - Long: "Modify bandwith of EIP instances", - Example: "ucloud eip modify-bw --eip-id eip-xxx --bandwidth-mb 20", - // Deprecated: "use 'ucloud eip modiy'", - Run: func(cmd *cobra.Command, args []string) { - for _, id := range ids { - id = base.PickResourceID(id) - req.EIPId = &id - _, err := base.BizClient.ModifyEIPBandwidth(req) - if err != nil { - base.HandleError(err) - } else { - base.Cxt.Printf("eip[%s]'s bandwidth modified\n", id) - } - } - }, - } - cmd.Flags().SortFlags = false - cmd.Flags().StringSliceVarP(&ids, "eip-id", "", nil, "Required, Resource ID of EIPs to modify bandwidth") - req.Bandwidth = cmd.Flags().Int("bandwidth-mb", 0, "Required. Bandwidth of EIP after modifed. Charge by traffic, range [1,300]; charge by bandwidth, range [1,800]") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign region") - cmd.Flags().SetFlagValuesFunc("eip-id", func() []string { - return getAllEip(*req.ProjectId, *req.Region, nil, nil) - }) - cmd.MarkFlagRequired("eip-id") - cmd.MarkFlagRequired("bandwidth-mb") - return cmd -} - -//NewCmdEIPSetChargeMode ucloud eip modify-traffic-mode -func NewCmdEIPSetChargeMode() *cobra.Command { - ids := []string{} - req := base.BizClient.NewSetEIPPayModeRequest() - cmd := &cobra.Command{ - Use: "modify-traffic-mode", - Short: "Modify charge mode of EIP instances", - Long: "Modify charge mode of EIP instances", - Example: "ucloud eip modify-traffic-mode --eip-id eip-xx1,eip-xx2 --traffic-mode Traffic", - Run: func(cmd *cobra.Command, args []string) { - for _, id := range ids { - id = base.PickResourceID(id) - req.EIPId = &id - eipIns, err := getEIP(id) - if err != nil { - base.HandleError(err) - return - } - req.Bandwidth = sdk.Int(eipIns.Bandwidth) - _, err = base.BizClient.SetEIPPayMode(req) - if err != nil { - base.HandleError(err) - } else { - base.Cxt.Printf("eip[%s]'s charge mode was modified to %s\n", id, *req.PayMode) - } - } - }, - } - - cmd.Flags().SortFlags = false - cmd.Flags().StringSliceVarP(&ids, "eip-id", "", nil, "Required, Resource ID of EIPs to modify charge mode") - req.PayMode = cmd.Flags().String("traffic-mode", "", "Required, Charge mode of eip, 'Traffic','Bandwidth' or 'PostAccurateBandwidth'") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign region") - cmd.Flags().SetFlagValues("traffic-mode", "Bandwidth", "Traffic", "PostAccurateBandwidth") - cmd.Flags().SetFlagValuesFunc("eip-id", func() []string { - return getAllEip(*req.ProjectId, *req.Region, nil, nil) - }) - cmd.MarkFlagRequired("eip-id") - cmd.MarkFlagRequired("traffic-mode") - return cmd -} - -//NewCmdEIPJoinSharedBW ucloud eip join-shared-bw -func NewCmdEIPJoinSharedBW() *cobra.Command { - eipIDs := []string{} - req := base.BizClient.NewAssociateEIPWithShareBandwidthRequest() - cmd := &cobra.Command{ - Use: "join-shared-bw", - Short: "Join shared bandwidth", - Long: "Join shared bandwidth", - Example: "ucloud eip join-shared-bw --eip-id eip-xxx --shared-bw-id bwshare-xxx", - Run: func(c *cobra.Command, args []string) { - for _, eip := range eipIDs { - req.EIPIds = append(req.EIPIds, base.PickResourceID(eip)) - } - req.ShareBandwidthId = sdk.String(base.PickResourceID(*req.ShareBandwidthId)) - _, err := base.BizClient.AssociateEIPWithShareBandwidth(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("eip%v joined shared bandwidth[%s]\n", req.EIPIds, *req.ShareBandwidthId) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - flags.StringSliceVar(&eipIDs, "eip-id", nil, "Required. Resource ID of EIPs to join shared bandwdith") - req.ShareBandwidthId = flags.String("shared-bw-id", "", "Required. Resource ID of shared bandwidth to be joined") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - flags.SetFlagValuesFunc("eip-id", func() []string { - return getAllEip(*req.ProjectId, *req.Region, nil, []string{status.EIP_CHARGE_BANDWIDTH, status.EIP_CHARGE_TRAFFIC}) - }) - flags.SetFlagValuesFunc("shared-bw-id", func() []string { - list, _ := getAllSharedBW(*req.ProjectId, *req.Region) - return list - }) - cmd.MarkFlagRequired("eip-id") - cmd.MarkFlagRequired("shared-bw-id") - - return cmd -} - -//NewCmdEIPLeaveSharedBW ucloud eip leave-shared-bw -func NewCmdEIPLeaveSharedBW() *cobra.Command { - eipIDs := []string{} - req := base.BizClient.NewDisassociateEIPWithShareBandwidthRequest() - cmd := &cobra.Command{ - Use: "leave-shared-bw", - Short: "Leave shared bandwidth", - Long: "Leave shared bandwidth", - Example: "ucloud eip leave-shared-bw --eip-id eip-b2gvu3", - Run: func(c *cobra.Command, args []string) { - if *req.ShareBandwidthId == "" { - for _, eipID := range eipIDs { - eipIns, err := getEIP(base.PickResourceID(eipID)) - if err != nil { - base.HandleError(err) - continue - } - sharedBWID := eipIns.ShareBandwidthSet.ShareBandwidthId - if sharedBWID == "" { - base.Cxt.Printf("eip[%s] doesn't join any shared bandwidth\n", eipID) - continue - } - req.ShareBandwidthId = sdk.String(sharedBWID) - req.EIPIds = []string{base.PickResourceID(eipID)} - _, err = base.BizClient.DisassociateEIPWithShareBandwidth(req) - if err != nil { - base.HandleError(err) - continue - } - base.Cxt.Printf("eip[%s] left shared bandwidth[%s]\n", eipID, sharedBWID) - } - } else { - for _, id := range eipIDs { - req.EIPIds = append(req.EIPIds, base.PickResourceID(id)) - } - *req.ShareBandwidthId = base.PickResourceID(*req.ShareBandwidthId) - _, err := base.BizClient.DisassociateEIPWithShareBandwidth(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("eip%v left shared bandwidth[%s]\n", eipIDs, *req.ShareBandwidthId) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - flags.StringSliceVar(&eipIDs, "eip-id", nil, "Required. Resource ID of EIPs to leave shared bandwidth") - req.Bandwidth = flags.Int("bandwidth-mb", 1, "Required. Bandwidth of EIP after leaving shared bandwidth, ranging [1,300] for 'Traffic' charge mode, ranging [1,800] for 'Bandwidth' charge mode. Unit:Mb") - req.PayMode = flags.String("traffic-mode", "Bandwidth", "Optional. Charge mode of the EIP after leaving shared bandwidth, 'Bandwidth' or 'Traffic'") - req.ShareBandwidthId = flags.String("shared-bw-id", "", "Optional. Resource ID of shared bandwidth instance, assign this flag to make the operation faster") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - - flags.SetFlagValues("traffic-mode", "Bandwidth", "Traffic") - flags.SetFlagValuesFunc("eip-id", func() []string { - return getAllEip(*req.ProjectId, *req.Region, nil, []string{status.EIP_CHARGE_SHARE}) - }) - flags.SetFlagValuesFunc("shared-bw-id", func() []string { - list, _ := getAllSharedBW(*req.ProjectId, *req.Region) - return list - }) - - cmd.MarkFlagRequired("bandwidth") - cmd.MarkFlagRequired("eip-id") - return cmd -} diff --git a/cmd/ext.go b/cmd/ext.go deleted file mode 100644 index c81411128a..0000000000 --- a/cmd/ext.go +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/model/status" - "github.com/ucloud/ucloud-sdk-go/services/uhost" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" -) - -//NewCmdExt ucloud ext -func NewCmdExt() *cobra.Command { - cmd := &cobra.Command{ - Use: "ext", - Short: "extended commands of UCloud CLI", - Long: "extended commands of UCloud CLI", - } - cmd.AddCommand(NewCmdExtUHost()) - return cmd -} - -//NewCmdExtUHost ucloud ext uhost -func NewCmdExtUHost() *cobra.Command { - cmd := &cobra.Command{ - Use: "uhost", - Short: "extended uhost commands", - Long: "extended uhost commands", - } - cmd.AddCommand(NewCmdExtUHostSwitchEIP()) - return cmd -} - -//NewCmdExtUHostSwitchEIP ucloud ext uhost switch-eip -func NewCmdExtUHostSwitchEIP() *cobra.Command { - var project, region, zone, chargeType, trafficMode, shareBandwidthID string - var uhostIDs, eipAddrs []string - var eipBandwidth, quntity int - var unbind, release bool - - cmd := &cobra.Command{ - Use: "switch-eip", - Short: "Switch EIP for UHost instances", - Long: "Switch EIP for UHost instances", - Example: "ucloud ext uhost switch-eip --uhost-id uhost-1n1sxx2,uhost-li4jxx1 --create-eip-bandwidth-mb 2", - Run: func(c *cobra.Command, args []string) { - project = base.PickResourceID(project) - eipAddrMap := make(map[string]bool) - for _, addr := range eipAddrs { - eipAddrMap[addr] = true - } - logs := make([]string, 0) - for _, idname := range uhostIDs { - uhostID := base.PickResourceID(idname) - logs = append(logs, fmt.Sprintf("describe uhost instance by uhostID %s", uhostID)) - ins, err := describeUHostByID(uhostID, project, region, zone) - if err != nil { - errStr := fmt.Sprintf("describe uhost %s failed: %v", uhostID, err) - base.HandleError(fmt.Errorf(errStr)) - logs = append(logs, errStr) - continue - } - uhostIns, ok := ins.(*uhost.UHostInstanceSet) - if !ok { - errStr := fmt.Sprintf("uhost %s does not exist", uhostID) - base.HandleError(fmt.Errorf(errStr)) - logs = append(logs, errStr) - continue - } - for _, ip := range uhostIns.IPSet { - if ip.IPId == "" { - continue - } - if len(eipAddrs) > 0 && eipAddrMap[ip.IP] == false { - continue - } - //申请EIP - req := base.BizClient.NewAllocateEIPRequest() - req.Region = ®ion - req.ProjectId = &project - if strings.HasPrefix(region, "cn") { - req.OperatorName = sdk.String("BGP") - } else { - req.OperatorName = sdk.String("International") - } - req.Bandwidth = &eipBandwidth - req.ChargeType = &chargeType - req.Quantity = &quntity - req.PayMode = &trafficMode - if trafficMode == "ShareBandwidth" { - if shareBandwidthID != "" { - req.ShareBandwidthId = &shareBandwidthID - } else { - errStr := "create-eip-share-bandwidth-id should not be empty when create-eip-traffic-mode is assigned 'ShareBandwidth'" - logs = append(logs, errStr) - base.HandleError(fmt.Errorf(errStr)) - return - } - } - logs = append(logs, fmt.Sprintf("api AllocateEIP, request:%v", base.ToQueryMap(req))) - resp, err := base.BizClient.AllocateEIP(req) - if err != nil { - errStr := fmt.Sprintf("allocate EIP failed: %v", err) - logs = append(logs, errStr) - base.HandleError(fmt.Errorf(errStr)) - continue - } - if len(resp.EIPSet) != 1 { - errStr := fmt.Sprintf("allocate EIP failed, length of eip set is not 1") - base.HandleError(fmt.Errorf(errStr)) - logs = append(logs, errStr) - continue - } - eipID := resp.EIPSet[0].EIPId - eipRet := fmt.Sprintf("allocated new eip %s|%s", eipID, resp.EIPSet[0].EIPAddr[0].IP) - logs = append(logs, eipRet) - fmt.Println(eipRet) - - //绑定新EIP - slogs, err2 := sbindEIP(&uhostID, sdk.String("uhost"), &eipID, &project, ®ion) - logs = append(logs, slogs...) - if err2 != nil { - base.HandleError(fmt.Errorf("bind new eip %s failed: %v", eipID, err2)) - continue - } - fmt.Printf("bound eip %s with uhost %s\n", eipID, uhostID) - - if unbind { - slogs, err := unbindEIP(uhostID, "uhost", ip.IPId, project, region) - logs = append(logs, slogs...) - if err != nil { - base.HandleError(fmt.Errorf("unbind eip %s failed: %v", ip.IPId, err)) - continue - } - fmt.Printf("unbound eip %s|%s with uhost %s\n", ip.IPId, ip.IP, uhostID) - } - - if release { - req := base.BizClient.NewReleaseEIPRequest() - req.ProjectId = &project - req.Region = ®ion - req.EIPId = sdk.String(ip.IPId) - logs = append(logs, fmt.Sprintf("api ReleaseEIP, request:%v", base.ToQueryMap(req))) - _, err := base.BizClient.ReleaseEIP(req) - if err != nil { - errStr := fmt.Sprintf("release eip %s failed: %v", ip.IPId, err) - logs = append(logs, errStr) - base.HandleError(fmt.Errorf(errStr)) - continue - } - releaseRet := fmt.Sprintf("released eip %s|%s", ip.IPId, ip.IP) - logs = append(logs, releaseRet) - fmt.Println(releaseRet) - } - base.LogInfo(logs...) - } - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&uhostIDs, "uhost-id", nil, "Required. Resource ID of uhost instances to switch EIP") - flags.StringSliceVar(&eipAddrs, "eip-addr", nil, "Optional. Address of EIP instances to be replaced. if eip-id is empty, replace all of the EIPs bound with the uhost ") - flags.BoolVar(&unbind, "unbind-all", true, "Optional. Unbind all EIP instances that has been replaced. Accept values:true or false") - flags.BoolVar(&release, "release-all", true, "Optional. Release all EIP instances that has been replaced. Accept values:true or false") - flags.IntVar(&eipBandwidth, "create-eip-bandwidth-mb", 1, "Optional. Bandwidth of EIP instance to be create with. Unit:Mb") - flags.StringVar(&trafficMode, "create-eip-traffic-mode", "Bandwidth", "Optional. traffic-mode is an enumeration value. 'Traffic','Bandwidth' or 'ShareBandwidth'") - flags.StringVar(&shareBandwidthID, "create-eip-share-bandwidth-id", "", "Optional. ShareBandwidthId, required only when traffic-mode is 'ShareBandwidth'") - flags.StringVar(&chargeType, "create-eip-charge-type", "Month", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") - flags.IntVar(&quntity, "create-eip-quantity", 1, "Optional. The duration of the instance. N years/months.") - - flags.SetFlagValues("create-eip-traffic-mode", "Bandwidth", "Traffic", "ShareBandwidth") - flags.SetFlagValues("create-eip-charge-type", "Month", "Year", "Dynamic", "Trial") - - bindProjectIDS(&project, flags) - bindRegionS(®ion, flags) - bindZoneEmptyS(&zone, ®ion, flags) - - flags.SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_RUNNING, status.HOST_STOPPED, status.HOST_FAIL}, project, region, zone) - }) - - cmd.MarkFlagRequired("uhost-id") - - return cmd -} diff --git a/cmd/ext_compat_test.go b/cmd/ext_compat_test.go new file mode 100644 index 0000000000..0c4209a560 --- /dev/null +++ b/cmd/ext_compat_test.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "os" + "strings" + "testing" +) + +func TestExtCommandMigratedOutOfPlatformCmd(t *testing.T) { + if _, err := os.Stat("ext.go"); err == nil { + t.Fatal("cmd/ext.go must be removed after ext migrates to products/eip/internal/ext") + } else if !os.IsNotExist(err) { + t.Fatalf("stat ext.go: %v", err) + } + + src, err := os.ReadFile("root.go") + if err != nil { + t.Fatalf("read root.go: %v", err) + } + if contains := string(src); contains == "" { + t.Fatal("root.go is unexpectedly empty") + } else if strings.Contains(contains, "NewCmdExt(") { + t.Fatal("cmd/root.go must not register NewCmdExt after ext migrates to products/eip") + } +} + +func TestExtCommandDoesNotDependOnCompatShims(t *testing.T) { + for _, path := range []string{"eip_compat.go", "uhost_compat.go"} { + if _, err := os.Stat(path); err == nil { + t.Fatalf("%s must be removed after ext owns its SDK helpers", path) + } else if !os.IsNotExist(err) { + t.Fatalf("stat %s: %v", path, err) + } + } +} diff --git a/cmd/firewall.go b/cmd/firewall.go deleted file mode 100644 index ff13d852a0..0000000000 --- a/cmd/firewall.go +++ /dev/null @@ -1,613 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "bufio" - "fmt" - "io" - "os" - "strings" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-sdk-go/services/unet" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" -) - -//NewCmdFirewall ucloud firewall -func NewCmdFirewall() *cobra.Command { - cmd := &cobra.Command{ - Use: "firewall", - Short: "List and manipulate extranet firewall", - Long: `List and manipulate extranet firewall`, - Args: cobra.NoArgs, - } - writer := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdFirewallList(writer)) - cmd.AddCommand(NewCmdFirewallCreate(writer)) - cmd.AddCommand(NewCmdFirewallAddRule(writer)) - cmd.AddCommand(NewCmdFirewallDeleteRule(writer)) - cmd.AddCommand(NewCmdFirewallApply()) - cmd.AddCommand(NewCmdFirewallCopy()) - cmd.AddCommand(NewCmdFirewallDelete()) - cmd.AddCommand(NewCmdFirewallResource(writer)) - cmd.AddCommand(NewCmdFirewallUpdate(writer)) - - return cmd -} - -//FirewallRow 表格行 -type FirewallRow struct { - ResourceID string - FirewallName string - Rule string - Group string - RuleAmount int - BoundResourceAmount int - CreationTime string -} - -//NewCmdFirewallList ucloud firewall list -func NewCmdFirewallList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeFirewallRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List extranet firewall", - Long: `List extranet firewall`, - Run: func(cmd *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeFirewall(req) - if err != nil { - base.HandleError(err) - return - } - list := []FirewallRow{} - for _, fw := range resp.DataSet { - row := FirewallRow{} - row.ResourceID = fw.FWId - row.FirewallName = fw.Name - row.Group = fw.Tag - row.RuleAmount = len(fw.Rule) - row.BoundResourceAmount = fw.ResourceCount - row.CreationTime = base.FormatDate(fw.CreateTime) - if fw.Remark != "" { - row.FirewallName += "\nremark:" + fw.Remark + "\n" - } - for _, r := range fw.Rule { - rule := fmt.Sprintf("%s|%s|%s|%s|%s", r.ProtocolType, r.DstPort, r.SrcIP, r.RuleAction, r.Priority) - row.Rule += rule + "\n" - } - list = append(list, row) - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - req.FWId = flags.String("firewall-id", "", "Optional. The Rsource ID of firewall. Return all firewalls by default.") - req.ResourceType = flags.String("bound-resource-type", "", "Optional. The type of resource bound on the firewall") - req.ResourceId = flags.String("bound-resource-id", "", "Optional. The resource ID of resource bound on the firewall") - req.Offset = flags.Int("offset", 0, "Optional. Offset") - req.Limit = flags.Int("limit", 50, "Optional. Limit") - return cmd -} - -func parseRulesFromFile(filePath string) ([]string, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer file.Close() - lines := []string{} - scanner := bufio.NewScanner(file) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - if err := scanner.Err(); err != nil { - return nil, err - } - return lines, nil -} - -//NewCmdFirewallCreate ucloud firewall create -func NewCmdFirewallCreate(out io.Writer) *cobra.Command { - var rulesFilePath string - var rules []string - - req := base.BizClient.NewCreateFirewallRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create firewall", - Long: "Create firewall", - Example: `ucloud firewall create --name test3 --rules "TCP|22|0.0.0.0/0|ACCEPT|HIGH" --rules-file firewall_rules.txt`, - Run: func(c *cobra.Command, args []string) { - if rules == nil && rulesFilePath == "" { - fmt.Fprintln(out, "Error: flags rules and rules-file can't be both empty") - return - } - if rulesFilePath != "" { - lines, err := parseRulesFromFile(rulesFilePath) - if err != nil { - base.HandleError(err) - return - } - rules = append(rules, lines...) - } - req.Rule = rules - resp, err := base.BizClient.CreateFirewall(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("firewall[%s] created\n", resp.FWId) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - flags.StringSliceVar(&rules, "rules", nil, "Required if rules-file doesn't exist. Schema: Protocol|Port|IP|Action|Level. Prototol range 'TCP','UDP','ICMP' and 'GRE'; Port is a local port accessed by source address, port range [0-65535]; IP is the source address of the network packet that requests ucloud host resource, supporting IP address and network segment, such as '120.132.69.216' or '0.0.0.0/0'; Action is the processing behavior of the packet when the firewall is in effect, including 'ACCEPT' AND 'DROP'; Level, when a rule is added to a firewall, the rules take effect in order of level, which range 'HIGH','MEDIUM' and 'LOW'. For example, 'TCP|22|192.168.1.1/22|DROP|LOW'") - flags.StringVar(&rulesFilePath, "rules-file", "", "Required if rules doesn't exist. Path of rules file, in which each rule occupies one line. Schema: Protocol|Port|IP|Action|Level.") - req.Name = flags.String("name", "", "Required. Name of firewall to create") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - req.Tag = flags.String("group", "", "Optional. Group of the firewall to create") - req.Remark = flags.String("remark", "", "Optional. Remark of the firewall to create") - cmd.MarkFlagRequired("name") - flags.SetFlagValuesFunc("rules-file", func() []string { - return base.GetFileList("") - }) - return cmd -} - -//NewCmdFirewallAddRule ucloud firewall add-rule -func NewCmdFirewallAddRule(out io.Writer) *cobra.Command { - var rulesFilePath string - var fwIDs []string - req := base.BizClient.NewUpdateFirewallRequest() - cmd := &cobra.Command{ - Use: "add-rule", - Short: "Add rule to firewall instance", - Long: "Add rule to firewall instance", - Example: `ucloud firewall add-rule --fw-id firewall-2xxxxz/test.lxj2 --rules "TCP|24|0.0.0.0/0|ACCEPT|HIGH" --rules-file firewall_rules.txt`, - Run: func(c *cobra.Command, args []string) { - if req.Rule == nil && rulesFilePath == "" { - fmt.Fprintln(out, "Error: flags rules and rules-file can't be both empty") - return - } - for _, fwID := range fwIDs { - id := base.PickResourceID(fwID) - req.FWId = &id - firewall, err := getFirewall(*req.FWId, *req.ProjectId, *req.Region) - if err != nil { - base.HandleError(err) - return - } - ruleMap := map[string]bool{} - for _, r := range firewall.Rule { - ruleStr := fmt.Sprintf("%s|%s|%s|%s|%s", r.ProtocolType, r.DstPort, r.SrcIP, r.RuleAction, r.Priority) - ruleMap[ruleStr] = true - } - if rulesFilePath != "" { - rules, err := parseRulesFromFile(rulesFilePath) - if err != nil { - base.HandleError(err) - return - } - req.Rule = append(req.Rule, rules...) - } - for _, r := range req.Rule { - ruleMap[r] = true - } - req.Rule = []string{} - for r := range ruleMap { - r = strings.TrimSpace(r) - req.Rule = append(req.Rule, r) - } - _, err = base.BizClient.UpdateFirewall(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("firewall[%s] updated\n", fwID) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&fwIDs, "fw-id", nil, "Required. Resource ID of firewalls to update") - flags.StringSliceVar(&req.Rule, "rules", nil, "Required if rules-file is empay. Rules to add to firewall. Schema:'Protocol|Port|IP|Action|Level'. See 'ucloud firewall create --help' for detail.") - flags.StringVar(&rulesFilePath, "rules-file", "", "Required if rules is empty. Path of rules file, in which each rule occupies one line. Schema: Protocol|Port|IP|Action|Level.") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - - flags.SetFlagValuesFunc("fw-id", func() []string { - return getFirewallIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("rules-file", func() []string { - return base.GetFileList("") - }) - - cmd.MarkFlagRequired("fw-id") - return cmd -} - -//NewCmdFirewallDeleteRule ucloud firewall remove-rule -func NewCmdFirewallDeleteRule(out io.Writer) *cobra.Command { - var rulesFilePath string - var fwIDs []string - req := base.BizClient.NewUpdateFirewallRequest() - cmd := &cobra.Command{ - Use: "remove-rule", - Short: "Remove rule from firewall instance", - Long: "Remove rule from firewall instance", - Example: `ucloud firewall remove-rule --fw-id firewall-2cxxxz/test.lxj2 --rules "TCP|24|0.0.0.0/0|ACCEPT|HIGH" --rules-file firewall_rules.txt`, - Run: func(c *cobra.Command, args []string) { - if req.Rule == nil && rulesFilePath == "" { - fmt.Fprintln(out, "Error: flags rules and rules-file can't be both empty") - return - } - for _, fwID := range fwIDs { - id := base.PickResourceID(fwID) - req.FWId = &id - firewall, err := getFirewall(*req.FWId, *req.ProjectId, *req.Region) - if err != nil { - base.HandleError(err) - return - } - ruleMap := map[string]bool{} - for _, r := range firewall.Rule { - ruleStr := fmt.Sprintf("%s|%s|%s|%s|%s", r.ProtocolType, r.DstPort, r.SrcIP, r.RuleAction, r.Priority) - ruleMap[ruleStr] = true - } - if rulesFilePath != "" { - rules, err := parseRulesFromFile(rulesFilePath) - if err != nil { - base.HandleError(err) - return - } - req.Rule = append(req.Rule, rules...) - } - for _, r := range req.Rule { - r = strings.TrimSpace(r) - delete(ruleMap, r) - } - req.Rule = []string{} - for r := range ruleMap { - req.Rule = append(req.Rule, r) - } - if len(req.Rule) == 0 { - fmt.Fprintf(out, "Error: rules can't be all deleted\n") - return - } - _, err = base.BizClient.UpdateFirewall(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "firewall[%s] updated\n", fwID) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&fwIDs, "fw-id", nil, "Required. Resource ID of firewalls to update") - flags.StringSliceVar(&req.Rule, "rules", nil, "Required if rules-file is empay. Rules to add to firewall. Schema:'Protocol|Port|IP|Action|Level'. See 'ucloud firewall create --help' for detail.") - flags.StringVar(&rulesFilePath, "rules-file", "", "Required if rules is empty. Path of rules file, in which each rule occupies one line. Schema: Protocol|Port|IP|Action|Level.") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - - flags.SetFlagValuesFunc("fw-id", func() []string { - return getFirewallIDNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("fw-id") - return cmd -} - -//NewCmdFirewallApply ucloud firewall apply -func NewCmdFirewallApply() *cobra.Command { - req := base.BizClient.NewGrantFirewallRequest() - resourceIDs := []string{} - fwID := "" - cmd := &cobra.Command{ - Use: "apply", - Short: "Applay firewall to ucloud service", - Long: "Applay firewall to ucloud service", - Example: "ucloud firewall apply --fw-id firewall-xxx --resource-id uhost-xxx --resource-type uhost", - Run: func(c *cobra.Command, args []string) { - req.FWId = sdk.String(base.PickResourceID(fwID)) - for _, id := range resourceIDs { - req.ResourceId = sdk.String(id) - _, err := base.BizClient.GrantFirewall(req) - if err != nil { - base.HandleError(err) - continue - } - base.Cxt.Printf("firewall[%s] applied to %s[%s]\n", fwID, *req.ResourceType, id) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVar(&fwID, "fw-id", "", "Required. Resource ID of firewall to apply to some ucloud resource") - req.ResourceType = flags.String("resource-type", "", "Required. Resource type of resource to be applied firewall. Range 'uhost','unatgw','upm','hadoophost','fortresshost','udhost','udockhost','dbaudit'.") - flags.StringSliceVar(&resourceIDs, "resource-id", nil, "Resource ID of resources to be applied firewall") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - - flags.SetFlagValues("resource-type", "uhost", "unatgw", "upm", "hadoophost", "fortresshost", "udhost", "udockhost", "dbaudit") - flags.SetFlagValuesFunc("fw-id", func() []string { - return getFirewallIDNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("fw-id") - cmd.MarkFlagRequired("resource-id") - cmd.MarkFlagRequired("resource-type") - - return cmd -} - -//NewCmdFirewallCopy ucloud firewall copy -func NewCmdFirewallCopy() *cobra.Command { - srcFirewall := "" - srcRegion := "" - req := base.BizClient.NewCreateFirewallRequest() - cmd := &cobra.Command{ - Use: "copy", - Short: "Copy firewall", - Long: "Copy firewall", - Example: "ucloud firewall copy --src-fw firewall-xxx --target-region cn-bj2 --name test", - Run: func(c *cobra.Command, args []string) { - fwID := base.PickResourceID(srcFirewall) - firewall, err := getFirewall(fwID, *req.ProjectId, srcRegion) - - if err != nil { - base.HandleError(err) - return - } - req.Tag = sdk.String(firewall.Tag) - req.Remark = sdk.String(firewall.Remark) - for _, r := range firewall.Rule { - rstr := fmt.Sprintf("%s|%s|%s|%s|%s", r.ProtocolType, r.DstPort, r.SrcIP, r.RuleAction, r.Priority) - req.Rule = append(req.Rule, rstr) - } - resp, err := base.BizClient.CreateFirewall(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("firewall[%s] created from %s\n", resp.FWId, srcFirewall) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - flags.StringVar(&srcFirewall, "src-fw", "", "Required. ResourceID or name of source firewall") - req.Name = flags.String("name", "", "Required. Name of new firewall") - flags.StringVar(&srcRegion, "region", base.ConfigIns.Region, "Optional. Current region, used to fetch source firewall") - req.Region = flags.String("target-region", base.ConfigIns.Region, "Optional. Copy firewall to target region") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - - flags.SetFlagValuesFunc("src-fw", func() []string { - return getFirewallIDNames(*req.ProjectId, srcRegion) - }) - flags.SetFlagValuesFunc("target-region", getRegionList) - flags.SetFlagValuesFunc("region", getRegionList) - - cmd.MarkFlagRequired("src-fw-id") - cmd.MarkFlagRequired("name") - - return cmd -} - -//NewCmdFirewallDelete ucloud firewall delete -func NewCmdFirewallDelete() *cobra.Command { - req := base.BizClient.NewDeleteFirewallRequest() - ids := []string{} - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete firewall by resource ids or names", - Long: "Delete firewall by resource ids or names", - Example: "ucloud firewall delete --fw-id firewall-xxx", - Run: func(c *cobra.Command, args []string) { - for _, id := range ids { - req.FWId = sdk.String(base.PickResourceID(id)) - _, err := base.BizClient.DeleteFirewall(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("firewall[%s] deleted\n", id) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - flags.StringSliceVar(&ids, "fw-id", nil, "Required. Resource IDs of firewall to delete") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - - cmd.MarkFlagRequired("fw-id") - flags.SetFlagValuesFunc("fw-id", func() []string { - return getFirewallIDNames(*req.ProjectId, *req.Region) - }) - - return cmd -} - -//FirewallResourceRow 表格行 -type FirewallResourceRow struct { - ResourceName string - ResourceID string - ResourceType string - IntranetIP string - Group string - Remark string -} - -//NewCmdFirewallResource ucloud firewall resource -func NewCmdFirewallResource(out io.Writer) *cobra.Command { - fwID := "" - req := base.BizClient.NewDescribeFirewallResourceRequest() - cmd := &cobra.Command{ - Use: "resource", - Short: "List resources that has been applied the firewall", - Long: "List resources that has been applied the firewall", - Run: func(c *cobra.Command, args []string) { - req.FWId = sdk.String(base.PickResourceID(fwID)) - resp, err := base.BizClient.DescribeFirewallResource(req) - if err != nil { - base.HandleError(err) - return - } - list := []FirewallResourceRow{} - for _, rs := range resp.ResourceSet { - row := FirewallResourceRow{} - row.ResourceName = rs.Name - row.ResourceID = rs.ResourceID - row.ResourceType = rs.ResourceType - row.IntranetIP = rs.PrivateIP - row.Group = rs.Tag - row.Remark = rs.Remark - list = append(list, row) - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVar(&fwID, "fw-id", "", "Required. Resource ID of firewall") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - req.Offset = flags.String("offset", "0", "Optional. Offset") - req.Limit = flags.String("limit", "50", "Optional. Limit") - - flags.SetFlagValuesFunc("fw-id", func() []string { - return getFirewallIDNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("fw-id") - - return cmd -} - -//NewCmdFirewallUpdate ucloud firewall update -func NewCmdFirewallUpdate(out io.Writer) *cobra.Command { - fwIDs := []string{} - req := base.BizClient.NewUpdateFirewallAttributeRequest() - cmd := &cobra.Command{ - Use: "update", - Short: "Update firewall attribute, such as name,group and remark.", - Long: "Update firewall attribute, such as name,group and remark.", - Example: `ucloud firewall update --fw-id firewall-2xxxx/test2 --name test_update.1 --remark "this is a remark"`, - Run: func(c *cobra.Command, args []string) { - if *req.Name == "" && *req.Tag == "" && *req.Remark == "" { - fmt.Fprintln(out, "Error: name, group and remark can't be all empty") - return - } - if *req.Name == "" { - req.Name = nil - } - if *req.Tag == "" { - req.Tag = nil - } - if *req.Remark == "" { - req.Remark = nil - } - for _, id := range fwIDs { - req.FWId = sdk.String(base.PickResourceID(id)) - _, err := base.BizClient.UpdateFirewallAttribute(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "firewall[%s] updated\n", id) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&fwIDs, "fw-id", nil, "Required. Resource ID of firewalls") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - req.Name = flags.String("name", "", "Name of firewall") - req.Tag = flags.String("group", "", "Group of firewall") - req.Remark = flags.String("remark", "", "Remark of firewall") - - flags.SetFlagValuesFunc("fw-id", func() []string { - return getFirewallIDNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("fw-id") - - return cmd -} - -func getFirewallIDNames(project, region string) (idNames []string) { - list, err := getAllFirewallIns(project, region) - if err != nil { - return - } - for _, f := range list { - idNames = append(idNames, f.FWId+"/"+f.Name) - } - return -} - -func getFirewall(fwNameID, project, region string) (*unet.FirewallDataSet, error) { - var firewall *unet.FirewallDataSet - list, err := getAllFirewallIns(project, region) - if err != nil { - return nil, err - } - for i, fw := range list { - if fw.FWId == fwNameID || fw.Name == fwNameID { - firewall = &list[i] - } - } - if firewall == nil { - return nil, fmt.Errorf("firwall[%s] does not exist", fwNameID) - } - return firewall, nil -} - -func getAllFirewallIns(project, region string) ([]unet.FirewallDataSet, error) { - req := base.BizClient.NewDescribeFirewallRequest() - req.ProjectId = sdk.String(project) - req.Region = sdk.String(region) - list := []unet.FirewallDataSet{} - for offset, limit := 0, 100; ; offset += limit { - req.Offset = sdk.Int(offset) - req.Limit = sdk.Int(limit) - resp, err := base.BizClient.DescribeFirewall(req) - if err != nil { - return nil, err - } - for _, fw := range resp.DataSet { - list = append(list, fw) - } - if resp.TotalCount < offset+limit { - break - } - } - return list, nil -} diff --git a/cmd/globalssh.go b/cmd/globalssh.go deleted file mode 100644 index bbacc5a560..0000000000 --- a/cmd/globalssh.go +++ /dev/null @@ -1,322 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - "net" - "strings" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-sdk-go/services/pathx" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" -) - -//NewCmdGssh ucloud gssh -func NewCmdGssh() *cobra.Command { - cmd := &cobra.Command{ - Use: "gssh", - Short: "Create,list,update and delete globalssh instance", - Long: `Create,list,update and delete globalssh instance`, - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdGsshList(out)) - cmd.AddCommand(NewCmdGsshCreate()) - cmd.AddCommand(NewCmdGsshDelete()) - cmd.AddCommand(NewCmdGsshModify()) - cmd.AddCommand(NewCmdGsshArea()) - return cmd -} - -//GSSHRow gssh表格行 -type GSSHRow struct { - ResourceID string - SSHServerIP string - AcceleratingDomain string - SSHServerLocation string - SSHPort int - Remark string -} - -//NewCmdGsshList ucloud gssh list -func NewCmdGsshList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeGlobalSSHInstanceRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List all GlobalSSH instances", - Long: `List all GlobalSSH instances`, - Example: "ucloud gssh list", - Run: func(cmd *cobra.Command, args []string) { - var areaMap = map[string]string{ - "洛杉矶": "LosAngeles", - "新加坡": "Singapore", - "香港": "HongKong", - "东京": "Tokyo", - "华盛顿": "Washington", - "法兰克福": "Frankfurt", - "拉各斯": "Lagos", - } - - resp, err := base.BizClient.DescribeGlobalSSHInstance(req) - if err != nil { - base.HandleError(err) - } else { - list := make([]GSSHRow, 0) - for _, gssh := range resp.InstanceSet { - row := GSSHRow{} - row.ResourceID = gssh.InstanceId - row.SSHServerIP = gssh.TargetIP - row.AcceleratingDomain = gssh.AcceleratingDomain - row.SSHPort = gssh.Port - row.Remark = gssh.Remark - if val, ok := areaMap[gssh.Area]; ok { - row.SSHServerLocation = val - } else { - row.SSHServerLocation = gssh.Area - } - list = append(list, row) - } - base.PrintList(list, out) - } - }, - } - cmd.Flags().SortFlags = false - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign region") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - return cmd -} - -//NewCmdGsshArea ucloud gssh area -func NewCmdGsshArea() *cobra.Command { - req := base.BizClient.NewDescribeGlobalSSHAreaRequest() - cmd := &cobra.Command{ - Use: "location", - Short: "List SSH server locations and covered areas", - Long: "List SSH server locations and covered areas", - Run: func(cmd *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeGlobalSSHArea(req) - if err != nil { - base.HandleError(err) - return - } - list := make([]GsshLocation, 0) - for _, item := range resp.AreaSet { - row := GsshLocation{ - AirportCode: item.AreaCode, - SSHServerLocation: areaCodeMap[item.AreaCode], - } - regionLabels := make([]string, 0) - for _, region := range item.RegionSet { - regionLabels = append(regionLabels, base.RegionLabel[region]) - } - row.CoveredArea = strings.Join(regionLabels, ",") - list = append(list, row) - } - - base.PrintTable(list, []string{"AirportCode", "SSHServerLocation", "CoveredArea"}) - }, - } - return cmd -} - -//GsshLocation 服务地点和覆盖区域 -type GsshLocation struct { - AirportCode string - SSHServerLocation string - CoveredArea string -} - -var areaCodeMap = map[string]string{ - "LAX": "LosAngeles", - "SIN": "Singapore", - "HKG": "HongKong", - "HND": "Tokyo", - "IAD": "Washington", - "FRA": "Frankfurt", - "LOS": "Lagos", -} - -//NewCmdGsshCreate ucloud gssh create -func NewCmdGsshCreate() *cobra.Command { - var targetIP *net.IP - req := base.BizClient.NewCreateGlobalSSHInstanceRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create GlobalSSH instance", - Long: "Create GlobalSSH instance", - Example: "ucloud gssh create --location Washington --target-ip 8.8.8.8", - Run: func(cmd *cobra.Command, args []string) { - port := *req.Port - for code, area := range areaCodeMap { - if area == *req.AreaCode { - *req.AreaCode = code - } - } - if port < 1 || port > 65535 || port == 80 || port == 443 { - base.Cxt.Println("The port number should be between 1 and 65535, and cannot be 80 or 443") - return - } - req.TargetIP = sdk.String(targetIP.String()) - resp, err := base.BizClient.CreateGlobalSSHInstance(req) - if err != nil { - base.HandleError(err) - } else { - base.Cxt.Printf("gssh[%s] created\n", resp.InstanceId) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.AreaCode = cmd.Flags().String("location", "", "Required. Location of the source server. See 'ucloud gssh location'") - targetIP = cmd.Flags().IP("target-ip", nil, "Required. IP of the source server. Required") - bindProjectID(req, flags) - req.Port = cmd.Flags().Int("port", 22, "Optional. Port of The SSH service between 1 and 65535. Do not use ports such as 80,443.") - req.Remark = cmd.Flags().String("remark", "", "Optional. Remark of your GlobalSSH.") - req.ChargeType = cmd.Flags().String("charge-type", "Month", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly(requires access)") - req.Quantity = cmd.Flags().Int("quantity", 1, "Optional. The duration of the instance. N years/months.") - - cmd.MarkFlagRequired("location") - cmd.MarkFlagRequired("target-ip") - cmd.Flags().SetFlagValues("location", "LosAngeles", "Singapore", "Lagos", "HongKong", "Tokyo", "Washington", "Frankfurt") - cmd.Flags().SetFlagValues("charge-type", "Month", "Year", "Dynamic", "Trial") - cmd.Flags().SetFlagValuesFunc("target-ip", func() []string { - eips := getAllEip(*req.ProjectId, base.ConfigIns.Region, nil, nil) - for idx, eip := range eips { - eips[idx] = strings.SplitN(eip, "/", 2)[1] - } - return eips - }) - return cmd -} - -//NewCmdGsshDelete ucloud gssh delete -func NewCmdGsshDelete() *cobra.Command { - var req = base.BizClient.NewDeleteGlobalSSHInstanceRequest() - var gsshIds *[]string - var cmd = &cobra.Command{ - Use: "delete", - Short: "Delete GlobalSSH instance", - Long: "Delete GlobalSSH instance", - Example: "ucloud gssh delete --gssh-id uga-xx1 --id uga-xx2", - Run: func(cmd *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - for _, id := range *gsshIds { - req.InstanceId = sdk.String(base.PickResourceID(id)) - _, err := base.BizClient.DeleteGlobalSSHInstance(req) - if err != nil { - base.HandleError(err) - } else { - base.Cxt.Printf("gssh[%s] deleted\n", id) - } - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - gsshIds = cmd.Flags().StringSlice("gssh-id", make([]string, 0), "Required. ID of the GlobalSSH instances you want to delete. Multiple values specified by multiple commas") - bindProjectID(req, flags) - cmd.MarkFlagRequired("gssh-id") - cmd.Flags().SetFlagValuesFunc("gssh-id", func() []string { - return getAllGsshIDNames(*req.ProjectId) - }) - return cmd -} - -//NewCmdGsshModify ucloud gssh modify -func NewCmdGsshModify() *cobra.Command { - gsshModifyPortReq := base.BizClient.NewModifyGlobalSSHPortRequest() - gsshModifyRemarkReq := base.BizClient.NewModifyGlobalSSHRemarkRequest() - project := base.ConfigIns.ProjectID - gsshIDs := []string{} - cmd := &cobra.Command{ - Use: "update", - Short: "Update GlobalSSH instance", - Long: "Update GlobalSSH instance, including port and remark attribute", - Example: "ucloud gssh update --gssh-id uga-xxx --port 22", - Run: func(cmd *cobra.Command, args []string) { - gsshModifyPortReq.ProjectId = sdk.String(project) - gsshModifyRemarkReq.ProjectId = sdk.String(project) - if *gsshModifyPortReq.Port == 0 && *gsshModifyRemarkReq.Remark == "" { - base.Cxt.Println("Error, port or remark required") - } - if *gsshModifyPortReq.Port != 0 { - port := *gsshModifyPortReq.Port - if port <= 1 || port >= 65535 || port == 80 || port == 443 { - base.Cxt.Println("The port number should be between 1 and 65535, and cannot be equal to 80 or 443") - return - } - for _, idname := range gsshIDs { - gsshModifyPortReq.InstanceId = sdk.String(base.PickResourceID(idname)) - _, err := base.BizClient.ModifyGlobalSSHPort(gsshModifyPortReq) - if err != nil { - base.HandleError(err) - } else { - base.Cxt.Printf("gssh[%s]'s port updated\n", *gsshModifyPortReq.InstanceId) - } - } - } - if *gsshModifyRemarkReq.Remark != "" { - for _, idname := range gsshIDs { - gsshModifyRemarkReq.InstanceId = sdk.String(base.PickResourceID(idname)) - _, err := base.BizClient.ModifyGlobalSSHRemark(gsshModifyRemarkReq) - if err != nil { - base.HandleError(err) - } else { - base.Cxt.Printf("gssh[%s]'s remark updated\n", *gsshModifyRemarkReq.InstanceId) - } - } - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&gsshIDs, "gssh-id", nil, "Required. ResourceID of your GlobalSSH instances") - bindProjectIDS(&project, flags) - gsshModifyPortReq.Port = cmd.Flags().Int("port", 0, "Optional. Port of SSH service.") - gsshModifyRemarkReq.Remark = cmd.Flags().String("remark", "", "Optional. Remark of your GlobalSSH.") - cmd.MarkFlagRequired("gssh-id") - cmd.Flags().SetFlagValuesFunc("gssh-id", func() []string { - return getAllGsshIDNames(project) - }) - return cmd -} - -func getAllGssh(project string) ([]pathx.GlobalSSHInfo, error) { - req := base.BizClient.NewDescribeGlobalSSHInstanceRequest() - req.ProjectId = &project - resp, err := base.BizClient.DescribeGlobalSSHInstance(req) - if err != nil { - return nil, err - } - return resp.InstanceSet, nil -} - -func getAllGsshIDNames(project string) []string { - gsshs, err := getAllGssh(project) - if err != nil { - return nil - } - list := []string{} - for _, gssh := range gsshs { - list = append(list, fmt.Sprintf("%s/%s", gssh.InstanceId, gssh.TargetIP)) - } - return list -} diff --git a/cmd/image.go b/cmd/image.go deleted file mode 100644 index 20de130f94..0000000000 --- a/cmd/image.go +++ /dev/null @@ -1,239 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - "strings" - - "github.com/spf13/cobra" - - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/model/cli" - "github.com/ucloud/ucloud-cli/model/status" -) - -//NewCmdUImage ucloud uimage -func NewCmdUImage() *cobra.Command { - cmd := &cobra.Command{ - Use: "image", - Short: "List and manipulate images", - Long: `List and manipulate images`, - Args: cobra.NoArgs, - } - writer := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdUImageList(writer)) - cmd.AddCommand(NewCmdImageCopy(writer)) - cmd.AddCommand(NewCmdUImageDelete()) - createImageCmd := NewCmdUhostCreateImage(writer) - createImageCmd.Use = "create" - cmd.AddCommand(createImageCmd) - - return cmd -} - -//ImageRow 表格行 -type ImageRow struct { - ImageName string - ImageID string - ImageType string - BasicImage string - ExtensibleFeature string - CreationTime string - State string -} - -//NewCmdUImageList ucloud uimage list -func NewCmdUImageList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeImageRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List image", - Long: "List image", - Example: "ucloud image list --image-type Base", - Run: func(cmd *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeImage(req) - if err != nil { - base.HandleError(err) - return - } - list := make([]ImageRow, 0) - for _, image := range resp.ImageSet { - row := ImageRow{} - row.ImageName = image.ImageName - row.ImageID = image.ImageId - row.ImageType = image.ImageType - row.BasicImage = image.OsName - row.ExtensibleFeature = strings.Join(image.Features, ",") - row.CreationTime = base.FormatDate(image.CreateTime) - row.State = image.State - if row.State == "Available" { - list = append(list, row) - } - } - base.PrintList(list, out) - }, - } - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") - req.ImageType = cmd.Flags().String("image-type", "Base", "Optional. 'Base',Standard image; 'Business',image market; 'Custom',custom image") - req.OsType = cmd.Flags().String("os-type", "", "Optional. Linux or Windows. Return all types by default") - req.ImageId = cmd.Flags().String("image-id", "", "Optional. Resource ID of image") - req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0") - req.Limit = cmd.Flags().Int("limit", 500, "Optional. Max count") - cmd.Flags().SetFlagValues("image-type", "Base", "Business", "Custom") - return cmd -} - -// func NewCmdImageImport() *cobra.Command { -// req := BizClient.NewImportCustomImageRequest() -// } - -//NewCmdUImageDelete ucloud image delete -func NewCmdUImageDelete() *cobra.Command { - var imageIDs *[]string - req := base.BizClient.NewTerminateCustomImageRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete custom images", - Long: "Delete custom images", - Run: func(cmd *cobra.Command, args []string) { - for _, id := range *imageIDs { - req.ImageId = sdk.String(base.PickResourceID(id)) - resp, err := base.BizClient.TerminateCustomImage(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("image[%s] deleted\n", resp.ImageId) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - imageIDs = cmd.Flags().StringSlice("image-id", nil, "Required. Resource ID of images") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") - cmd.MarkFlagRequired("image-id") - flags.SetFlagValuesFunc("image-id", func() []string { - return getImageList([]string{status.IMAGE_AVAILABLE, status.IMAGE_COPYING, status.IMAGE_MAKING}, cli.IAMGE_CUSTOM, *req.ProjectId, *req.Region, "") - }) - return cmd -} - -//NewCmdImageCopy ucloud image copy -func NewCmdImageCopy(out io.Writer) *cobra.Command { - var imageIDs *[]string - var async *bool - req := base.BizClient.NewCopyCustomImageRequest() - cmd := &cobra.Command{ - Use: "copy", - Short: "Copy custom images", - Long: "Copy custom images", - Run: func(c *cobra.Command, args []string) { - *req.ProjectId = base.PickResourceID(*req.ProjectId) - *req.TargetProjectId = base.PickResourceID(*req.TargetProjectId) - for _, id := range *imageIDs { - id = base.PickResourceID(id) - req.SourceImageId = &id - resp, err := base.BizClient.CopyCustomImage(req) - if err != nil { - base.HandleError(err) - return - } - text := fmt.Sprintf("image[%s] is coping", resp.TargetImageId) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewPoller(describeImageByID, out) - poller.Poll(resp.TargetImageId, *req.TargetProjectId, *req.TargetRegion, "", text, []string{status.IMAGE_AVAILABLE, status.IMAGE_UNAVAILABLE}) - } - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - imageIDs = cmd.Flags().StringSlice("source-image-id", nil, "Required. Resource ID of source image") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = cmd.Flags().String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - req.TargetRegion = flags.String("target-region", base.ConfigIns.Region, "Optional. Target region. See 'ucloud region'") - req.TargetProjectId = flags.String("target-project", base.ConfigIns.ProjectID, "Optional. Target Project ID. See 'ucloud project list'") - req.TargetImageName = flags.String("target-image-name", "", "Optional. Name of target image") - req.TargetImageDescription = flags.String("target-image-desc", "", "Optional. Description of target image") - async = flags.Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") - - flags.SetFlagValuesFunc("source-image-id", func() []string { - return getImageList([]string{status.IMAGE_AVAILABLE}, cli.IAMGE_CUSTOM, *req.ProjectId, *req.Region, *req.Zone) - }) - flags.SetFlagValuesFunc("project-id", getProjectList) - flags.SetFlagValuesFunc("region", getRegionList) - flags.SetFlagValuesFunc("zone", func() []string { - return getZoneList(*req.Region) - }) - flags.SetFlagValuesFunc("target-region", getRegionList) - flags.SetFlagValuesFunc("target-project", getProjectList) - - cmd.MarkFlagRequired("source-image-id") - - return cmd -} - -func getImageList(states []string, imageType, project, region, zone string) []string { - req := base.BizClient.NewDescribeImageRequest() - req.ProjectId = &project - req.Region = ®ion - req.Zone = &zone - req.Limit = sdk.Int(1000) - if imageType != cli.IMAGE_ALL { - req.ImageType = sdk.String(imageType) - } - resp, err := base.BizClient.DescribeImage(req) - if err != nil { - return nil - } - list := []string{} - for _, image := range resp.ImageSet { - for _, s := range states { - if image.State == s { - list = append(list, image.ImageId+"/"+image.ImageName) - } - } - } - return list -} - -func describeImageByID(imageID, project, region, zone string) (interface{}, error) { - req := base.BizClient.NewDescribeImageRequest() - req.ImageId = sdk.String(imageID) - req.ProjectId = sdk.String(project) - req.Region = sdk.String(region) - req.Zone = sdk.String(zone) - req.Limit = sdk.Int(50) - resp, err := base.BizClient.DescribeImage(req) - if err != nil { - return nil, err - } - if len(resp.ImageSet) < 1 { - return nil, nil - } - return &resp.ImageSet[0], nil -} diff --git a/cmd/internal/platform/client.go b/cmd/internal/platform/client.go new file mode 100644 index 0000000000..e714daa213 --- /dev/null +++ b/cmd/internal/platform/client.go @@ -0,0 +1,289 @@ +package platform + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/ucloud/ucloud-sdk-go/private/protocol/http" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" +) + +// newCredHeaderInjector 返回凭据头注入 handler。 +// aksk/CloudShell 行为与历史完全一致(Cookie/Csrf-Token 始终 set,含空值); +// auth_mode==oauth 时剥离 SDK 编码器无条件附加的签名参数(Credential.Apply 即使 +// 空密钥也会算出 Signature),并在 token 非空时追加 Authorization: Bearer, +// 保证 oauth 请求只携带 Bearer 一种凭据机制(凭据模型见 spec §2)。 +func newCredHeaderInjector(credConfig *CredentialConfig) sdk.HttpRequestHandler { + if credConfig == nil { + credConfig = &CredentialConfig{} + } + return func(c *sdk.Client, req *http.HttpRequest) (*http.HttpRequest, error) { + if err := req.SetHeader("Cookie", credConfig.Cookie); err != nil { + return req, err + } + if err := req.SetHeader("Csrf-Token", credConfig.CSRFToken); err != nil { + return req, err + } + if credConfig.AuthMode == AuthModeOAuth { + // 按 Content-Type 分派剥离:编码器决定 body 形态,剥离方式必须与之配对。 + // 不能只按一种形态盲剥 —— url.ParseQuery 对 JSON 往往"成功",重编码会 + // 悄悄毁掉 body;不认识的 Content-Type 一律不碰 body。 + switch req.GetHeaderMap()[http.HeaderNameContentType] { + case http.MimeFormURLEncoded: + vals, err := url.ParseQuery(string(req.GetRequestBody())) + if err != nil { + // 剥不掉就明确失败:客户端报错优于网关 171 + return req, fmt.Errorf("strip signature params from oauth request failed: %w", err) + } + vals.Del("Signature") + vals.Del("PublicKey") + if err := req.SetRequestBody([]byte(vals.Encode())); err != nil { + return req, err + } + case http.MimeJSON: + // JSONEncoder 把 cred.Apply 附加的 Signature/PublicKey 放在 body 顶层 + // (SDK ucloud/request/encoder_json.go),与 form 分支同构剥离。 + var payload map[string]interface{} + if err := json.Unmarshal(req.GetRequestBody(), &payload); err != nil { + return req, fmt.Errorf("strip signature params from oauth json request failed: %w", err) + } + delete(payload, "Signature") + delete(payload, "PublicKey") + bs, err := json.Marshal(payload) + if err != nil { + return req, fmt.Errorf("re-encode oauth json request failed: %w", err) + } + if err := req.SetRequestBody(bs); err != nil { + return req, err + } + } + if credConfig.AccessToken != "" { + if err := req.SetHeader("Authorization", "Bearer "+credConfig.AccessToken); err != nil { + return req, err + } + } + } + return req, nil + } +} + +// newChannelHeaderInjector 返回专属云渠道头注入 handler。 +// +// 网关据 channel-key 识别「复用主站域名」的专属云渠道(如 api.ucloud-global.com); +// 独立域名渠道与主站用户没有这个 key,故空值必须完全不注入该头 —— 与本文件中 +// Cookie/Csrf-Token「空值也照旧 set」的历史行为刻意相反:那是存量契约,而这是新增头, +// 给全部存量用户平白加一个空头即构成回归。 +// +// 取值源是 ac 而非 credConfig:channel-key 是接入点配置(与 base_url 同类、成对配置), +// 不是凭据机制,与 auth_mode 正交(spec auth-guidelines「一个请求只携带一种凭据机制」 +// 说的是凭据,不含本头)。且 newServiceClientForConfig 传入的 ac 正是用户此刻正在配置 +// 的 profile,config 子命令自身的 region/project 远程校验请求因此天然带上正确的 key, +// 不会出现「配置时校验失败 → 配不上」的死锁。 +// +// 实测(2026-07-16,真实 combo 账号 + 真实网关):同一 token 同一域名,唯一变量为本头, +// RetCode 174 → 0;且 Go 的 Header.Set 规范化为 Channel-Key 后网关照常接受。 +func newChannelHeaderInjector(ac *AggConfig) sdk.HttpRequestHandler { + return func(c *sdk.Client, req *http.HttpRequest) (*http.HttpRequest, error) { + if ac == nil || ac.ChannelKey == "" { + return req, nil + } + return req, req.SetHeader("channel-key", ac.ChannelKey) + } +} + +// authRetCodeWhitelist 鉴权类 RetCode 白名单(D6)。实测网关(2026-06-11 实探): +// 鉴权失败以 HTTP 200 + RetCode 返回,401 仅作防御性分支保留。 +// 174 "Token Not Exists":伪造与已过期的 Bearer 同为 174(已实测确认);属网关 +// 前置鉴权拒绝,业务必未执行,重放一次安全。网关团队书面确认仍待补档(spec §7)。 +// 170(缺签名,oauth 请求恒带 Bearer 不会触发)、171/172(AK/SK 路径)不入列。 +// +// 174 的第三种成因(2026-07-16 实测发现,已知且刻意保留现状):channel-key 与账号 +// 所属渠道不匹配时网关同样返回 174(同一有效 token 换成别的渠道 key 即报 174)。 +// 于是本白名单会把「配错 channel-key」误判为 token 过期 → 刷新(refresh_token 轮转、 +// 旧的立即作废)→ 重放 → 仍 174。后果是每次请求白耗一轮刷新,且错误文案把用户导向 +// 重新登录 —— 而重新登录永远治不好。本次不改控制流(改了会波及正常的过期刷新路径)。 +// 排障:OAuth profile 报 174 且重新登录无效时,优先检查 channel_key 是否与账号所属 +// 渠道匹配。 +var authRetCodeWhitelist = map[int]bool{ + 174: true, // Token Not Exists:无效或过期 Bearer(亦可能是 channel-key 不匹配,见上) +} + +// isAuthFailure 判定是否鉴权类失败:HTTP 401 或 body RetCode 在白名单(网关前置鉴权,业务必未执行)。 +// 注意 SDK 行为:HttpClient.Send 对 status>=400 返回 (nil, StatusError)(vendor +// private/protocol/http/client.go),且默认 errorHTTPHandler 先于本 handler 把它 +// 转成 uerr.ServerError —— 401 只会出现在 err 里、resp 必为 nil;resp 路径仅作 +// RetCode 白名单(HTTP 200 + 鉴权 RetCode)的判定入口。 +func isAuthFailure(resp *http.HttpResponse, err error) bool { + switch e := err.(type) { + case http.StatusError: + if e.StatusCode == 401 { + return true + } + case uerr.ServerError: + if e.StatusCode() == 401 { + return true + } + } + if resp == nil { + return false + } + var body struct { + RetCode int `json:"RetCode"` + } + if jerr := json.Unmarshal(resp.GetBody(), &body); jerr == nil { + return authRetCodeWhitelist[body.RetCode] + } + return false +} + +// newOAuthRetryHandler 反应式兜底(D6,Google 式):鉴权失败 → 刷新 → 自动重放一次。 +// 重放直接走 httpClient.Send,不再经过本 handler,天然不会循环。 +// 刷新对象是构造本 client 的 ac(而非 ConfigIns):cmd/root.go 的 os.Args 扫描 +// 识别不了 -p X/--profile=X 等形式,ConfigIns 可能指向另一个 profile,错刷会把 +// 别人的 Bearer 重放到当前请求上。ac 在所有 oauth 路径上都是 manager 持有的指针 +// (GetAggConfigByProfile/Append 直接存取同一指针),refreshAndSave 的写回因此可靠。 +// req 的 Authorization 由 SetHeader 以 map 赋值覆盖(不会叠加重复头),且 body 中 +// 的签名参数已被 newCredHeaderInjector 剥离,重放仍满足「oauth 请求只带 Bearer」不变式。 +func newOAuthRetryHandler(credConfig *CredentialConfig, ac *AggConfig, manager *AggConfigManager) sdk.HttpResponseHandler { + return func(c *sdk.Client, req *http.HttpRequest, resp *http.HttpResponse, err error) (*http.HttpResponse, error) { + if ac == nil || credConfig.AuthMode != AuthModeOAuth || credConfig.AccessToken == "" { + return resp, err + } + if !isAuthFailure(resp, err) { + return resp, err + } + if manager == nil { + LogWarn("oauth reactive refresh skipped: config manager is not initialized") + return resp, err + } + // 刷新(flock 串行化 + 拿锁后重读,见 refreshAndSave) + if rerr := refreshAndSave(ac, manager); rerr != nil { + LogWarn(fmt.Sprintf("oauth reactive refresh failed: %v", Redact(rerr.Error()))) + return resp, err + } + credConfig.AccessToken = ac.AccessToken + _ = req.SetHeader("Authorization", "Bearer "+credConfig.AccessToken) // SetHeader 恒返回 nil + LogInfo("auth failure detected, token refreshed, replaying request once") + hc := http.NewHttpClient() + nresp, nerr := hc.Send(req) + if serr, ok := nerr.(http.StatusError); ok { + // 本 handler 位于链尾,重放结果不会再经过默认 errorHTTPHandler, + // 在此对齐其行为:StatusError → uerr.ServerError + nerr = uerr.NewServerStatusError(serr.StatusCode, serr.Message) + } + return nresp, nerr + } +} + +// buildCredential 构造 SDK 签名凭据。 +// 不变式:一个请求只携带一种凭据机制(auth_mode 唯一决定走哪种)。 +// oauth profile 会保留旧 AK/SK 在磁盘上(供 auth logout 恢复),但它们必须 +// 对 SDK 签名器不可见——否则签名参数与 Bearer 同时上行,网关先验签名 +// 直接报 RetCode 171 Signature VerifyAC Error。oauth 模式下凭据留空; +// 注意 SDK 编码器对空密钥仍会附加 Signature 参数,由 newCredHeaderInjector +// 剥离,最终 Bearer 是唯一凭据。AK/SK 模式填真实公私钥,SDK 签名器据此签名。 +func buildCredential(credConfig *CredentialConfig) *auth.Credential { + if credConfig == nil { + credConfig = &CredentialConfig{} + } + credential := &auth.Credential{} + if credConfig.AuthMode != AuthModeOAuth { + credential.PublicKey = credConfig.PublicKey + credential.PrivateKey = credConfig.PrivateKey + } + return credential +} + +// BuildCredential 从包级 AuthCredential(由 InitConfig/InitClientRuntime 填充)构造签名凭据。 +// 供 cli.NewServiceClient 使用——与 NewClient 走完全相同的 buildCredential 逻辑/分支, +// oauth 与 AK/SK profile 共用一条代码路径(不分叉,§9 无鉴权回归)。 +func BuildCredential() *auth.Credential { + return BuildCredentialFrom(AuthCredential) +} + +// BuildCredentialFrom constructs an SDK signing credential from an explicit +// credential config, without reading package-level runtime state. +func BuildCredentialFrom(credConfig *CredentialConfig) *auth.Credential { + return buildCredential(credConfig) +} + +// normalizeProjectID 把 project-id 从补全带出的 "id/name" 形态还原成纯 id +// (补全候选由 getProjectList 生成,形如 "org-xxx/ProjectName")。 +// +// typed 请求 SetProjectId 写进 CommonBase 即可。GenericRequest 要多一步:SDK 的 +// BaseGenericRequest 把 GetProjectId() override 成 payload 优先,却没有 override +// SetProjectId —— SetProjectId 写的是 CommonBase,而 GetPayload() 末尾用 payload +// 覆盖 CommonBase(SDK ucloud/request/generic.go),于是归一化被 payload 里的原值 +// 吃掉,"org-x/Name" 原样上行,网关报 RetCode 292 Project [org-x/Name] not exists +// (2026-07-14 对 api.ucloud.cn 实测)。凡是把 ProjectId 放进 payload map 的产品 +// 都会中招,故在此一并同步 payload。 +// +// 未发生归一化时(已是纯 id 或为空,即绝大多数调用)直接返回,payload 一字不动 —— +// 行为与历史逐字节一致。 +func normalizeProjectID(req request.Common) (request.Common, error) { + raw := req.GetProjectId() + normalized := PickResourceID(raw) + if err := req.SetProjectId(normalized); err != nil { + return req, err + } + if raw == normalized { + return req, nil + } + gr, ok := req.(request.GenericRequest) + if !ok { + return req, nil + } + payload := gr.GetPayload() + if _, exists := payload["ProjectId"]; !exists { + return req, nil + } + payload["ProjectId"] = normalized + if err := gr.SetPayload(payload); err != nil { + return req, err + } + return req, nil +} + +// attachHandlers 把平台 handler 挂到 service client 上: +// project-id 归一化、请求日志、凭据头注入、专属云渠道头注入、oauth 反应式重试。 +// credConfig 与 ac 显式传入:NewClient 借此传它自己的构造来源 profile(ac), +// 重试目标必须是构造本 client 的 profile,而非包级 ConfigIns +// (详见 newOAuthRetryHandler 的注释:os.Args 扫描识别不了 -p X/--profile=X, +// ConfigIns 可能指向另一个 profile,错刷会把别人的 Bearer 重放到当前请求)。 +func attachHandlersWithManager(sc sdk.ServiceClient, credConfig *CredentialConfig, ac *AggConfig, manager *AggConfigManager) { + if credConfig == nil { + credConfig = &CredentialConfig{} + } + sc.AddRequestHandler(func(c *sdk.Client, req request.Common) (request.Common, error) { + return normalizeProjectID(req) + }) + // Platform request logging: every API request is logged uniformly at the SDK + // layer (replaces per-command hand-rolled logging; products no longer build + // "api:..." lines with ToQueryMap). logToFile writes to local cli.log only + // (NO DAS upload) and skips completion (COMP_LINE) — see batch-1 plan Part 0 + // Task 0.2 (decision A: keep request logs local, don't inflate telemetry). + sc.AddRequestHandler(func(c *sdk.Client, req request.Common) (request.Common, error) { + logToFile(requestLogLine(req)) + return req, nil + }) + sc.AddHttpRequestHandler(newCredHeaderInjector(credConfig)) + sc.AddHttpRequestHandler(newChannelHeaderInjector(ac)) + sc.AddHttpResponseHandler(newOAuthRetryHandler(credConfig, ac, manager)) +} + +// AttachHandlers 用包级 AuthCredential/ConfigIns(由 InitConfig 填充)把平台 handler +// 挂到 sc 上。供 cli.NewServiceClient 使用——此时活动 profile 就是 ConfigIns, +// 它正是正确的反应式刷新目标。 +func AttachHandlers(sc sdk.ServiceClient) { + AttachHandlersWith(sc, AuthCredential, ConfigIns, AggConfigListIns) +} + +// AttachHandlersWith attaches platform handlers using explicit runtime state, +// so callers do not need the old aggregate base client singleton. +func AttachHandlersWith(sc sdk.ServiceClient, credConfig *CredentialConfig, ac *AggConfig, manager *AggConfigManager) { + attachHandlersWithManager(sc, credConfig, ac, manager) +} diff --git a/cmd/internal/platform/client_test.go b/cmd/internal/platform/client_test.go new file mode 100644 index 0000000000..8b565ec19e --- /dev/null +++ b/cmd/internal/platform/client_test.go @@ -0,0 +1,674 @@ +// base/client_test.go +package platform + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + uhttp "github.com/ucloud/ucloud-sdk-go/private/protocol/http" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" +) + +func injectorHeaders(t *testing.T, cred *CredentialConfig) map[string]string { + t.Helper() + h := newCredHeaderInjector(cred) + req, err := h(nil, uhttp.NewHttpRequest()) + if err != nil { + t.Fatal(err) + } + return req.GetHeaderMap() +} + +// oauth 模式:注入 Authorization Bearer +func TestInjectorOAuthBearer(t *testing.T) { + headers := injectorHeaders(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok123"}) + if headers["Authorization"] != "Bearer tok123" { + t.Errorf("Authorization = %q, want Bearer tok123", headers["Authorization"]) + } +} + +// CRITICAL 回归:aksk 模式(含 CloudShell Cookie 注入)头部行为零变化 +func TestInjectorAkskAndCloudShellUnchanged(t *testing.T) { + // aksk:Cookie/Csrf-Token 照旧(空值也照旧 set),绝不出现 Authorization + h1 := injectorHeaders(t, &CredentialConfig{PublicKey: "pub", PrivateKey: "pri"}) + if _, ok := h1["Authorization"]; ok { + t.Error("aksk mode must NOT inject Authorization header") + } + if v, ok := h1["Cookie"]; !ok || v != "" { + t.Errorf("Cookie header behavior changed: %q %v", v, ok) + } + // CloudShell:Cookie/Csrf-Token 注入照旧 + h2 := injectorHeaders(t, &CredentialConfig{Cookie: "ck", CSRFToken: "cs"}) + if h2["Cookie"] != "ck" || h2["Csrf-Token"] != "cs" { + t.Errorf("cloudshell headers changed: %v", h2) + } + if _, ok := h2["Authorization"]; ok { + t.Error("cloudshell mode must NOT inject Authorization header") + } +} + +// oauth 模式但 token 为空:不注入(让网关报错而不是发送 "Bearer ") +func TestInjectorOAuthEmptyToken(t *testing.T) { + headers := injectorHeaders(t, &CredentialConfig{AuthMode: AuthModeOAuth}) + if _, ok := headers["Authorization"]; ok { + t.Error("empty token must not inject Authorization") + } +} + +// injectBody 把 body 以指定 Content-Type 过一遍 injector,返回处理后的请求。 +func injectBody(t *testing.T, cred *CredentialConfig, contentType, body string) *uhttp.HttpRequest { + t.Helper() + req := uhttp.NewHttpRequest() + if err := req.SetHeader(uhttp.HeaderNameContentType, contentType); err != nil { + t.Fatal(err) + } + if err := req.SetRequestBody([]byte(body)); err != nil { + t.Fatal(err) + } + out, err := newCredHeaderInjector(cred)(nil, req) + if err != nil { + t.Fatal(err) + } + return out +} + +// oauth + JSON body:剥离 Signature/PublicKey,其余字段与 Content-Type 原样保留。 +// JSON 编码器此前不可达;products/pgsql(#127) 是第一个走这条路的产品(UPgSQL 网关 +// 无法把 form 的字符串 "100" unmarshal 进 Go 的 *int,RetCode 214001,故切 JSONEncoder)。 +func TestInjectorOAuthStripsJSONSignature(t *testing.T) { + body := `{"Action":"ListUPgSQLParamTemplate","Count":100,"PublicKey":"pub","Region":"cn-bj2","Signature":"deadbeef"}` + out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok123"}, uhttp.MimeJSON, body) + + var got map[string]interface{} + if err := json.Unmarshal(out.GetRequestBody(), &got); err != nil { + t.Fatalf("body is not valid json after strip: %v", err) + } + if _, ok := got["Signature"]; ok { + t.Error("Signature must be stripped from oauth json body") + } + if _, ok := got["PublicKey"]; ok { + t.Error("PublicKey must be stripped from oauth json body") + } + if got["Action"] != "ListUPgSQLParamTemplate" || got["Region"] != "cn-bj2" { + t.Errorf("business fields must survive untouched, got %v", got) + } + // int 必须仍是 JSON number —— 产品切 JSONEncoder 的全部意义就在于此, + // 剥离过程若把它变回字符串就重新踩回 214001。 + if got["Count"] != float64(100) { + t.Errorf("Count = %#v, want JSON number 100", got["Count"]) + } + if out.GetHeaderMap()[uhttp.HeaderNameContentType] != uhttp.MimeJSON { + t.Error("Content-Type must stay application/json") + } + if out.GetHeaderMap()["Authorization"] != "Bearer tok123" { + t.Error("Bearer must still be injected for json body") + } +} + +// CRITICAL 回归:非 oauth(aksk)模式下 JSON body 必须逐字节不变 —— +// AK/SK 路径的签名就活在 body 里,碰一下就验签失败。 +func TestInjectorAkskJSONBodyUntouched(t *testing.T) { + body := `{"Action":"X","PublicKey":"pub","Signature":"deadbeef"}` + out := injectBody(t, &CredentialConfig{PublicKey: "pub", PrivateKey: "pri"}, uhttp.MimeJSON, body) + if string(out.GetRequestBody()) != body { + t.Errorf("aksk json body must be byte-identical\n got: %s\nwant: %s", out.GetRequestBody(), body) + } +} + +// CRITICAL 回归:oauth + form body 行为与历史完全一致(本次只加分支,不动 form)。 +func TestInjectorOAuthFormUnchanged(t *testing.T) { + body := "Action=X&PublicKey=pub&Region=cn-bj2&Signature=deadbeef" + out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"}, uhttp.MimeFormURLEncoded, body) + vals, err := url.ParseQuery(string(out.GetRequestBody())) + if err != nil { + t.Fatal(err) + } + if vals.Has("Signature") || vals.Has("PublicKey") { + t.Errorf("form signature params must be stripped, got %s", out.GetRequestBody()) + } + if vals.Get("Action") != "X" || vals.Get("Region") != "cn-bj2" { + t.Errorf("form business fields changed: %s", out.GetRequestBody()) + } +} + +// 不认识的 Content-Type:不碰 body(盲目重编码会毁掉它),但 Bearer 照常注入。 +func TestInjectorOAuthUnknownContentTypeBodyUntouched(t *testing.T) { + body := `deadbeef` + out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"}, "application/xml", body) + if string(out.GetRequestBody()) != body { + t.Errorf("unknown content-type body must be untouched, got %s", out.GetRequestBody()) + } + if out.GetHeaderMap()["Authorization"] != "Bearer tok" { + t.Error("Bearer must still be injected for unknown content-type") + } +} + +// 端到端:真实 SDK JSONEncoder + 真实 oauth 凭据,确认 +// (a) SDK 即使凭据为空也会附加 Signature(这正是必须剥离的原因); +// (b) injector 之后 body 内再无签名参数,只剩 Bearer 一种凭据机制。 +func TestInjectorOAuthJSONEndToEndWithSDKEncoder(t *testing.T) { + credConfig := &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"} + cfg := ucloud.NewConfig() + cred := BuildCredentialFrom(credConfig) + + req := &request.CommonBase{} + if err := req.SetAction("ListUPgSQLParamTemplate"); err != nil { + t.Fatal(err) + } + if err := req.SetRegion("cn-bj2"); err != nil { + t.Fatal(err) + } + httpReq, err := request.NewJSONEncoder(&cfg, cred).Encode(req) + if err != nil { + t.Fatal(err) + } + + var before map[string]interface{} + if err := json.Unmarshal(httpReq.GetRequestBody(), &before); err != nil { + t.Fatal(err) + } + if _, ok := before["Signature"]; !ok { + t.Fatal("premise broken: SDK JSONEncoder no longer attaches Signature for empty credential") + } + + out, err := newCredHeaderInjector(credConfig)(nil, httpReq) + if err != nil { + t.Fatal(err) + } + var after map[string]interface{} + if err := json.Unmarshal(out.GetRequestBody(), &after); err != nil { + t.Fatal(err) + } + if _, ok := after["Signature"]; ok { + t.Error("Signature survived the injector on a real SDK-encoded json body") + } + if _, ok := after["PublicKey"]; ok { + t.Error("PublicKey survived the injector on a real SDK-encoded json body") + } + if after["Action"] != "ListUPgSQLParamTemplate" { + t.Errorf("Action lost: %v", after) + } + if out.GetHeaderMap()["Authorization"] != "Bearer tok" { + t.Error("Bearer missing after injector") + } +} + +// recordedRequest 记录业务请求实际携带的 header 与全部参数(query + form 合并) +type recordedRequest struct { + header http.Header + params url.Values +} + +// bizRecorderServer 模拟业务网关:记录请求并返回成功响应 +func bizRecorderServer(t *testing.T, rec *recordedRequest) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + rec.header = r.Header.Clone() + rec.params = r.Form // r.Form 含 URL query + body form,两路都覆盖 + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"RetCode":0,"Action":"GetRegionResponse"}`) + })) +} + +func callGetRegion(t *testing.T, ac *AggConfig, rec *recordedRequest) { + t.Helper() + // InitClientRuntime 会改写包级全局 ClientConfig/AuthCredential,恢复现场避免测试顺序耦合 + oldClientConfig, oldAuthCredential := ClientConfig, AuthCredential + t.Cleanup(func() { + ClientConfig, AuthCredential = oldClientConfig, oldAuthCredential + }) + if err := InitClientRuntime(ac); err != nil { + t.Fatal(err) + } + client := uaccount.NewClient(ClientConfig, BuildCredential()) + AttachHandlersWith(client, AuthCredential, ac, AggConfigListIns) + if _, err := client.GetRegion(client.NewGetRegionRequest()); err != nil { + t.Fatalf("GetRegion failed: %v", err) + } + if rec.params == nil { + t.Fatal("server did not record any request") + } +} + +func newTestUAccountClient(t *testing.T, ac *AggConfig) *uaccount.UAccountClient { + t.Helper() + if err := InitClientRuntime(ac); err != nil { + t.Fatal(err) + } + client := uaccount.NewClient(ClientConfig, BuildCredential()) + AttachHandlersWith(client, AuthCredential, ac, AggConfigListIns) + return client +} + +// CRITICAL 缺陷回归(RetCode 171):oauth profile 残留 AK/SK(供 logout 恢复)时, +// 请求必须只携带 Bearer 一种凭据,绝不能同时出现 SDK 签名参数。 +func TestOAuthProfileWithRetainedKeysDoesNotSign(t *testing.T) { + rec := &recordedRequest{} + s := bizRecorderServer(t, rec) + defer s.Close() + + ac := &AggConfig{ + Profile: "oauth-leftover", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0), + Region: "cn-bj2", AuthMode: AuthModeOAuth, AccessToken: "tok", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + PublicKey: "leftover-pub", PrivateKey: "leftover-pri", + } + callGetRegion(t, ac, rec) + + if got := rec.header.Get("Authorization"); got != "Bearer tok" { + t.Errorf("Authorization = %q, want %q", got, "Bearer tok") + } + for _, k := range []string{"Signature", "PublicKey"} { + if v, ok := rec.params[k]; ok { + t.Errorf("oauth profile must not send signature param %s=%v (one request carries exactly one credential)", k, v) + } + } +} + +// channelInjectorHeaders 单独驱动渠道头注入 handler,取其产出的 header map。 +// 注意查的是 SetHeader 原样存入的 map(未经 Go 规范化),故 key 必须与注入端字面一致。 +func channelInjectorHeaders(t *testing.T, ac *AggConfig) map[string]string { + t.Helper() + h := newChannelHeaderInjector(ac) + req, err := h(nil, uhttp.NewHttpRequest()) + if err != nil { + t.Fatal(err) + } + return req.GetHeaderMap() +} + +// 配了 channel_key 的 profile:注入 channel-key 头(AC1) +func TestChannelInjectorSetsHeader(t *testing.T) { + headers := channelInjectorHeaders(t, &AggConfig{ChannelKey: "ch_combo_test"}) + if headers["channel-key"] != "ch_combo_test" { + t.Errorf("channel-key = %q, want ch_combo_test", headers["channel-key"]) + } +} + +// CRITICAL 零回归(AC2):未配 channel_key 时该头必须完全不存在——不是空值,是键不存在。 +// 主站用户与独立域名专属云渠道恒走此路径,线路字节必须与引入本特性前逐字节一致。 +// 与同文件 Cookie/Csrf-Token「空值也照旧 set」的存量契约刻意相反:那是历史行为, +// 本头是新增的,注入空头会给全部存量用户构成回归。 +func TestChannelInjectorAbsentWhenEmpty(t *testing.T) { + for _, tc := range []struct { + name string + ac *AggConfig + }{ + {"empty channel key", &AggConfig{}}, + {"nil agg config", nil}, // AttachHandlersWith(sc, nil, nil, nil) 降级路径确实存在 + } { + t.Run(tc.name, func(t *testing.T) { + if v, ok := channelInjectorHeaders(t, tc.ac)["channel-key"]; ok { + t.Errorf("channel-key header must be absent, got present with value %q", v) + } + }) + } +} + +// channel-key 与凭据机制正交(AC3):auth_mode 不影响其注入。 +// spec auth-guidelines 的「一个请求只携带一种凭据机制」约束的是凭据,channel-key 是 +// 渠道路由标识,不在其列。2026-07-16 真实网关实测:Bearer 与 channel-key 同时上行被接受。 +func TestChannelInjectorOrthogonalToAuthMode(t *testing.T) { + for _, tc := range []struct { + name string + ac *AggConfig + }{ + {"aksk", &AggConfig{ChannelKey: "ch_x", PublicKey: "pub", PrivateKey: "pri"}}, + {"oauth", &AggConfig{ChannelKey: "ch_x", AuthMode: AuthModeOAuth, AccessToken: "tok"}}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := channelInjectorHeaders(t, tc.ac)["channel-key"]; got != "ch_x" { + t.Errorf("channel-key = %q, want ch_x", got) + } + }) + } +} + +// 端到端:经完整 handler 链发真实请求,断言服务端侧确实收到该头。 +// 这是唯一能验证「Go 的 Header.Set 规范化为 Channel-Key 后网关仍可取到」的用例 +// (单测查的 map 未规范化,验证不到线路形式),也顺带证明 handler 确实被挂上了。 +func TestChannelKeyHeaderEndToEnd(t *testing.T) { + rec := &recordedRequest{} + s := bizRecorderServer(t, rec) + defer s.Close() + + ac := &AggConfig{ + Profile: "combo", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0), + Region: "hk", ChannelKey: "ch_combo_e2e", + PublicKey: "pub", PrivateKey: "pri", + } + callGetRegion(t, ac, rec) + + // http.Header.Get 大小写不敏感:线路上是 Channel-Key,此处照样取得到 + if got := rec.header.Get("channel-key"); got != "ch_combo_e2e" { + t.Errorf("server received channel-key = %q, want ch_combo_e2e", got) + } +} + +// AttachHandlers(读包级 ConfigIns 的那条路径,GetUserInfo 等在用)同样注入 channel-key。 +// 该路径实测难以触发(仅 agree_upload_log=true 的 DAS 日志上传会走 GetUserInfo),故以单测钉死。 +func TestAttachHandlersInjectsChannelKeyFromConfigIns(t *testing.T) { + rec := &recordedRequest{} + s := bizRecorderServer(t, rec) + defer s.Close() + + oldConfig, oldClientConfig, oldCred := ConfigIns, ClientConfig, AuthCredential + t.Cleanup(func() { ConfigIns, ClientConfig, AuthCredential = oldConfig, oldClientConfig, oldCred }) + + ac := &AggConfig{ + Profile: "combo", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0), + Region: "hk", ChannelKey: "ch_from_configins", + PublicKey: "pub", PrivateKey: "pri", + } + ConfigIns = ac + if err := InitClientRuntime(ac); err != nil { + t.Fatal(err) + } + client := uaccount.NewClient(ClientConfig, BuildCredential()) + AttachHandlers(client) // 包级路径:等价于 AttachHandlersWith(sc, AuthCredential, ConfigIns, ...) + if _, err := client.GetRegion(client.NewGetRegionRequest()); err != nil { + t.Fatalf("GetRegion failed: %v", err) + } + if got := rec.header.Get("channel-key"); got != "ch_from_configins" { + t.Errorf("AttachHandlers must inject channel-key from ConfigIns, got %q", got) + } +} + +// 端到端零回归:未配 channel_key 时服务端不得看到该头 +func TestChannelKeyHeaderAbsentEndToEnd(t *testing.T) { + rec := &recordedRequest{} + s := bizRecorderServer(t, rec) + defer s.Close() + + ac := &AggConfig{ + Profile: "mainsite", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0), + Region: "cn-bj2", PublicKey: "pub", PrivateKey: "pri", + } + callGetRegion(t, ac, rec) + + if _, ok := rec.header["Channel-Key"]; ok { + t.Errorf("main-site profile must not send channel-key header, got %q", rec.header.Get("channel-key")) + } +} + +// 401 自动重放矩阵(D6 反应式兜底):401→刷新→重放成功;aksk 模式不重放。 +// 注意 SDK 行为:HttpClient.Send 对 status>=400 返回 (nil, StatusError), +// 401 的 body 在 handler 层不可见,鉴权失败只能从 err 判定。 +func TestOAuthRetryHandler(t *testing.T) { + apiCalls := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalls++ + if r.Header.Get("Authorization") == "Bearer good" { + fmt.Fprint(w, `{"RetCode":0,"Action":"GetRegionResponse"}`) + return + } + w.WriteHeader(401) + fmt.Fprint(w, `{"RetCode":170,"Message":"token expired"}`) + })) + defer api.Close() + oauth := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"good","refresh_token":"rt2","expires_in":3600}`) + })) + defer oauth.Close() + + ac := &AggConfig{ + Profile: "pr", Active: true, BaseURL: api.URL, Timeout: 15, MaxRetryTimes: intPtr(0), + AuthMode: AuthModeOAuth, AccessToken: "bad", RefreshToken: "rt1", + ExpiresAt: 9999999999, OAuthBaseURL: oauth.URL, // 未到期 → 不触发主动刷新,逼出反应式路径 + } + m := newTestManager(t, ac) + prevIns, prevList := ConfigIns, AggConfigListIns + prevCC, prevAC := ClientConfig, AuthCredential + ConfigIns, AggConfigListIns = ac, m + t.Cleanup(func() { + ConfigIns, AggConfigListIns = prevIns, prevList + ClientConfig, AuthCredential = prevCC, prevAC + }) + + client := newTestUAccountClient(t, ac) + resp, err := client.GetRegion(client.NewGetRegionRequest()) + if err != nil { + t.Fatalf("replay should succeed: %v", err) + } + if resp.GetRetCode() != 0 { + t.Errorf("RetCode = %d", resp.GetRetCode()) + } + if apiCalls != 2 { + t.Errorf("expect 1 fail + 1 replay = 2 api calls, got %d", apiCalls) + } + var creds []CredentialConfig + raw, _ := ioutil.ReadFile(".ucloud/credential.json") + json.Unmarshal(raw, &creds) + var persisted *CredentialConfig + for i := range creds { + if creds[i].Profile == "pr" { + persisted = &creds[i] + } + } + if persisted == nil || persisted.AccessToken != "good" || persisted.RefreshToken != "rt2" { + t.Errorf("refreshed token and rotated refresh_token must be persisted: %s", raw) + } +} + +// RetCode 白名单路径(实测网关行为):鉴权失败返回 HTTP 200 + RetCode 174 "Token Not Exists" +// (无效与过期 Bearer 同码,2026-06-11 实测)。SDK 管道:200 时 Send 返回 (resp, nil), +// 默认 errorHTTPHandler 不动 err==nil,body 在本 handler 可读 → 走 isAuthFailure 的 +// resp-body 白名单分支:刷新 → 重放一次成功。 +func TestOAuthRetryHandlerRetCode174(t *testing.T) { + apiCalls := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalls++ + w.Header().Set("Content-Type", "application/json") + if r.Header.Get("Authorization") == "Bearer good" { + fmt.Fprint(w, `{"RetCode":0,"Action":"GetRegionResponse"}`) + return + } + fmt.Fprint(w, `{"RetCode":174,"Message":"Token Not Exists"}`) + })) + defer api.Close() + oauth := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"good","refresh_token":"rt2","expires_in":3600}`) + })) + defer oauth.Close() + + ac := &AggConfig{ + Profile: "p174", Active: true, BaseURL: api.URL, Timeout: 15, MaxRetryTimes: intPtr(0), + AuthMode: AuthModeOAuth, AccessToken: "bad", RefreshToken: "rt1", + ExpiresAt: 9999999999, OAuthBaseURL: oauth.URL, // 未到期 → 不触发主动刷新,逼出反应式路径 + } + m := newTestManager(t, ac) + prevIns, prevList := ConfigIns, AggConfigListIns + prevCC, prevAC := ClientConfig, AuthCredential + ConfigIns, AggConfigListIns = ac, m + t.Cleanup(func() { + ConfigIns, AggConfigListIns = prevIns, prevList + ClientConfig, AuthCredential = prevCC, prevAC + }) + + client := newTestUAccountClient(t, ac) + resp, err := client.GetRegion(client.NewGetRegionRequest()) + if err != nil { + t.Fatalf("replay should succeed: %v", err) + } + if resp.GetRetCode() != 0 { + t.Errorf("RetCode = %d", resp.GetRetCode()) + } + if apiCalls != 2 { + t.Errorf("expect 1 fail + 1 replay = 2 api calls, got %d", apiCalls) + } + var creds []CredentialConfig + raw, _ := ioutil.ReadFile(".ucloud/credential.json") + json.Unmarshal(raw, &creds) + var persisted *CredentialConfig + for i := range creds { + if creds[i].Profile == "p174" { + persisted = &creds[i] + } + } + if persisted == nil || persisted.AccessToken != "good" || persisted.RefreshToken != "rt2" { + t.Errorf("refreshed token and rotated refresh_token must be persisted: %s", raw) + } +} + +// 负路径(174 持续):重放后仍 174 → 只重放一次(共 2 次 api 调用,不循环), +// RetCode 由 SDK 默认 errorHandler 转为 ServerCodeError 上浮。 +func TestOAuthRetryHandlerRetCode174Persists(t *testing.T) { + apiCalls := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalls++ + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"RetCode":174,"Message":"Token Not Exists"}`) + })) + defer api.Close() + oauth := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"good","refresh_token":"rt2","expires_in":3600}`) + })) + defer oauth.Close() + + ac := &AggConfig{ + Profile: "p174s", Active: true, BaseURL: api.URL, Timeout: 15, MaxRetryTimes: intPtr(0), + AuthMode: AuthModeOAuth, AccessToken: "bad", RefreshToken: "rt1", + ExpiresAt: 9999999999, OAuthBaseURL: oauth.URL, + } + m := newTestManager(t, ac) + prevList, prevCC, prevAC := AggConfigListIns, ClientConfig, AuthCredential + AggConfigListIns = m + t.Cleanup(func() { AggConfigListIns, ClientConfig, AuthCredential = prevList, prevCC, prevAC }) + + client := newTestUAccountClient(t, ac) + if _, err := client.GetRegion(client.NewGetRegionRequest()); err == nil { + t.Error("persistent RetCode 174 must surface an error after single replay") + } + if apiCalls != 2 { + t.Errorf("exactly one replay allowed (no loop): got %d api calls", apiCalls) + } +} + +// 负路径 (a):刷新失败(invalid_grant)→ 原始 401 错误上浮,不重放、不 panic +func TestOAuthRetryHandlerRefreshFails(t *testing.T) { + apiCalls := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalls++ + w.WriteHeader(401) + fmt.Fprint(w, `{"RetCode":170,"Message":"token expired"}`) + })) + defer api.Close() + oauth := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + fmt.Fprint(w, `{"error":"invalid_grant"}`) + })) + defer oauth.Close() + + ac := &AggConfig{ + Profile: "prf", Active: true, BaseURL: api.URL, Timeout: 15, MaxRetryTimes: intPtr(0), + AuthMode: AuthModeOAuth, AccessToken: "bad", RefreshToken: "rt1", + ExpiresAt: 9999999999, OAuthBaseURL: oauth.URL, + } + m := newTestManager(t, ac) + prevList, prevCC, prevAC := AggConfigListIns, ClientConfig, AuthCredential + AggConfigListIns = m + t.Cleanup(func() { AggConfigListIns, ClientConfig, AuthCredential = prevList, prevCC, prevAC }) + + client := newTestUAccountClient(t, ac) + if _, err := client.GetRegion(client.NewGetRegionRequest()); err == nil { + t.Error("refresh failure must surface the original 401 error") + } + if apiCalls != 1 { + t.Errorf("refresh failed, must not replay: got %d api calls", apiCalls) + } +} + +// 负路径 (b):重放后仍 401 → 只重放一次(共 2 次 api 调用,不循环),错误上浮 +func TestOAuthRetryHandlerReplayStill401(t *testing.T) { + apiCalls := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalls++ + w.WriteHeader(401) + fmt.Fprint(w, `{"RetCode":170,"Message":"still no"}`) + })) + defer api.Close() + oauth := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"good","refresh_token":"rt2","expires_in":3600}`) + })) + defer oauth.Close() + + ac := &AggConfig{ + Profile: "prs", Active: true, BaseURL: api.URL, Timeout: 15, MaxRetryTimes: intPtr(0), + AuthMode: AuthModeOAuth, AccessToken: "bad", RefreshToken: "rt1", + ExpiresAt: 9999999999, OAuthBaseURL: oauth.URL, + } + m := newTestManager(t, ac) + prevList, prevCC, prevAC := AggConfigListIns, ClientConfig, AuthCredential + AggConfigListIns = m + t.Cleanup(func() { AggConfigListIns, ClientConfig, AuthCredential = prevList, prevCC, prevAC }) + + client := newTestUAccountClient(t, ac) + if _, err := client.GetRegion(client.NewGetRegionRequest()); err == nil { + t.Error("replay still 401 must surface error") + } + if apiCalls != 2 { + t.Errorf("exactly one replay allowed (no loop): got %d api calls", apiCalls) + } +} + +func TestOAuthRetryHandlerSkipsAksk(t *testing.T) { + apiCalls := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalls++ + w.WriteHeader(401) + fmt.Fprint(w, `{"RetCode":170,"Message":"x"}`) + })) + defer api.Close() + ac := &AggConfig{ + Profile: "pa", Active: true, BaseURL: api.URL, Timeout: 15, MaxRetryTimes: intPtr(0), + PublicKey: "pub", PrivateKey: "pri", + } + _ = newTestManager(t, ac) + prevCC, prevAC := ClientConfig, AuthCredential + t.Cleanup(func() { ClientConfig, AuthCredential = prevCC, prevAC }) + client := newTestUAccountClient(t, ac) + if _, err := client.GetRegion(client.NewGetRegionRequest()); err == nil { + t.Error("aksk 401 should surface error, not replay-refresh") + } + if apiCalls != 1 { + t.Errorf("aksk mode must not replay, got %d calls", apiCalls) + } +} + +// 反向护栏:aksk profile 照旧签名(Signature + PublicKey 必须在场,且无 Bearer) +func TestAkskProfileStillSigns(t *testing.T) { + rec := &recordedRequest{} + s := bizRecorderServer(t, rec) + defer s.Close() + + ac := &AggConfig{ + Profile: "aksk", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0), + Region: "cn-bj2", PublicKey: "pub", PrivateKey: "pri", + } + callGetRegion(t, ac, rec) + + for _, k := range []string{"Signature", "PublicKey"} { + if _, ok := rec.params[k]; !ok { + t.Errorf("aksk profile must sign requests, missing param %s; got params %v", k, rec.params) + } + } + if _, ok := rec.header["Authorization"]; ok { + t.Error("aksk profile must not send Authorization header") + } +} diff --git a/cmd/internal/platform/config.go b/cmd/internal/platform/config.go new file mode 100644 index 0000000000..12c140018b --- /dev/null +++ b/cmd/internal/platform/config.go @@ -0,0 +1,931 @@ +package platform + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/url" + "os" + "strings" + "time" + + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" + "github.com/ucloud/ucloud-sdk-go/ucloud/log" + + "github.com/ucloud/ucloud-cli/cmd/internal/version" +) + +// ConfigFilePath path of config.json +var ConfigFilePath = fmt.Sprintf("%s/%s", GetConfigDir(), "config.json") + +// CredentialFilePath path of credential.json +var CredentialFilePath = fmt.Sprintf("%s/%s", GetConfigDir(), "credential.json") + +var CredentialFilePathInCloudShell = os.Getenv("CLOUD_SHELL_CREDENTIAL_FILE") + +// LocalFileMode file mode of $HOME/ucloud/* +const LocalFileMode os.FileMode = 0600 + +// DefaultTimeoutSec default timeout for requesting api, 15s +const DefaultTimeoutSec = 15 + +// DefaultMaxRetryTimes default timeout for requesting api, 15s +const DefaultMaxRetryTimes = 3 + +// DefaultProfile name of default profile +const DefaultProfile = "default" + +// 两个可被 profile 覆盖的默认服务域,并列以体现 api 网关与 oauth 域的对称: +// - 业务 API 网关(api.ucloud.cn):承载全部云资源 RPC,AK/SK 签名或 Bearer。 +// - OAuth 授权服务(oauth2.ucloud.cn):承载浏览器登录的 /authorize 与 /token。 +const ( + // DefaultBaseURL location of api server + DefaultBaseURL = "https://api.ucloud.cn/" + + // defaultOAuthBaseURL 内置默认 OAuth 域名(profile 的 oauth_base_url 可覆盖,见 GetOAuthBaseURL) + defaultOAuthBaseURL = "https://oauth2.ucloud.cn" +) + +// OAuth 协议常量与 loopback 回调常量 +const ( + oauthAuthorizePath = "/authorize" + oauthTokenPath = "/token" + oauthScope = "openid email offline_access full_access" + + // OAuth 客户端凭据(public client:secret 嵌入二进制为知情裁定,同 gcloud/gh) + oauthClientID = "WP77AwxvUgWt2JqaRCKn" + oauthClientSecret = "mksUQLod9VaUKMt3wESdgteTFCgVasiUwLSPqq5e" + + // LoopbackListenHost 本地回调 server 的监听地址(回环 IP,导出供 cmd/callback.go 复用)。 + // loopbackRedirectHost redirect_uri 里的 host 必须是字面量 localhost——后端拒 127.0.0.1 形式的 redirect_uri。 + // 两者指向同一回环地址,但写法必须如此区分。 + LoopbackListenHost = "127.0.0.1" + loopbackRedirectHost = "localhost" + // OAuthRedirectPath redirect_uri 与回调 server mux 共用的路径,必须一致(导出供 cmd/callback.go 复用)。 + OAuthRedirectPath = "/authorization" +) + +var InCloudShell = os.Getenv("CLOUD_SHELL") == "true" + +// ConfigIns 配置实例, 程序加载时生成 +var ConfigIns = &AggConfig{ + Profile: DefaultProfile, + BaseURL: DefaultBaseURL, + Timeout: DefaultTimeoutSec, + MaxRetryTimes: sdk.Int(DefaultMaxRetryTimes), +} + +// AggConfigListIns 配置列表, 进程启动时从本地文件加载 +var AggConfigListIns = &AggConfigManager{} + +// ClientConfig 创建sdk client参数 +var ClientConfig *sdk.Config + +// AuthCredential 创建sdk client参数 +var AuthCredential *CredentialConfig + +// Global 全局flag +var Global GlobalFlag + +// GlobalFlag 几乎所有接口都需要的参数,例如 region zone projectID +type GlobalFlag struct { + Debug bool + JSON bool + Output string + Version bool + Completion bool + Config bool + Signup bool + Profile string + PublicKey string + PrivateKey string + BaseURL string + ChannelKey string + Timeout int + WaitTimeout int // 同步轮询总超时(秒),0 表示用内置默认(600s) + MaxRetryTimes int +} + +// CLIConfig cli_config element +type CLIConfig struct { + ProjectID string `json:"project_id"` + Region string `json:"region"` + Zone string `json:"zone"` + BaseURL string `json:"base_url"` + Timeout int `json:"timeout_sec"` + Profile string `json:"profile"` + Active bool `json:"active"` //是否生效 + MaxRetryTimes *int `json:"max_retry_times"` + AgreeUploadLog bool `json:"agree_upload_log"` + OAuthBaseURL string `json:"oauth_base_url,omitempty"` + // ChannelKey 专属云渠道标识,与 base_url 成对配置(复用主站域名的专属云渠道必填, + // 独立域名渠道与主站用户为空)。属接入点配置而非用户凭据,故落在 config.json, + // 不进 credential.json。 + ChannelKey string `json:"channel_key,omitempty"` +} + +// CredentialConfig credential element +type CredentialConfig struct { + PublicKey string `json:"public_key"` + PrivateKey string `json:"private_key"` + Cookie string `json:"cookie"` + CSRFToken string `json:"csrf_token"` + Profile string `json:"profile"` + + AuthMode string `json:"auth_mode,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresAt int64 `json:"expires_at,omitempty"` +} + +// AggConfig 聚合配置 config+credential +type AggConfig struct { + Profile string `json:"profile"` + Active bool `json:"active"` + ProjectID string `json:"project_id"` + Region string `json:"region"` + Zone string `json:"zone"` + BaseURL string `json:"base_url"` + Timeout int `json:"timeout_sec"` + PublicKey string `json:"public_key"` + PrivateKey string `json:"private_key"` + Cookie string `json:"cookie"` + CSRFToken string `json:"csrf_token"` + MaxRetryTimes *int `json:"max_retry_times"` + AgreeUploadLog bool `json:"agree_upload_log"` + AuthMode string `json:"auth_mode,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresAt int64 `json:"expires_at,omitempty"` + OAuthBaseURL string `json:"oauth_base_url,omitempty"` + ChannelKey string `json:"channel_key,omitempty"` +} + +// ConfigPublicKey 输入公钥 +func (p *AggConfig) ConfigPublicKey() error { + Cxt.Print("Your public-key:") + _, err := fmt.Scanf("%s\n", &p.PublicKey) + if err != nil { + Cxt.Println(err) + return err + } + p.PublicKey = strings.TrimSpace(p.PublicKey) + AuthCredential.PublicKey = p.PublicKey + return nil +} + +// ConfigPrivateKey 输入私钥 +func (p *AggConfig) ConfigPrivateKey() error { + Cxt.Print("Your private-key:") + _, err := fmt.Scanf("%s\n", &p.PrivateKey) + if err != nil { + Cxt.Println(err) + return err + } + p.PrivateKey = strings.TrimSpace(p.PrivateKey) + AuthCredential.PrivateKey = p.PrivateKey + return nil +} + +// ConfigBaseURL 输入BaseURL +func (p *AggConfig) ConfigBaseURL() error { + fmt.Printf("Default base-url(%s):", DefaultBaseURL) + _, err := fmt.Scanf("%s\n", &p.BaseURL) + if err != nil { + return err + } + p.BaseURL = strings.TrimSpace(p.BaseURL) + if len(p.BaseURL) == 0 { + p.BaseURL = DefaultBaseURL + } + return nil +} + +// ConfigUploadLog agree upload log or not +func (p *AggConfig) ConfigUploadLog() error { + var input string + fmt.Print("Do you agree to upload log in local file ~/.ucloud/cli.log to help ucloud-cli get better(yes|no):") + _, err := fmt.Scanf("%s\n", &input) + if err != nil { + HandleError(err) + return err + } + + if str := strings.ToLower(input); str == "y" || str == "ye" || str == "yes" { + p.AgreeUploadLog = true + } + return nil +} + +// GetClientConfig 用来生成sdkClient +func (p *AggConfig) GetClientConfig(isDebug bool) *sdk.Config { + clientConfig := &sdk.Config{ + Region: p.Region, + ProjectId: p.ProjectID, + BaseUrl: ClientConfig.BaseUrl, + Timeout: ClientConfig.Timeout, + UserAgent: ClientConfig.UserAgent, + LogLevel: ClientConfig.LogLevel, + } + if isDebug == true { + clientConfig.LogLevel = log.DebugLevel + } + return clientConfig +} + +// GetCredential 用来生成SDkClient +func (p *AggConfig) GetCredential() *auth.Credential { + return &auth.Credential{ + PublicKey: p.PublicKey, + PrivateKey: p.PrivateKey, + } +} + +func (p *AggConfig) copyToCLIConfig(target *CLIConfig) { + target.Profile = p.Profile + target.BaseURL = p.BaseURL + target.Timeout = p.Timeout + target.ProjectID = p.ProjectID + target.Region = p.Region + target.Zone = p.Zone + target.Active = p.Active + target.MaxRetryTimes = p.MaxRetryTimes + target.AgreeUploadLog = p.AgreeUploadLog + target.OAuthBaseURL = p.OAuthBaseURL + target.ChannelKey = p.ChannelKey +} + +func (p *AggConfig) copyToCredentialConfig(target *CredentialConfig) { + target.Profile = p.Profile + target.PrivateKey = p.PrivateKey + target.PublicKey = p.PublicKey + target.Cookie = p.Cookie + target.CSRFToken = p.CSRFToken + target.AuthMode = p.AuthMode + target.AccessToken = p.AccessToken + target.RefreshToken = p.RefreshToken + target.ExpiresAt = p.ExpiresAt +} + +// AggConfigManager 配置管理 +type AggConfigManager struct { + activeProfile string + configs map[string]*AggConfig + configPath string + credPath string +} + +// NewAggConfigManager create instance +func NewAggConfigManager(configPath, credPath string) (*AggConfigManager, error) { + manager := &AggConfigManager{ + configs: make(map[string]*AggConfig), + configPath: configPath, + credPath: credPath, + } + + err := manager.Load() + if err != nil { + if !os.IsNotExist(err) { + return manager, err + } + + aerr := adaptOldConfig() + if aerr != nil { + HandleError(fmt.Errorf("adapt to old config failed: %v", aerr)) + return manager, aerr + } + + err := manager.Load() + if err != nil { + HandleError(fmt.Errorf("retry to load cli config failed: %v", err)) + return manager, err + } + } + return manager, nil +} + +// Append config to list, override if already exist the same profile +func (p *AggConfigManager) Append(config *AggConfig) error { + if _, ok := p.configs[config.Profile]; ok { + return fmt.Errorf("profile [%s] exists already", config.Profile) + } + + if config.Active && config.Profile != p.activeProfile { + if ac, ok := p.configs[p.activeProfile]; ok { + ac.Active = false + } + p.activeProfile = config.Profile + } + p.configs[config.Profile] = config + return p.Save() +} + +// UpdateAggConfig update AggConfig append if not exist +func (p *AggConfigManager) UpdateAggConfig(config *AggConfig) error { + existing, ok := p.configs[config.Profile] + if !ok { + return p.Append(config) + } + + if config.Active && config.Profile != p.activeProfile { + if ac, ok := p.configs[p.activeProfile]; ok { + ac.Active = false + } + p.activeProfile = config.Profile + } + // 调用方传入的可能不是 map 内条目本身(如 --profile 回退默认配置的场景): + // 以传入值为准覆盖 map 条目,否则 Save 会把旧数据落盘、静默丢弃调用方的修改。 + // 保留 map 指针不变,已持有该指针的别名(如 oauth 刷新写回)继续有效。 + if existing != config { + *existing = *config + } + return p.Save() +} + +// Load AggConfigList from local file $HOME/.ucloud/config.json+credential.json +func (p *AggConfigManager) Load() error { + configs, err := p.parseCLIConfigs() + if err != nil { + return fmt.Errorf("read config failed: %v", err) + } + credentials, err := p.parseCredentials() + if err != nil { + return fmt.Errorf("read credential failed: %v", err) + } + + //key: profile , value: CLIConfig + configMap := make(map[string]*CLIConfig) + for _, config := range configs { + c := config + configMap[config.Profile] = &c + if config.Active { + p.activeProfile = config.Profile + } + } + credMap := make(map[string]*CredentialConfig) + for _, cred := range credentials { + c := cred + credMap[cred.Profile] = &c + } + + for profile, config := range configMap { + cred, ok := credMap[profile] + if !ok { + LogError("profile: %s don't exist in credential") + continue + } + + p.configs[profile] = &AggConfig{ + PrivateKey: cred.PrivateKey, + PublicKey: cred.PublicKey, + Cookie: cred.Cookie, + CSRFToken: cred.CSRFToken, + Profile: config.Profile, + ProjectID: config.ProjectID, + Region: config.Region, + Zone: config.Zone, + BaseURL: config.BaseURL, + Timeout: config.Timeout, + Active: config.Active, + MaxRetryTimes: config.MaxRetryTimes, + AgreeUploadLog: config.AgreeUploadLog, + AuthMode: cred.AuthMode, + AccessToken: cred.AccessToken, + RefreshToken: cred.RefreshToken, + ExpiresAt: cred.ExpiresAt, + OAuthBaseURL: config.OAuthBaseURL, + ChannelKey: config.ChannelKey, + } + } + + if p.activeProfile == "" && len(configMap) > 0 { + return fmt.Errorf("no active config found, run 'ucloud config list' to check") + } + if _, ok := credMap[p.activeProfile]; p.activeProfile != "" && !ok { + return fmt.Errorf("profile %s's credential don't exist, run 'ucloud config list' to check", p.activeProfile) + } + + return nil +} + +type CredHeader struct { + Key string + Value []string +} + +type project struct { + ProjectId string + ProjectName string +} + +type region struct { + Region string + Zone string +} + +func NewInCloudShell() (*AggConfigManager, error) { + credFile, err := os.OpenFile(CredentialFilePathInCloudShell, os.O_RDONLY, LocalFileMode) + if err != nil { + return nil, fmt.Errorf("open credential file error: %w", err) + } + data, err := ioutil.ReadAll(credFile) + if err != nil { + return nil, fmt.Errorf("read from credential file error: %w", err) + } + var creds []CredHeader + err = json.Unmarshal(data, &creds) + if err != nil { + return nil, fmt.Errorf("unmarshal credential file error: %w", err) + } + + var cookie string + var tokenMap map[string]string + for _, header := range creds { + key := strings.ToLower(header.Key) + if key == "cookie" { + cookie = header.Value[0] + tokenMap, err = parseCookie(header.Value[0]) + } + } + if err != nil { + return nil, err + } + email := tokenMap["U_USER_EMAIL"] + email = strings.ReplaceAll(email, ".", "_") + email = strings.ReplaceAll(email, "@", "_") + projectKey := fmt.Sprintf("c_project_%s", email) + regionKey := fmt.Sprintf("c_last_region_%s", email) + var proj project + var reg region + if _, ok := tokenMap[projectKey]; ok { + err = json.Unmarshal([]byte(tokenMap[projectKey]), &proj) + if err != nil { + return nil, err + } + } else { + id, name, err := getDefaultProject(cookie, tokenMap["CSRF_TOKEN"]) + if err != nil { + return nil, fmt.Errorf("query default project error: %w", err) + } + proj.ProjectId = id + proj.ProjectName = name + } + if _, ok := tokenMap[regionKey]; ok { + err = json.Unmarshal([]byte(tokenMap[regionKey]), ®) + if err != nil { + return nil, err + } + } else { + region, zone, err := getDefaultRegion(cookie, tokenMap["CSRF_TOKEN"]) + if err != nil { + return nil, fmt.Errorf("query default region error: %w", err) + } + reg.Region = region + reg.Zone = zone + } + + ac := &AggConfig{ + Cookie: cookie, + Profile: DefaultProfile, + Active: true, + BaseURL: DefaultBaseURL, + ProjectID: proj.ProjectId, + Region: reg.Region, + Zone: reg.Zone, + MaxRetryTimes: sdk.Int(DefaultMaxRetryTimes), + CSRFToken: tokenMap["CSRF_TOKEN"], + Timeout: DefaultTimeoutSec, + } + + aggConfigs := make(map[string]*AggConfig, 0) + aggConfigs[DefaultProfile] = ac + + return &AggConfigManager{ + activeProfile: DefaultProfile, + configs: aggConfigs, + }, nil +} + +func parseCookie(str string) (map[string]string, error) { + items := strings.Split(str, ";") + tokenMap := make(map[string]string, 0) + for _, str := range items { + strs := strings.SplitN(str, "=", 2) + if len(strs) == 2 { + v, err := url.QueryUnescape(strings.TrimSpace(strs[1])) + if err != nil { + return tokenMap, err + } + tokenMap[strings.TrimSpace(strs[0])] = v + } + } + return tokenMap, nil +} + +// Save configs to local file +func (p *AggConfigManager) Save() error { + var clics []*CLIConfig + var credcs []*CredentialConfig + for _, aggConfig := range p.configs { + cliConfig := &CLIConfig{} + aggConfig.copyToCLIConfig(cliConfig) + clics = append(clics, cliConfig) + + credConfig := &CredentialConfig{} + aggConfig.copyToCredentialConfig(credConfig) + credcs = append(credcs, credConfig) + } + aerr := WriteJSONFileAtomic(clics, p.configPath) + berr := WriteJSONFileAtomic(credcs, p.credPath) + + if aerr != nil && berr != nil { + return fmt.Errorf("save cli config failed: %v | save credentail failed: %v", aerr, berr) + } + if aerr != nil { + return fmt.Errorf("save cli config failed: %v", aerr) + } + if berr != nil { + return fmt.Errorf("save cerdentail failed: %v", berr) + } + return nil +} + +// DeleteByProfile 从AggConfigList和本地文件中删除此配置 +func (p *AggConfigManager) DeleteByProfile(profile string) error { + if _, ok := p.configs[profile]; !ok { + return fmt.Errorf("profile: %s is not exist", profile) + } + + ac := p.configs[profile] + if ac.Active { + return fmt.Errorf("can't delete active profile") + } + + delete(p.configs, profile) + + err := p.Save() + if err != nil { + return fmt.Errorf("delete profile %s failed: %v", profile, err) + } + return nil +} + +// GetProfileNameList 获取所有profiles 用于ucloud config --profile 补全 +func (p *AggConfigManager) GetProfileNameList() []string { + profiles := []string{} + for _, item := range p.configs { + profiles = append(profiles, item.Profile) + } + return profiles +} + +// GetAggConfigList get all profile config +func (p *AggConfigManager) GetAggConfigList() []AggConfig { + configs := []AggConfig{} + for _, cfg := range p.configs { + configs = append(configs, *cfg) + } + return configs +} + +// GetAggConfigByProfile get config of specify profile +func (p *AggConfigManager) GetAggConfigByProfile(profile string) (*AggConfig, bool) { + if ac, ok := p.configs[profile]; ok { + return ac, true + } + return nil, false +} + +// GetActiveAggConfig get active agg config +func (p *AggConfigManager) GetActiveAggConfig() (*AggConfig, error) { + if ac, ok := p.configs[p.activeProfile]; ok { + return ac, nil + } + return nil, fmt.Errorf("active profile not found. see 'ucloud config list'") +} + +// GetActiveAggConfigName get active config name +func (p *AggConfigManager) GetActiveAggConfigName() string { + if ac, ok := p.configs[p.activeProfile]; ok { + return ac.Profile + } + return "" +} + +func (p *AggConfigManager) parseCLIConfigs() ([]CLIConfig, error) { + var configs []CLIConfig + rawConfig, err := ioutil.ReadFile(p.configPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + if len(rawConfig) == 0 { + return nil, nil + } + + err = json.Unmarshal(rawConfig, &configs) + if err != nil { + return nil, fmt.Errorf("parse cli config faild: %v", err) + } + //特殊处理未配置max_retry_times的情况,v0.1.21之前硬编码重试次数为3 + for idx := range configs { + if configs[idx].MaxRetryTimes == nil { + configs[idx].MaxRetryTimes = sdk.Int(DefaultMaxRetryTimes) + } + } + return configs, nil +} + +func (p *AggConfigManager) parseCredentials() ([]CredentialConfig, error) { + var credentials []CredentialConfig + rawCred, err := ioutil.ReadFile(p.credPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + if len(rawCred) == 0 { + return nil, nil + } + + err = json.Unmarshal(rawCred, &credentials) + if err != nil { + return nil, fmt.Errorf("parse credential failed: %v", err) + } + return credentials, nil +} + +// ListAggConfig ucloud --config + ucloud config list +func ListAggConfig(json bool) { + aggConfigs := AggConfigListIns.GetAggConfigList() + for idx, ac := range aggConfigs { + aggConfigs[idx].PrivateKey = MosaicString(ac.PrivateKey, 8, 5) + aggConfigs[idx].PublicKey = MosaicString(ac.PublicKey, 8, 5) + aggConfigs[idx].AccessToken = MosaicString(ac.AccessToken, 8, 5) + aggConfigs[idx].RefreshToken = MosaicString(ac.RefreshToken, 8, 5) + } + if json { + err := PrintJSON(aggConfigs, os.Stdout) + if err != nil { + HandleError(err) + } + } else { + PrintTable(aggConfigs, []string{"Profile", "Active", "AuthMode", "ProjectID", "Region", "Zone", "BaseURL", "ChannelKey", "Timeout", "PublicKey", "PrivateKey", "MaxRetryTimes", "AgreeUploadLog"}) + } +} + +// LoadUserInfo 从~/.ucloud/user.json加载用户信息 +func LoadUserInfo() (*uaccount.UserInfo, error) { + filePath := GetConfigDir() + "/user.json" + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return nil, fmt.Errorf("user.json is not exist") + } + content, err := ioutil.ReadFile(filePath) + if err != nil { + return nil, err + } + var user uaccount.UserInfo + err = json.Unmarshal(content, &user) + if err != nil { + return nil, err + } + return &user, nil +} + +// GetUserInfo from local file and remote api +func GetUserInfo() (*uaccount.UserInfo, error) { + user, err := LoadUserInfo() + if err == nil { + return user, nil + } + + client := uaccount.NewClient(ClientConfig, BuildCredential()) + AttachHandlers(client) + req := client.NewGetUserInfoRequest() + resp, err := client.GetUserInfo(req) + + if err != nil { + return nil, err + } + + if len(resp.DataSet) == 1 { + user = &resp.DataSet[0] + bytes, err := json.Marshal(user) + if err != nil { + return nil, err + } + fileFullPath := GetConfigDir() + "/user.json" + err = ioutil.WriteFile(fileFullPath, bytes, 0600) + if err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("GetUserInfo DataSet length: %d", len(resp.DataSet)) + } + return user, nil +} + +// OldConfig 0.1.7以及之前版本的配置struct +type OldConfig struct { + PublicKey string `json:"public_key"` + PrivateKey string `json:"private_key"` + Region string `json:"region"` + Zone string `json:"zone"` + ProjectID string `json:"project_id"` +} + +// Load 从本地文件加载配置 +func (p *OldConfig) Load() error { + if _, err := os.Stat(ConfigFilePath); os.IsNotExist(err) { + p = new(OldConfig) + return nil + } + + content, err := ioutil.ReadFile(ConfigFilePath) + if err != nil { + return err + } + err = json.Unmarshal(content, p) + if err != nil { + return err + } + + return nil +} + +func adaptOldConfig() error { + oc := &OldConfig{} + err := oc.Load() + if err != nil { + return err + } + ac := &AggConfig{ + Profile: DefaultProfile, + ProjectID: oc.ProjectID, + Region: oc.Region, + Zone: oc.Zone, + BaseURL: DefaultBaseURL, + Timeout: DefaultTimeoutSec, + Active: true, + PrivateKey: oc.PrivateKey, + PublicKey: oc.PublicKey, + MaxRetryTimes: sdk.Int(DefaultMaxRetryTimes), + } + err = os.Rename(ConfigFilePath, ConfigFilePath+".old") + if err != nil { + return err + } + return AggConfigListIns.Append(ac) +} + +// BuildClientRuntime builds SDK config and credential config for a profile +// without creating an aggregate business client. +func BuildClientRuntime(ac *AggConfig) (*sdk.Config, *CredentialConfig, error) { + timeout, err := time.ParseDuration(fmt.Sprintf("%ds", ac.Timeout)) + if err != nil { + err = fmt.Errorf("parse timeout %ds failed: %v", ac.Timeout, err) + } + cfg := &sdk.Config{ + BaseUrl: ac.BaseURL, + Timeout: timeout, + UserAgent: version.UserAgent(), + LogLevel: log.FatalLevel, + Region: ac.Region, + ProjectId: ac.ProjectID, + MaxRetries: *ac.MaxRetryTimes, + } + cred := &CredentialConfig{ + PublicKey: ac.PublicKey, + PrivateKey: ac.PrivateKey, + Cookie: ac.Cookie, + CSRFToken: ac.CSRFToken, + AuthMode: ac.AuthMode, + AccessToken: ac.AccessToken, + RefreshToken: ac.RefreshToken, + ExpiresAt: ac.ExpiresAt, + } + return cfg, cred, err +} + +// InitClientRuntime initializes package-level SDK config and credential +// pointers for legacy callers, while keeping AuthCredential pointer identity +// stable for service clients that captured it at command registration time. +func InitClientRuntime(ac *AggConfig) error { + cfg, cred, err := BuildClientRuntime(ac) + ClientConfig = cfg + // AuthCredential must keep a STABLE pointer identity for the whole process: + // service clients (cli.NewServiceClient/runtime clients) capture this pointer at + // command-tree registration and read AccessToken lazily per request, so a + // token refresh here must be visible to them. Overwrite the pointed-to object + // in place instead of replacing the pointer — otherwise those already-built + // clients keep sending the pre-refresh (possibly expired) Bearer and only + // recover via the reactive retry handler at the cost of a wasted round-trip. + if AuthCredential == nil { + AuthCredential = &CredentialConfig{} + } + *AuthCredential = *cred + return err +} + +func InitConfigInCloudShell() error { + data, err := ioutil.ReadFile(CredentialFilePath) + if err != nil && !os.IsNotExist(err) { + return err + } + if len(data) > 0 { + var credConfigs []CredentialConfig + err = json.Unmarshal(data, &credConfigs) + if err != nil { + return err + } + if len(credConfigs) > 0 { + cred := credConfigs[0] + if cred.Cookie != "" && cred.CSRFToken != "" { + return nil + } + } + } + + AggConfigM, err := NewInCloudShell() + if err != nil { + return err + } + + AggConfigM.credPath = CredentialFilePath + AggConfigM.configPath = ConfigFilePath + ins, err := AggConfigM.GetActiveAggConfig() + if err != nil { + return err + } + ConfigIns = ins + if err := InitClientRuntime(ConfigIns); err != nil { + return err + } + return AggConfigM.Save() +} + +// InitConfig 初始化配置 +func InitConfig() { + var err error + AggConfigListIns, err = NewAggConfigManager(ConfigFilePath, CredentialFilePath) + if err != nil { + LogError(err.Error()) + return + } + + var ins *AggConfig + if Global.Profile == "" { + ins, err = AggConfigListIns.GetActiveAggConfig() + if err != nil && len(AggConfigListIns.GetAggConfigList()) != 0 { + HandleError(err) + } + } else { + ins, _ = AggConfigListIns.GetAggConfigByProfile(Global.Profile) + } + + if ins != nil { + ConfigIns = ins + } + + mergeConfigIns(ConfigIns) + logCmd() + + if err := InitClientRuntime(ConfigIns); err != nil { + HandleError(err) + } +} + +func mergeConfigIns(ins *AggConfig) { + if Global.BaseURL != "" { + ins.BaseURL = Global.BaseURL + } + if Global.ChannelKey != "" { + ins.ChannelKey = Global.ChannelKey + } + if Global.Timeout != 0 { + ins.Timeout = Global.Timeout + } + if Global.MaxRetryTimes != -1 { + ins.MaxRetryTimes = sdk.Int(Global.MaxRetryTimes) + } + + if Global.PublicKey != "" && Global.PrivateKey != "" { + ins.PrivateKey = Global.PrivateKey + ins.PublicKey = Global.PublicKey + ins.AuthMode = "" // flag 显式给了 AK/SK:走签名,抑制 Bearer 注入(D5) + } +} + +func init() { + //配置日志 + err := initLog() + if err != nil { + fmt.Println(err) + } +} diff --git a/cmd/internal/platform/config_test.go b/cmd/internal/platform/config_test.go new file mode 100644 index 0000000000..307fa93e36 --- /dev/null +++ b/cmd/internal/platform/config_test.go @@ -0,0 +1,238 @@ +package platform + +import ( + "io/ioutil" + "os" + "strings" + "testing" +) + +const cliConfigJSON = `[ + {"project_id":"org-bdks4e","region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"profile":"uweb","active":true}, + {"project_id":"org-oxjwoi","region":"hk","zone":"hk-02","base_url":"https://api.ucloud.cn/","timeout_sec":15,"profile":"test","active":false} +]` + +const credentialJSON = `[ + {"public_key":"4E9UU*****3ZAPWQ==","private_key":"6945*****a0d45","profile":"uweb"}, + {"public_key":"YSQG*****zgnCRQ=","private_key":"jtma*****Avms","profile":"test"} +]` + +func TestAggConfigManager(t *testing.T) { + os.MkdirAll(".ucloud", 0700) + err := ioutil.WriteFile(".ucloud/config.json", []byte(cliConfigJSON), LocalFileMode) + if err != nil { + t.Error(err) + } + err = ioutil.WriteFile(".ucloud/credential.json", []byte(credentialJSON), LocalFileMode) + if err != nil { + t.Error(err) + } + defer func() { + err := os.RemoveAll(".ucloud") + if err != nil { + t.Error(err) + } + }() + + acManager, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Error(err) + } + + if len(acManager.configs) != 2 { + t.Errorf("expect length of configs is 2, accpet %d", len(acManager.configs)) + } + +} + +func TestEmptyAggConfigManager(t *testing.T) { + os.MkdirAll(".ucloud", 0700) + defer func() { + err := os.RemoveAll(".ucloud") + if err != nil { + t.Error(err) + } + }() + + acManager, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Error(err) + } + + err = acManager.Load() + if err != nil { + t.Fatal(err) + } + + if len(acManager.configs) != 0 { + t.Errorf("expect length of configs is 2, accpet %d", len(acManager.configs)) + } +} + +// CRITICAL 回归:旧 credential.json(无 oauth 字段)必须照常加载且 Save 后不丢数据 +func TestOldCredentialCompat(t *testing.T) { + os.MkdirAll(".ucloud", 0700) + defer os.RemoveAll(".ucloud") + ioutil.WriteFile(".ucloud/config.json", []byte(cliConfigJSON), LocalFileMode) + ioutil.WriteFile(".ucloud/credential.json", []byte(credentialJSON), LocalFileMode) + + m, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + ac, ok := m.GetAggConfigByProfile("uweb") + if !ok { + t.Fatal("profile uweb missing") + } + if ac.AuthMode != "" || ac.AccessToken != "" { + t.Errorf("old file should yield empty oauth fields, got %+v", ac) + } + if ac.PublicKey == "" { + t.Error("aksk fields must survive") + } + if err := m.Save(); err != nil { + t.Fatal(err) + } +} + +// oauth 字段写入后能读回(含轮换写回场景的字段完整性) +func TestOAuthFieldsRoundTrip(t *testing.T) { + os.MkdirAll(".ucloud", 0700) + defer os.RemoveAll(".ucloud") + m, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + ac := &AggConfig{ + Profile: "oauthp", Active: true, BaseURL: DefaultBaseURL, Timeout: 15, + MaxRetryTimes: intPtr(3), + AuthMode: AuthModeOAuth, AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1234567890, + OAuthBaseURL: "https://oauth.example.com", + } + if err := m.Append(ac); err != nil { + t.Fatal(err) + } + + // 重新读盘验证 + m2, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + got, ok := m2.GetAggConfigByProfile("oauthp") + if !ok { + t.Fatal("profile oauthp missing after reload") + } + if got.AuthMode != AuthModeOAuth || got.AccessToken != "at" || got.RefreshToken != "rt" || + got.ExpiresAt != 1234567890 || got.OAuthBaseURL != "https://oauth.example.com" { + t.Errorf("oauth fields lost on round trip: %+v", got) + } +} + +// channel_key 写入后能读回(AC4)。覆盖跨层链路 copyToCLIConfig → config.json → Load, +// 任一层漏改都会表现为「能存不能读」或「存了不落盘」。 +func TestChannelKeyRoundTrip(t *testing.T) { + os.MkdirAll(".ucloud", 0700) + defer os.RemoveAll(".ucloud") + m, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + ac := &AggConfig{ + Profile: "combo", Active: true, BaseURL: "https://api.ucloud-global.com/", Timeout: 15, + MaxRetryTimes: intPtr(3), ChannelKey: "ch_combo_roundtrip", + } + if err := m.Append(ac); err != nil { + t.Fatal(err) + } + + m2, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + got, ok := m2.GetAggConfigByProfile("combo") + if !ok { + t.Fatal("profile combo missing after reload") + } + if got.ChannelKey != "ch_combo_roundtrip" { + t.Errorf("ChannelKey lost on round trip: got %q, want ch_combo_roundtrip", got.ChannelKey) + } + + // channel-key 是接入点配置而非凭据:绝不能落进 credential.json + raw, err := os.ReadFile(".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "ch_combo_roundtrip") { + t.Error("channel_key must not be persisted to credential.json (it is an endpoint config, not a credential)") + } +} + +// 向后兼容(AC6):不含 channel_key 字段的旧 config.json 照常加载,且该值为空。 +func TestConfigWithoutChannelKeyLoads(t *testing.T) { + os.MkdirAll(".ucloud", 0700) + defer os.RemoveAll(".ucloud") + if err := os.WriteFile(".ucloud/config.json", + []byte(`[{"profile":"legacy","active":true,"base_url":"https://api.ucloud.cn/","timeout_sec":15,"max_retry_times":3}]`), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(".ucloud/credential.json", + []byte(`[{"profile":"legacy","public_key":"pub","private_key":"pri"}]`), 0600); err != nil { + t.Fatal(err) + } + m, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + got, ok := m.GetAggConfigByProfile("legacy") + if !ok { + t.Fatal("legacy profile missing") + } + if got.ChannelKey != "" { + t.Errorf("ChannelKey = %q, want empty for a legacy config", got.ChannelKey) + } +} + +// UpdateAggConfig 必须以传入的 config 为准:当传入指针与 map 内条目不是同一个对象时 +// (如 `ucloud --profile <不存在>` 回退到包级默认 ConfigIns 而盘上已有同名 profile), +// 不能静默把 map 里的旧数据存盘、丢掉调用方的数据。 +func TestUpdateAggConfigPointerMismatch(t *testing.T) { + os.MkdirAll(".ucloud", 0700) + defer os.RemoveAll(".ucloud") + m, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + old := &AggConfig{ + Profile: "x", Active: true, Region: "cn-bj2", Zone: "cn-bj2-04", + PublicKey: "oldpub", PrivateKey: "oldpri", + BaseURL: DefaultBaseURL, Timeout: 15, MaxRetryTimes: intPtr(3), + } + if err := m.Append(old); err != nil { + t.Fatal(err) + } + + // 独立构造的另一个指针,同 Profile、不同字段值 + fresh := &AggConfig{ + Profile: "x", Active: true, Region: "hk", Zone: "hk-02", + PublicKey: "newpub", PrivateKey: "newpri", + BaseURL: DefaultBaseURL, Timeout: 30, MaxRetryTimes: intPtr(5), + } + if err := m.UpdateAggConfig(fresh); err != nil { + t.Fatal(err) + } + + m2, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + got, ok := m2.GetAggConfigByProfile("x") + if !ok { + t.Fatal("profile x missing after reload") + } + if got.Region != "hk" || got.Zone != "hk-02" || got.PublicKey != "newpub" || + got.PrivateKey != "newpri" || got.Timeout != 30 { + t.Errorf("passed config was silently dropped, stale data persisted: %+v", got) + } +} + +func intPtr(i int) *int { return &i } diff --git a/cmd/internal/platform/credential_test.go b/cmd/internal/platform/credential_test.go new file mode 100644 index 0000000000..121597a961 --- /dev/null +++ b/cmd/internal/platform/credential_test.go @@ -0,0 +1,71 @@ +// base/credential_test.go +package platform + +import ( + "testing" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" +) + +// oauth 模式:残留的 AK/SK 绝不能进入签名凭据(否则签名参数与 Bearer 同时上行 → 网关 RetCode 171)。 +func TestBuildCredentialOAuthEmpty(t *testing.T) { + cc := &CredentialConfig{AuthMode: AuthModeOAuth, PublicKey: "pk", PrivateKey: "sk"} + cred := buildCredential(cc) + if cred.PublicKey != "" { + t.Errorf("oauth credential PublicKey = %q, want empty", cred.PublicKey) + } + if cred.PrivateKey != "" { + t.Errorf("oauth credential PrivateKey = %q, want empty", cred.PrivateKey) + } +} + +// AK/SK 模式(非 oauth):真实公私钥必须进入签名凭据,否则 SDK 签名器拿不到密钥 → 网关 RetCode 171。 +func TestBuildCredentialAkskKeys(t *testing.T) { + // AuthMode 留空即 AK/SK(没有 AuthModeAKSK 常量;config.go:896 显式置空表示走签名)。 + cc := &CredentialConfig{AuthMode: "", PublicKey: "pk", PrivateKey: "sk"} + cred := buildCredential(cc) + if cred.PublicKey != "pk" { + t.Errorf("aksk credential PublicKey = %q, want pk", cred.PublicKey) + } + if cred.PrivateKey != "sk" { + t.Errorf("aksk credential PrivateKey = %q, want sk", cred.PrivateKey) + } +} + +// 包级 wrapper BuildCredential 必须与 buildCredential 走同一逻辑/分支(cli.NewServiceClient 依赖它)。 +func TestBuildCredentialPackageWrapper(t *testing.T) { + old := AuthCredential + t.Cleanup(func() { AuthCredential = old }) + + // AK/SK:返回真实密钥 + AuthCredential = &CredentialConfig{AuthMode: "", PublicKey: "pk", PrivateKey: "sk"} + cred := BuildCredential() + if cred.PublicKey != "pk" || cred.PrivateKey != "sk" { + t.Errorf("BuildCredential aksk = {%q,%q}, want {pk,sk}", cred.PublicKey, cred.PrivateKey) + } + + // oauth:返回空凭据 + AuthCredential = &CredentialConfig{AuthMode: AuthModeOAuth, PublicKey: "pk", PrivateKey: "sk"} + cred = BuildCredential() + if cred.PublicKey != "" || cred.PrivateKey != "" { + t.Errorf("BuildCredential oauth = {%q,%q}, want empty", cred.PublicKey, cred.PrivateKey) + } +} + +// AttachHandlers 在真实 sub-client 上挂载三个 handler,不 panic、不报错;挂载后 client 仍可用。 +// (handler 内部行为已由 client_test.go 的 12 个鉴权用例覆盖,此处只验证 wiring 不破。) +func TestAttachHandlersDoesNotPanic(t *testing.T) { + oldCred, oldIns := AuthCredential, ConfigIns + t.Cleanup(func() { AuthCredential, ConfigIns = oldCred, oldIns }) + AuthCredential = &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"} + ConfigIns = &AggConfig{Profile: "p"} + + c := udb.NewClient(&sdk.Config{}, &auth.Credential{}) + // 不 panic 即通过;sdk 的 AddXxxHandler 恒返回 nil,但以防回归仍断言 client 非空。 + AttachHandlers(c) + if c == nil || c.Client == nil { + t.Fatal("AttachHandlers must leave the client usable") + } +} diff --git a/cmd/internal/platform/getbizclient_identity_test.go b/cmd/internal/platform/getbizclient_identity_test.go new file mode 100644 index 0000000000..04ff3675ee --- /dev/null +++ b/cmd/internal/platform/getbizclient_identity_test.go @@ -0,0 +1,41 @@ +package platform + +import "testing" + +// TestInitClientRuntimeKeepsAuthCredentialIdentity locks the invariant that a token +// refresh via InitClientRuntime overwrites the existing *AuthCredential in place +// instead of swapping the package pointer. +// +// Product service clients (cli.NewServiceClient) capture the AuthCredential +// pointer at command-tree registration and read AccessToken lazily per request. +// If InitClientRuntime replaced the pointer, those already-built clients would keep +// sending the pre-refresh (expired) Bearer on the first request and only recover +// through the reactive retry handler at the cost of a wasted round-trip. +func TestInitClientRuntimeKeepsAuthCredentialIdentity(t *testing.T) { + savedCred, savedCfg := AuthCredential, ClientConfig + t.Cleanup(func() { AuthCredential, ClientConfig = savedCred, savedCfg }) + + AuthCredential = &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "stale-token"} + captured := AuthCredential // the pointer a product client would have captured + + retries := 3 + ac := &AggConfig{ + BaseURL: "https://api.ucloud.cn/", + Timeout: 15, + Region: "cn-bj2", + ProjectID: "org-test", + MaxRetryTimes: &retries, + AuthMode: AuthModeOAuth, + AccessToken: "fresh-token", + } + if err := InitClientRuntime(ac); err != nil { + t.Fatalf("InitClientRuntime returned error: %v", err) + } + + if AuthCredential != captured { + t.Fatal("AuthCredential pointer was replaced; registration-time product clients would keep the stale token") + } + if captured.AccessToken != "fresh-token" { + t.Fatalf("captured credential not refreshed in place: got %q, want %q", captured.AccessToken, "fresh-token") + } +} diff --git a/cmd/internal/platform/log.go b/cmd/internal/platform/log.go new file mode 100644 index 0000000000..b21d243c55 --- /dev/null +++ b/cmd/internal/platform/log.go @@ -0,0 +1,363 @@ +package platform + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "runtime" + "strings" + "sync" + "time" + + uuid "github.com/satori/go.uuid" + log "github.com/sirupsen/logrus" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + sdkversion "github.com/ucloud/ucloud-sdk-go/ucloud/version" + + cliversion "github.com/ucloud/ucloud-cli/cmd/internal/version" + "github.com/ucloud/ucloud-cli/internal/common" +) + +const DefaultDasURL = "https://das-rpt.ucloud.cn/log" + +// Logger 日志 +var logger *log.Logger +var mu sync.Mutex +var out = Cxt.GetWriter() +var tracer = Tracer{DefaultDasURL} + +func initConfigDir() { + if _, err := os.Stat(GetLogFileDir()); os.IsNotExist(err) { + err := os.MkdirAll(GetLogFileDir(), LocalFileMode) + if err != nil { + panic(err) + } + } +} + +func initLog() error { + initConfigDir() + file, err := os.OpenFile(GetLogFilePath(), os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("open log file failed: %v", err) + } + logger = log.New() + logger.SetNoLock() + logger.AddHook(NewLogRotateHook(file)) + logger.SetOutput(file) + + return nil +} + +// redactCmdArgs 脱敏命令行参数:flag 值遮蔽(名单含 oauth 敏感词)+ 整体过 Redact 兜底 +func redactCmdArgs(osArgs []string) []string { + args := make([]string, len(osArgs)) + copy(args, osArgs) + for idx, arg := range args { + for _, word := range []string{"password", "private-key", "public-key", "code", "token", "authorization"} { + if strings.Contains(arg, word) && idx <= len(args)-2 { + args[idx+1] = strings.Repeat("*", 8) + } + } + } + for idx := range args { + args[idx] = Redact(args[idx]) + } + return args +} + +// redactLogLines 日志出口统一脱敏(Phase 3 扩面:错误包装/调试输出经 Log* 的部分) +func redactLogLines(logs []string) []string { + out := make([]string, len(logs)) + for i, line := range logs { + out[i] = Redact(line) + } + return out +} + +func logCmd() { + args := redactCmdArgs(os.Args) + LogInfo(fmt.Sprintf("command: %s", strings.Join(args, " "))) +} + +// GetLogger return point of logger +func GetLogger() *log.Logger { + return logger +} + +// GetLogFileDir 获取日志文件路径 +func GetLogFileDir() string { + return common.GetHomePath() + fmt.Sprintf("/%s", ConfigPath) +} + +// GetLogFilePath 获取日志文件路径 +func GetLogFilePath() string { + return common.GetHomePath() + fmt.Sprintf("/%s/cli.log", ConfigPath) +} + +// logToFile writes lines to the local cli.log only — NO DAS telemetry upload — +// with the same redaction and COMP_LINE skip as LogInfo. Used by the platform +// request-logging handler so logging every API request does not inflate +// telemetry traffic for users who opted into log upload (see batch-1 plan +// Part 0 Task 0.2, decision A). +func logToFile(logs ...string) { + if _, ok := os.LookupEnv("COMP_LINE"); ok { + return + } + logs = redactLogLines(logs) + mu.Lock() + defer mu.Unlock() + goID := curGoroutineID() + for _, line := range logs { + logger.WithField("goroutine_id", goID).Info(line) + } +} + +// LogInfo 记录日志 +func LogInfo(logs ...string) { + _, ok := os.LookupEnv("COMP_LINE") + if ok { + return + } + logs = redactLogLines(logs) + mu.Lock() + defer mu.Unlock() + goID := curGoroutineID() + for _, line := range logs { + logger.WithField("goroutine_id", goID).Info(line) + } + if ConfigIns.AgreeUploadLog { + UploadLogs(logs, "info", goID) + } +} + +// LogPrint 记录日志. Console copy → global stdout; product code should prefer the +// ctx wrappers (→ *To with stderr) so machine output on stdout stays clean. +func LogPrint(logs ...string) { LogPrintTo(out, logs...) } + +// LogPrintTo is LogPrint with a caller-chosen console writer w (file + telemetry +// unchanged). +func LogPrintTo(w io.Writer, logs ...string) { + if _, ok := os.LookupEnv("COMP_LINE"); ok { + return + } + logs = redactLogLines(logs) + mu.Lock() + defer mu.Unlock() + goID := curGoroutineID() + for _, line := range logs { + logger.WithField("goroutine_id", goID).Print(line) + fmt.Fprintln(w, line) + } + if ConfigIns.AgreeUploadLog { + UploadLogs(logs, "print", goID) + } +} + +// LogWarn 记录日志. Console copy → global stdout; product code should prefer the +// ctx wrappers (→ *To with stderr). +func LogWarn(logs ...string) { LogWarnTo(out, logs...) } + +// LogWarnTo is LogWarn with a caller-chosen console writer w (file + telemetry +// unchanged). +func LogWarnTo(w io.Writer, logs ...string) { + if _, ok := os.LookupEnv("COMP_LINE"); ok { + return + } + logs = redactLogLines(logs) + mu.Lock() + defer mu.Unlock() + goID := curGoroutineID() + for _, line := range logs { + logger.WithField("goroutine_id", goID).Warn(line) + fmt.Fprintln(w, line) + } + if ConfigIns.AgreeUploadLog { + UploadLogs(logs, "warn", goID) + } +} + +// LogError 记录日志. The console copy goes to the global writer (stdout); product +// code should prefer ctx.HandleError (→ LogErrorTo with stderr) so machine output +// on stdout stays clean. +func LogError(logs ...string) { + LogErrorTo(out, logs...) +} + +// LogErrorTo is LogError with a caller-chosen console writer w; file logging and +// telemetry are unchanged. Products route the console copy to stderr via +// ctx.HandleError so stdout carries only machine-readable results. +func LogErrorTo(w io.Writer, logs ...string) { + if _, ok := os.LookupEnv("COMP_LINE"); ok { + return + } + logs = redactLogLines(logs) + mu.Lock() + defer mu.Unlock() + goID := curGoroutineID() + for _, line := range logs { + logger.WithField("goroutine_id", goID).Error(line) + fmt.Fprintln(w, line) + } + if ConfigIns.AgreeUploadLog { + UploadLogs(logs, "error", goID) + } +} + +// UploadLogs send logs to das server +func UploadLogs(logs []string, level string, goID int64) { + var lines []string + for _, log := range logs { + line := fmt.Sprintf("time=%s level=%s goroutine_id=%d msg=%s", time.Now().Format(time.RFC3339Nano), level, goID, log) + lines = append(lines, line) + } + tracer.Send(lines) +} + +// LogRotateHook rotate log file +type LogRotateHook struct { + MaxSize int64 + Cut float32 + LogFile *os.File + mux sync.Mutex +} + +// Levels fires hook +func (hook *LogRotateHook) Levels() []log.Level { + return log.AllLevels +} + +// Fire do someting when hook is triggered +func (hook *LogRotateHook) Fire(entry *log.Entry) error { + hook.mux.Lock() + defer hook.mux.Unlock() + info, err := hook.LogFile.Stat() + if err != nil { + return err + } + + if info.Size() <= hook.MaxSize { + return nil + } + hook.LogFile.Sync() + offset := int64(float32(hook.MaxSize) * hook.Cut) + buf := make([]byte, info.Size()-offset) + _, err = hook.LogFile.ReadAt(buf, offset) + if err != nil { + return err + } + + nfile, err := os.Create(GetLogFilePath() + ".tmp") + if err != nil { + return err + } + nfile.Write(buf) + nfile.Close() + + err = os.Rename(GetLogFilePath()+".tmp", GetLogFilePath()) + if err != nil { + return err + } + + mfile, err := os.OpenFile(GetLogFilePath(), os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + fmt.Println("open log file failed: ", err) + return err + } + entry.Logger.SetOutput(mfile) + return nil +} + +// NewLogRotateHook create a LogRotateHook +func NewLogRotateHook(file *os.File) *LogRotateHook { + return &LogRotateHook{ + MaxSize: 1024 * 1024, //1MB + Cut: 0.2, + LogFile: file, + } +} + +// ToQueryMap tranform request to map +func ToQueryMap(req request.Common) map[string]string { + reqMap, err := request.ToQueryMap(req) + if err != nil { + return nil + } + delete(reqMap, "Password") + return reqMap +} + +// requestLogLine formats an API request for the platform request-logging +// handler: "api: , request: " (Password already redacted by +// ToQueryMap). This replaces the per-command hand-rolled request logging that +// products used to build with ToQueryMap — every request is now logged +// uniformly at the SDK handler layer (see batch-1 plan Part 0 Task 0.2). +func requestLogLine(req request.Common) string { + return fmt.Sprintf("api: %s, request: %v", req.GetAction(), ToQueryMap(req)) +} + +// Tracer upload log to server if allowed +type Tracer struct { + DasUrl string +} + +func (t Tracer) wrapLogs(log []string) ([]byte, error) { + dataSet := make([]map[string]interface{}, 0) + dataItem := map[string]interface{}{ + "level": "info", + "topic": "api", + "log": log, + } + dataSet = append(dataSet, dataItem) + reqUUID := uuid.NewV4() + sessionID := uuid.NewV4() + user, err := GetUserInfo() + if err != nil { + return nil, err + } + payload := map[string]interface{}{ + "aid": "iywtleaa", + "uuid": reqUUID, + "sid": sessionID, + "ds": dataSet, + "cs": map[string]interface{}{ + "uname": user.UserEmail, + }, + } + marshaled, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("cannot to marshal log: %s", err) + } + return marshaled, nil +} + +// Send logs to server +func (t Tracer) Send(logs []string) error { + body, err := t.wrapLogs(logs) + if err != nil { + return err + } + for i := 0; i < len(body); i++ { + body[i] = ^body[i] + } + + client := &http.Client{} + ua := fmt.Sprintf("GO/%s GO-SDK/%s %s", runtime.Version(), sdkversion.Version, cliversion.UserAgent()) + req, err := http.NewRequest("POST", t.DasUrl, bytes.NewReader(body)) + req.Header.Add("Origin", "https://sdk.ucloud.cn") + req.Header.Add("User-Agent", ua) + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("send logs failed: status %d %s", resp.StatusCode, resp.Status) + } + + return nil +} diff --git a/cmd/internal/platform/log_test.go b/cmd/internal/platform/log_test.go new file mode 100644 index 0000000000..79e912f81c --- /dev/null +++ b/cmd/internal/platform/log_test.go @@ -0,0 +1,79 @@ +// base/log_test.go +package platform + +import ( + "bytes" + "os" + "strings" + "testing" +) + +func TestRedactCmdArgs(t *testing.T) { + args := []string{"ucloud", "login", "--private-key", "PRIKEY", "--code", "CODE1", "--token", "TOK1", "--authorization", "AUTH1"} + got := strings.Join(redactCmdArgs(args), " ") + for _, secret := range []string{"PRIKEY", "CODE1", "TOK1", "AUTH1"} { + if strings.Contains(got, secret) { + t.Errorf("redactCmdArgs leaked %q: %s", secret, got) + } + } + if !strings.Contains(got, "login") { + t.Error("non-sensitive args must be preserved") + } +} + +// 整行兜底:args 中内嵌的 URL query 形态的 code/token 也要被遮蔽 +func TestRedactCmdArgsURLForm(t *testing.T) { + args := []string{"ucloud", "x", "http://localhost/authorization?code=SEC&state=ST"} + got := strings.Join(redactCmdArgs(args), " ") + if strings.Contains(got, "SEC") { + t.Errorf("url-embedded code leaked: %s", got) + } +} + +// 出口接线测试:直接走 LogInfo,确认脱敏真的接在出口上(防止 redactLogLines 调用行被误删而无测试失败) +func TestLogInfoOutletWired(t *testing.T) { + if logger == nil { + t.Fatal("logger not initialized by package init") + } + // COMP_LINE 存在时 Log* 直接 return,须确保未设置 + if v, ok := os.LookupEnv("COMP_LINE"); ok { + os.Unsetenv("COMP_LINE") + t.Cleanup(func() { os.Setenv("COMP_LINE", v) }) + } + // 关闭上传路径,避免测试触网 + prevUpload := ConfigIns.AgreeUploadLog + ConfigIns.AgreeUploadLog = false + t.Cleanup(func() { ConfigIns.AgreeUploadLog = prevUpload }) + + var buf bytes.Buffer + prevOut := logger.Out + logger.SetOutput(&buf) + t.Cleanup(func() { logger.SetOutput(prevOut) }) + + LogInfo(`Authorization: Bearer SECRET-WIRE`) + + got := buf.String() + if got == "" { + t.Fatal("LogInfo wrote nothing to logger output") + } + if strings.Contains(got, "SECRET-WIRE") { + t.Errorf("LogInfo outlet leaked token: %s", got) + } + if !strings.Contains(got, "********") { + t.Errorf("LogInfo outlet missing redaction placeholder: %s", got) + } +} + +// 扩面:任何经 Log* 出口的行都不得泄漏 token(HandleError → LogError 同样被覆盖) +func TestLogOutputsRedacted(t *testing.T) { + lines := redactLogLines([]string{ + `request failed: Authorization: Bearer SECRET-AT`, + `refresh response: {"access_token":"SECRET-AT2","refresh_token":"SECRET-RT"}`, + }) + joined := strings.Join(lines, "\n") + for _, s := range []string{"SECRET-AT", "SECRET-AT2", "SECRET-RT"} { + if strings.Contains(joined, s) { + t.Errorf("log line leaked %q: %s", s, joined) + } + } +} diff --git a/cmd/internal/platform/logtofile_test.go b/cmd/internal/platform/logtofile_test.go new file mode 100644 index 0000000000..bbc99da2bd --- /dev/null +++ b/cmd/internal/platform/logtofile_test.go @@ -0,0 +1,32 @@ +package platform + +import ( + "bytes" + "os" + "strings" + "testing" + + log "github.com/sirupsen/logrus" +) + +// logToFile writes to cli.log only — never to DAS telemetry — so the per-request +// logging handler does not upload every API request to the server. +func TestLogToFileWritesToLoggerOnly(t *testing.T) { + // logToFile skips when COMP_LINE is present (completion); ensure it's absent. + if v, ok := os.LookupEnv("COMP_LINE"); ok { + os.Unsetenv("COMP_LINE") + defer os.Setenv("COMP_LINE", v) + } + + var buf bytes.Buffer + old := logger + logger = log.New() + logger.SetOutput(&buf) + defer func() { logger = old }() + + logToFile("api: DescribeUHostInstance, request: map[Region:cn-bj2]") + + if !strings.Contains(buf.String(), "DescribeUHostInstance") { + t.Fatalf("logToFile did not write to the cli.log logger: %q", buf.String()) + } +} diff --git a/cmd/internal/platform/normalize_projectid_test.go b/cmd/internal/platform/normalize_projectid_test.go new file mode 100644 index 0000000000..1a8728c124 --- /dev/null +++ b/cmd/internal/platform/normalize_projectid_test.go @@ -0,0 +1,171 @@ +package platform + +import ( + "testing" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" +) + +// projectIDOnWire 按 SDK 默认 form 编码器编码 req,返回真正会上行的 ProjectId。 +// 断言 wire 值而不是 GetPayload() 中间态:产品踩的坑正是「中间态已归一化、 +// wire 上却是原值」,只有编码结果能证伪。 +func projectIDOnWire(t *testing.T, req request.Common) (string, bool) { + t.Helper() + form, err := request.EncodeForm(req) + if err != nil { + t.Fatalf("encode form: %v", err) + } + v, ok := form["ProjectId"] + return v, ok +} + +// TestNormalizeProjectIDAcrossProductShapes 覆盖 master 上各产品传 project-id 的全部 +// 形态(每条 name 标注取样来源),断言 wire 上的最终值。 +// +// 平台补全 getProjectList 给出的候选是 "org-xxx/ProjectName"(cmd/project.go), +// 平台 handler 负责还原成纯 id。把 ProjectId 放进 generic payload map 的产品, +// 归一化会被 SDK 的 payload 覆盖语义吃掉 —— 详见 normalizeProjectID 的注释。 +func TestNormalizeProjectIDAcrossProductShapes(t *testing.T) { + const idName = "org-x/MyProject" + const bare = "org-x" + + tests := []struct { + name string + build func(t *testing.T) request.Common + wantWire string + wantSet bool + }{ + { + // 取样:umongodb create_replset.go:61 / utidb api.go:49 + // sqlserver create.go:89 / pgsql(#127) supabase params() + // 这是本次修复的目标形态:修复前 wire 上是 "org-x/MyProject", + // 网关报 RetCode 292 Project [org-x/MyProject] not exists。 + name: "generic payload map + id/name (umongodb/utidb/sqlserver/pgsql-supabase)", + build: func(t *testing.T) request.Common { + gr := &request.BaseGenericRequest{} + if err := gr.SetPayload(map[string]interface{}{"Action": "X", "ProjectId": idName}); err != nil { + t.Fatal(err) + } + return gr + }, + wantWire: bare, wantSet: true, + }, + { + // 同上形态但用户传的已是纯 id(不按 Tab 补全)—— 修复前后行为必须一致。 + name: "generic payload map + bare id (未按 Tab,最常见)", + build: func(t *testing.T) request.Common { + gr := &request.BaseGenericRequest{} + if err := gr.SetPayload(map[string]interface{}{"Action": "X", "ProjectId": bare}); err != nil { + t.Fatal(err) + } + return gr + }, + wantWire: bare, wantSet: true, + }, + { + // 取样:cloudwatch query_metric_data.go:180 (BindProjectID 绑 CommonBase) + // ukafka list.go:60 (genReq.SetProjectId) + // payload 不含 ProjectId → 一直是好的,本次修复不得改变它。 + name: "generic CommonBase only + id/name (cloudwatch/ukafka)", + build: func(t *testing.T) request.Common { + gr := &request.BaseGenericRequest{} + if err := gr.SetPayload(map[string]interface{}{"Action": "X"}); err != nil { + t.Fatal(err) + } + if err := gr.SetProjectId(idName); err != nil { + t.Fatal(err) + } + return gr + }, + wantWire: bare, wantSet: true, + }, + { + // 取样:mysql create.go:50 (payload 只有 DBVersion/Region/Zone) / uddos (不传 project) + // 无 project-id → wire 上不该凭空出现该字段。 + name: "generic 无 project-id (mysql/uddos)", + build: func(t *testing.T) request.Common { + gr := &request.BaseGenericRequest{} + if err := gr.SetPayload(map[string]interface{}{"Action": "X"}); err != nil { + t.Fatal(err) + } + return gr + }, + wantWire: "", wantSet: false, + }, + { + // 绝大多数产品:typed SDK 请求 —— 历史行为,必须不变。 + name: "typed request + id/name (绝大多数产品)", + build: func(t *testing.T) request.Common { + req := &request.CommonBase{} + if err := req.SetProjectId(idName); err != nil { + t.Fatal(err) + } + return req + }, + wantWire: bare, wantSet: true, + }, + { + name: "typed request + bare id", + build: func(t *testing.T) request.Common { + req := &request.CommonBase{} + if err := req.SetProjectId(bare); err != nil { + t.Fatal(err) + } + return req + }, + wantWire: bare, wantSet: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := normalizeProjectID(tt.build(t)) + if err != nil { + t.Fatalf("normalizeProjectID: %v", err) + } + got, ok := projectIDOnWire(t, out) + if ok != tt.wantSet { + t.Fatalf("ProjectId present on wire = %v, want %v (got %q)", ok, tt.wantSet, got) + } + if got != tt.wantWire { + t.Errorf("ProjectId on wire = %q, want %q", got, tt.wantWire) + } + }) + } +} + +// 归一化不得殃及 payload 里的其它字段 —— SetPayload 是整表替换,回归保护。 +func TestNormalizeProjectIDLeavesOtherPayloadFieldsIntact(t *testing.T) { + gr := &request.BaseGenericRequest{} + if err := gr.SetPayload(map[string]interface{}{ + "Action": "CreateUMongoDBReplSet", + "ProjectId": "org-x/MyProject", + "Region": "cn-bj2", + "Zone": "cn-bj2-02", + "Name": "my/instance/with/slashes", // 业务字段里的 "/" 绝不能被 pick + "DiskSpace": 100, // int 必须仍是 int(JSON 编码器依赖它) + "IsMemoryDB": true, + }); err != nil { + t.Fatal(err) + } + out, err := normalizeProjectID(gr) + if err != nil { + t.Fatal(err) + } + payload := out.(request.GenericRequest).GetPayload() + if payload["ProjectId"] != "org-x" { + t.Errorf("ProjectId = %v, want org-x", payload["ProjectId"]) + } + if payload["Name"] != "my/instance/with/slashes" { + t.Errorf("business field with slashes was mangled: %v", payload["Name"]) + } + if payload["DiskSpace"] != 100 { + t.Errorf("DiskSpace = %#v, want int 100 (type must survive for JSON encoder)", payload["DiskSpace"]) + } + if payload["IsMemoryDB"] != true { + t.Errorf("IsMemoryDB = %#v, want bool true", payload["IsMemoryDB"]) + } + if payload["Action"] != "CreateUMongoDBReplSet" || payload["Region"] != "cn-bj2" || payload["Zone"] != "cn-bj2-02" { + t.Errorf("common fields changed: %v", payload) + } +} diff --git a/cmd/internal/platform/oauth.go b/cmd/internal/platform/oauth.go new file mode 100644 index 0000000000..2d267b0130 --- /dev/null +++ b/cmd/internal/platform/oauth.go @@ -0,0 +1,401 @@ +// base/oauth.go +package platform + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" + + "github.com/gofrs/flock" + "github.com/mattn/go-isatty" +) + +// BuildLoopbackRedirectURI 按后端规则拼 loopback redirect_uri:host 必须是字面量 localhost +// (127.0.0.1 会被后端拒),端口为内核分配的临时端口(>=1024)。 +func BuildLoopbackRedirectURI(port int) string { + return fmt.Sprintf("http://%s:%d%s", loopbackRedirectHost, port, OAuthRedirectPath) +} + +// AuthModeOAuth auth_mode 取值:OAuth 浏览器登录。空串/其他值一律视为 AK/SK 签名模式。 +const AuthModeOAuth = "oauth" + +// TokenExpirySkew 主动刷新的时钟偏斜余量(D6) +const TokenExpirySkew = 5 * time.Minute + +// GenerateState 生成 CSRF state:32 字节随机 base64url +func GenerateState() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate state failed: %v", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// GetOAuthBaseURL 生效的 OAuth 域名:profile 配置优先,否则内置默认(D9.2) +func GetOAuthBaseURL(cfg *AggConfig) (string, error) { + if cfg.OAuthBaseURL != "" { + return strings.TrimSuffix(cfg.OAuthBaseURL, "/"), nil + } + return defaultOAuthBaseURL, nil +} + +// BuildAuthorizeURL 拼授权 URL(流程步骤①) +func BuildAuthorizeURL(oauthBase, redirectURI, state string) string { + v := url.Values{} + v.Set("response_type", "code") + v.Set("client_id", oauthClientID) + v.Set("redirect_uri", redirectURI) + v.Set("scope", oauthScope) + v.Set("state", state) + return fmt.Sprintf("%s%s?%s", oauthBase, oauthAuthorizePath, v.Encode()) +} + +// SanitizeCallbackInput 容忍前后空白/引号/终端折行引入的内部空白与换行(D7 输入容错) +func SanitizeCallbackInput(input string) string { + s := strings.TrimSpace(input) + s = strings.Trim(s, `"'`) + return strings.Map(func(r rune) rune { + switch r { + case '\n', '\r', ' ', '\t': + return -1 + } + return r + }, s) +} + +const callbackFormatHint = "expected format: http://" + loopbackRedirectHost + OAuthRedirectPath + "?code=xxx&state=yyy" + +// ParseCallbackURL 校验 state 并提取 code(流程步骤③) +func ParseCallbackURL(input, expectState string) (string, error) { + s := SanitizeCallbackInput(input) + u, err := url.Parse(s) + if err != nil { + return "", fmt.Errorf("cannot parse the pasted URL, no authorization code found; %s", callbackFormatHint) + } + q := u.Query() + if e := q.Get("error"); e != "" { + if e == "access_denied" { + return "", fmt.Errorf("authorization was denied in the browser. Run 'ucloud auth login' to try again") + } + return "", fmt.Errorf("oauth server returned error %q. Run 'ucloud auth login' to try again", e) + } + code := q.Get("code") + if code == "" { + return "", fmt.Errorf("no authorization code in the pasted URL; %s", callbackFormatHint) + } + if q.Get("state") != expectState { + return "", fmt.Errorf("state mismatch: the pasted URL likely comes from a previous login attempt. Run 'ucloud auth login' again and paste the URL from THIS attempt") + } + return code, nil +} + +// TokenExpiredAt 判断 token 是否需要刷新(留 TokenExpirySkew 余量) +func TokenExpiredAt(expiresAt int64, now time.Time) bool { + if expiresAt == 0 { + return true + } + return now.Add(TokenExpirySkew).Unix() >= expiresAt +} + +// TokenExpired TokenExpiredAt 的当前时间封装 +func TokenExpired(expiresAt int64) bool { + return TokenExpiredAt(expiresAt, time.Now()) +} + +// ParseIDTokenEmail 解 id_token payload 取 email。不验签,仅用于 UI 展示(D2 知情裁定);id_token 不落盘。 +func ParseIDTokenEmail(idToken string) (string, error) { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return "", fmt.Errorf("malformed id_token") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + // 部分 OIDC 实现会输出带 '=' 填充的 base64url,去填充后重试一次 + payload, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "=")) + if err != nil { + return "", fmt.Errorf("decode id_token payload failed: %v", err) + } + } + var claims struct { + Email string `json:"email"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "", fmt.Errorf("parse id_token payload failed: %v", err) + } + return claims.Email, nil +} + +// redactPatterns 覆盖 query 参数、JSON 字段、HTTP 头三种形态的敏感值 +var redactPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)((?:^|[?&\s])code=)[^&\s"']+`), + regexp.MustCompile(`(?i)((?:^|[?&\s])state=)[^&\s"']+`), + regexp.MustCompile(`(?i)(access_token"?\s*[:=]\s*"?)[^,}&\s"']+`), + regexp.MustCompile(`(?i)(refresh_token"?\s*[:=]\s*"?)[^,}&\s"']+`), + regexp.MustCompile(`(?i)(id_token"?\s*[:=]\s*"?)[^,}&\s"']+`), + regexp.MustCompile(`(?i)(authorization:?\s*bearer\s+)\S+`), +} + +// Redact 脱敏 code/token/authorization(D7 最小脱敏,UC1 提前到 Phase 1) +func Redact(s string) string { + for _, p := range redactPatterns { + s = p.ReplaceAllString(s, "${1}********") + } + return s +} + +// IsStdinTTY 判断 stdin 是否为交互终端(AP-1)。 +// 不能用 os.ModeCharDevice:/dev/null 也是字符设备,cron/CI 重定向会被误判为交互。 +// go-isatty 走真实终端检查(unix ioctl / windows console API),Cygwin/mintty 下 stdin 是管道,单独判。 +func IsStdinTTY() bool { + fd := os.Stdin.Fd() + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) +} + +// OAuthLoginRequiredHint oauth 模式但 token 缺失时的提示(AP-1/AP-3,走 stderr) +func OAuthLoginRequiredHint(profile string, isTTY bool) string { + if isTTY { + return fmt.Sprintf("Profile '%s' uses OAuth login but has no token. Run 'ucloud auth login' first", profile) + } + return fmt.Sprintf("Profile '%s' uses OAuth login, which cannot work in a non-interactive environment. For automation/CI, use an AK/SK profile: ucloud config --profile --public-key --private-key ", profile) +} + +// OAuthRefreshFailedHint refresh_token 失效/刷新失败时的提示(AP-3 模板) +func OAuthRefreshFailedHint(profile string, isTTY bool, err error) string { + if isTTY { + return fmt.Sprintf("Login expired for profile '%s' (%s). Run 'ucloud auth login' again", profile, Redact(err.Error())) + } + return fmt.Sprintf("OAuth login for profile '%s' cannot be renewed in a non-interactive environment (%s). For unattended scenarios, use an AK/SK profile instead", profile, Redact(err.Error())) +} + +// CheckOAuthRunnable oauth 模式启动检查;ok=false 时调用方应将 msg 输出到 stderr 并以非零码退出。 +// 此处刻意忽略 ExpiresAt——过期由 EnsureFreshToken(Task 6)处理,本函数只检查 token 是否存在。 +func CheckOAuthRunnable(cfg *AggConfig, isTTY bool) (string, bool) { + if cfg.AccessToken == "" || cfg.RefreshToken == "" { + return OAuthLoginRequiredHint(cfg.Profile, isTTY), false + } + return "", true +} + +// TokenResponse /token 端点响应(流程步骤④) +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +// oauthHTTPClient 使用默认 Transport:自动遵守 HTTPS_PROXY/HTTP_PROXY/NO_PROXY(ProxyFromEnvironment) +var oauthHTTPClient = &http.Client{Timeout: 30 * time.Second} + +func requestToken(oauthBase string, form url.Values) (*TokenResponse, error) { + endpoint := strings.TrimSuffix(oauthBase, "/") + oauthTokenPath + resp, err := oauthHTTPClient.PostForm(endpoint, form) + if err != nil { + return nil, fmt.Errorf("cannot reach oauth server %s (check network or proxy settings): %v", endpoint, err) + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read oauth server response failed: %v", err) + } + var tr TokenResponse + if jerr := json.Unmarshal(body, &tr); jerr != nil { + if resp.StatusCode >= 500 { + return nil, fmt.Errorf("oauth server error (HTTP %d), retry later", resp.StatusCode) + } + return nil, fmt.Errorf("unexpected oauth server response (HTTP %d): %s", resp.StatusCode, Redact(string(body))) + } + if tr.Error != "" { + return nil, translateOAuthError(tr.Error, tr.ErrorDescription) + } + if resp.StatusCode >= 500 { + return nil, fmt.Errorf("oauth server error (HTTP %d), retry later", resp.StatusCode) + } + if tr.AccessToken == "" { + return nil, fmt.Errorf("oauth server returned no access_token (HTTP %d)", resp.StatusCode) + } + return &tr, nil +} + +// translateOAuthError 按 AP-3 模板翻译 OAuth 错误码:原因 + 下一步命令 +func translateOAuthError(code, desc string) error { + switch code { + case "invalid_grant": + return fmt.Errorf("authorization code or refresh token expired or already used (each code works only once). Run 'ucloud auth login' again and paste the URL promptly") + case "access_denied": + return fmt.Errorf("authorization was denied. Run 'ucloud auth login' to try again") + default: + return fmt.Errorf("oauth server rejected the request: %s (%s). Run 'ucloud auth login' to start over", code, Redact(desc)) + } +} + +// ExchangeToken 授权码换 token(流程步骤④) +func ExchangeToken(oauthBase, redirectURI, code string) (*TokenResponse, error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("client_id", oauthClientID) + form.Set("client_secret", oauthClientSecret) + form.Set("redirect_uri", redirectURI) + return requestToken(oauthBase, form) +} + +// RefreshToken 刷新 access_token;响应中的新 refresh_token 表示轮换(D3:旧的立即作废,必须写回) +func RefreshToken(oauthBase, refreshToken string) (*TokenResponse, error) { + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", refreshToken) + form.Set("client_id", oauthClientID) + form.Set("client_secret", oauthClientSecret) + return requestToken(oauthBase, form) +} + +// ApplyTokenResponse 把 /token 响应写入 cfg;轮换语义:响应带新 refresh_token 则覆盖(D3) +func ApplyTokenResponse(cfg *AggConfig, tr *TokenResponse) { + cfg.AuthMode = AuthModeOAuth + cfg.AccessToken = tr.AccessToken + if tr.RefreshToken != "" { + cfg.RefreshToken = tr.RefreshToken + } + cfg.ExpiresAt = time.Now().Unix() + tr.ExpiresIn +} + +// EnsureFreshToken 主动刷新(D6):过期(含 5min 偏斜余量)则 refresh 并写回。 +// 「刷新+写回」由 refreshAndSave 内的 flock 串行化,拿锁后重读磁盘(Task 11)。 +func EnsureFreshToken(cfg *AggConfig, manager *AggConfigManager) error { + if !TokenExpired(cfg.ExpiresAt) { + return nil + } + return refreshAndSave(cfg, manager) +} + +// credentialLockPath flock 锁文件路径;包级变量便于测试注入 +var credentialLockPath = "" + +func getCredentialLockPath() string { + if credentialLockPath != "" { + return credentialLockPath + } + return GetConfigDir() + "/credential.lock" +} + +// credentialLockTimeout 拿锁超时(D3:超时明确报错) +const credentialLockTimeout = 10 * time.Second + +// refreshAndSave 串行化「刷新+写回」临界区(D3/D9.4): +// flock 跨进程互斥 → 拿锁后重读磁盘(他进程可能已刷新并轮换)→ 仍过期才真正刷新。 +func refreshAndSave(cfg *AggConfig, manager *AggConfigManager) error { + staleToken := cfg.AccessToken + + fl := flock.New(getCredentialLockPath()) + ctx, cancel := context.WithTimeout(context.Background(), credentialLockTimeout) + defer cancel() + ok, err := fl.TryLockContext(ctx, 200*time.Millisecond) + if err != nil && !errors.Is(err, context.DeadlineExceeded) { + // 硬错误(如锁文件无权限),与拿锁超时是两回事,必须带上原始错误 + return fmt.Errorf("acquire credential lock %s failed: %v", getCredentialLockPath(), err) + } + if !ok { + return fmt.Errorf("timed out acquiring credential lock %s after %v: another ucloud process may be refreshing, retry later", getCredentialLockPath(), credentialLockTimeout) + } + defer fl.Unlock() + + // 拿锁后重读:他进程已刷新则直接采用,避免用已作废的 refresh_token 二次刷新 + if disk, derr := readCredentialFromDisk(manager.credPath, cfg.Profile); derr == nil && disk != nil { + if disk.AccessToken != "" && disk.AccessToken != staleToken && !TokenExpired(disk.ExpiresAt) { + cfg.AccessToken = disk.AccessToken + cfg.RefreshToken = disk.RefreshToken + cfg.ExpiresAt = disk.ExpiresAt + cfg.AuthMode = AuthModeOAuth + return nil + } + if disk.RefreshToken != "" { + cfg.RefreshToken = disk.RefreshToken // 轮换后的最新 refresh_token 以磁盘为准 + } + } + + oauthBase, err := GetOAuthBaseURL(cfg) + if err != nil { + return err + } + tr, err := RefreshToken(oauthBase, cfg.RefreshToken) + if err != nil { + return err + } + ApplyTokenResponse(cfg, tr) + // Save() 会把内存里全部 profile 整写落盘,而本进程内存是 t0 快照:他进程可能已在 + // t0 之后轮换了其它 profile 的 refresh_token(D3 旧的立即作废)。落盘前重读磁盘, + // 把「非当前 profile」的 oauth 字段以磁盘为准合并,否则会把轮换结果覆盖回陈旧值, + // 导致对方 profile 下次刷新 invalid_grant(被迫重新登录)。 + if creds, rerr := readAllCredentialsFromDisk(manager.credPath); rerr == nil { + mergeOtherProfilesOAuthFromDisk(manager.configs, creds, cfg.Profile) + } + if err := manager.Save(); err != nil { + return fmt.Errorf("token refreshed but saving credential failed: %v", err) + } + return nil +} + +// mergeOtherProfilesOAuthFromDisk 把磁盘版凭据中「非当前 profile」的 oauth 四字段 +// (auth_mode/access_token/refresh_token/expires_at)合并进内存。只合并这四个字段: +// flock 临界区内唯一的合法并发写就是 oauth 刷新轮换,AK/SK、cookie 等字段不受锁保护、 +// 不在此处静默采纳。当前 profile 保持本次刷新后的内存值。 +func mergeOtherProfilesOAuthFromDisk(configs map[string]*AggConfig, diskCreds []CredentialConfig, currentProfile string) { + for i := range diskCreds { + dc := &diskCreds[i] + if dc.Profile == currentProfile { + continue + } + ac, ok := configs[dc.Profile] + if !ok { + continue + } + ac.AuthMode = dc.AuthMode + ac.AccessToken = dc.AccessToken + ac.RefreshToken = dc.RefreshToken + ac.ExpiresAt = dc.ExpiresAt + } +} + +// readAllCredentialsFromDisk 重新读盘取全部 profile 的最新凭据(不经 manager 缓存) +func readAllCredentialsFromDisk(credPath string) ([]CredentialConfig, error) { + raw, err := ioutil.ReadFile(credPath) + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, nil + } + var creds []CredentialConfig + if err := json.Unmarshal(raw, &creds); err != nil { + return nil, err + } + return creds, nil +} + +// readCredentialFromDisk 重新读盘取指定 profile 的最新凭据(不经 manager 缓存) +func readCredentialFromDisk(credPath, profile string) (*CredentialConfig, error) { + creds, err := readAllCredentialsFromDisk(credPath) + if err != nil { + return nil, err + } + for i := range creds { + if creds[i].Profile == profile { + return &creds[i], nil + } + } + return nil, nil +} diff --git a/cmd/internal/platform/oauth_http_test.go b/cmd/internal/platform/oauth_http_test.go new file mode 100644 index 0000000000..2c8c0381dc --- /dev/null +++ b/cmd/internal/platform/oauth_http_test.go @@ -0,0 +1,119 @@ +// base/oauth_http_test.go +package platform + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func tokenServer(t *testing.T, status int, body string, gotForm *map[string]string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/token" { + t.Errorf("unexpected path %s", r.URL.Path) + } + r.ParseForm() + if gotForm != nil { + m := map[string]string{} + for k := range r.PostForm { + m[k] = r.PostForm.Get(k) + } + *gotForm = m + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + fmt.Fprint(w, body) + })) +} + +// 换 token 3 分支:成功 / invalid_grant / 5xx +func TestExchangeToken(t *testing.T) { + t.Run("success", func(t *testing.T) { + var form map[string]string + s := tokenServer(t, 200, `{"access_token":"at1","refresh_token":"rt1","id_token":"idt","expires_in":3600,"token_type":"Bearer"}`, &form) + defer s.Close() + tr, err := ExchangeToken(s.URL, "http://localhost:8723/authorization", "code1") + if err != nil { + t.Fatal(err) + } + if tr.AccessToken != "at1" || tr.RefreshToken != "rt1" || tr.ExpiresIn != 3600 { + t.Errorf("unexpected token response: %+v", tr) + } + if form["grant_type"] != "authorization_code" || form["code"] != "code1" || form["redirect_uri"] != "http://localhost:8723/authorization" { + t.Errorf("bad form: %v", form) + } + }) + t.Run("invalid_grant translated", func(t *testing.T) { + s := tokenServer(t, 400, `{"error":"invalid_grant","error_description":"code expired"}`, nil) + defer s.Close() + _, err := ExchangeToken(s.URL, "http://localhost:8723/authorization", "old") + if err == nil || !strings.Contains(err.Error(), "ucloud auth login") || !strings.Contains(err.Error(), "expired or already used") { + t.Errorf("invalid_grant should translate to actionable message, got %v", err) + } + }) + t.Run("default error redacts description", func(t *testing.T) { + s := tokenServer(t, 400, `{"error":"invalid_request","error_description":"authorization: Bearer sk-secret-token-123"}`, nil) + defer s.Close() + _, err := ExchangeToken(s.URL, "http://localhost:8723/authorization", "c") + if err == nil || !strings.Contains(err.Error(), "rejected the request") { + t.Fatalf("default branch should surface rejection, got %v", err) + } + if strings.Contains(err.Error(), "sk-secret-token-123") { + t.Errorf("error_description must be redacted, got %v", err) + } + }) + t.Run("server 5xx", func(t *testing.T) { + s := tokenServer(t, 500, `oops`, nil) + defer s.Close() + _, err := ExchangeToken(s.URL, "http://localhost:8723/authorization", "c") + if err == nil || !strings.Contains(err.Error(), "server error") { + t.Errorf("5xx should say server error + retry, got %v", err) + } + }) +} + +// 刷新 3 分支:成功(轮换) / refresh 失效 / 网络不可达 +func TestRefreshToken(t *testing.T) { + t.Run("success with rotation", func(t *testing.T) { + var form map[string]string + s := tokenServer(t, 200, `{"access_token":"at2","refresh_token":"rt2-rotated","expires_in":3600}`, &form) + defer s.Close() + tr, err := RefreshToken(s.URL, "rt1") + if err != nil { + t.Fatal(err) + } + if tr.RefreshToken != "rt2-rotated" { + t.Errorf("rotated refresh token not surfaced: %+v", tr) + } + if form["grant_type"] != "refresh_token" || form["refresh_token"] != "rt1" { + t.Errorf("bad form: %v", form) + } + }) + t.Run("invalid refresh token", func(t *testing.T) { + s := tokenServer(t, 400, `{"error":"invalid_grant","error_description":"refresh token revoked"}`, nil) + defer s.Close() + if _, err := RefreshToken(s.URL, "dead"); err == nil { + t.Error("expect error for revoked refresh token") + } + }) + t.Run("unreachable", func(t *testing.T) { + _, err := RefreshToken("http://127.0.0.1:1", "rt") + if err == nil || !strings.Contains(err.Error(), "cannot reach oauth server") { + t.Errorf("network error should be distinguished, got %v", err) + } + }) +} + +// 钉死:oauthHTTPClient 必须遵守 HTTPS_PROXY 等代理环境变量(默认 Transport 或显式 ProxyFromEnvironment) +func TestOAuthClientHonorsProxyEnv(t *testing.T) { + if oauthHTTPClient.Transport == nil { + return // nil Transport == http.DefaultTransport,自带 ProxyFromEnvironment + } + tr, ok := oauthHTTPClient.Transport.(*http.Transport) + if !ok || tr.Proxy == nil { + t.Error("oauthHTTPClient custom transport must set Proxy: http.ProxyFromEnvironment") + } +} diff --git a/cmd/internal/platform/oauth_refresh_test.go b/cmd/internal/platform/oauth_refresh_test.go new file mode 100644 index 0000000000..cc99946145 --- /dev/null +++ b/cmd/internal/platform/oauth_refresh_test.go @@ -0,0 +1,217 @@ +// base/oauth_refresh_test.go +package platform + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" +) + +func newTestManager(t *testing.T, ac *AggConfig) *AggConfigManager { + t.Helper() + os.MkdirAll(".ucloud", 0700) + t.Cleanup(func() { os.RemoveAll(".ucloud") }) + credentialLockPath = ".ucloud/credential.lock" + t.Cleanup(func() { credentialLockPath = "" }) + m, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + if err := m.Append(ac); err != nil { + t.Fatal(err) + } + return m +} + +func TestApplyTokenResponse(t *testing.T) { + cfg := &AggConfig{Profile: "p", RefreshToken: "old-rt"} + ApplyTokenResponse(cfg, &TokenResponse{AccessToken: "at", ExpiresIn: 3600}) + if cfg.AuthMode != AuthModeOAuth || cfg.AccessToken != "at" { + t.Errorf("token not applied: %+v", cfg) + } + if cfg.RefreshToken != "old-rt" { + t.Error("empty refresh_token in response must keep the old one") + } + if cfg.ExpiresAt < time.Now().Unix()+3500 || cfg.ExpiresAt > time.Now().Unix()+3700 { + t.Errorf("expires_at wrong: %d", cfg.ExpiresAt) + } + // 轮换:新 refresh_token 覆盖旧(D3) + ApplyTokenResponse(cfg, &TokenResponse{AccessToken: "at2", RefreshToken: "new-rt", ExpiresIn: 3600}) + if cfg.RefreshToken != "new-rt" { + t.Error("rotated refresh_token must overwrite") + } +} + +func TestEnsureFreshToken(t *testing.T) { + refreshCalls := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + refreshCalls++ + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"new-at","refresh_token":"new-rt","expires_in":3600}`) + })) + defer s.Close() + + t.Run("expired triggers refresh and persists", func(t *testing.T) { + ac := &AggConfig{ + Profile: "p1", Active: true, BaseURL: DefaultBaseURL, Timeout: 15, MaxRetryTimes: intPtr(3), + AuthMode: AuthModeOAuth, AccessToken: "old-at", RefreshToken: "old-rt", + ExpiresAt: time.Now().Unix() - 100, OAuthBaseURL: s.URL, + } + m := newTestManager(t, ac) + if err := EnsureFreshToken(ac, m); err != nil { + t.Fatal(err) + } + if ac.AccessToken != "new-at" || ac.RefreshToken != "new-rt" { + t.Errorf("token not refreshed in memory: %+v", ac) + } + raw, _ := ioutil.ReadFile(".ucloud/credential.json") + if !strings.Contains(string(raw), "new-rt") { + t.Errorf("rotated refresh token not persisted: %s", raw) + } + }) + + t.Run("fresh token skips refresh", func(t *testing.T) { + before := refreshCalls + ac := &AggConfig{ + Profile: "p2", Active: true, BaseURL: DefaultBaseURL, Timeout: 15, MaxRetryTimes: intPtr(3), + AuthMode: AuthModeOAuth, AccessToken: "at", RefreshToken: "rt", + ExpiresAt: time.Now().Add(time.Hour).Unix(), OAuthBaseURL: s.URL, + } + m := newTestManager(t, ac) + if err := EnsureFreshToken(ac, m); err != nil { + t.Fatal(err) + } + if refreshCalls != before { + t.Error("fresh token must not hit /token") + } + }) +} + +// 跨 profile 凭据保护:进程 A(t0 加载 X/Y)刷新 Y 落盘时,不得用内存里的陈旧 X +// 覆盖他进程 B(t1)已轮换写盘的 X 凭据——否则 X 下次刷新必 invalid_grant(D3 旧 refresh_token 立即作废)。 +func TestRefreshAndSaveKeepsOtherProfilesRotatedTokens(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"y-new-at","refresh_token":"y-new-rt","expires_in":3600}`) + })) + defer s.Close() + + // t0:进程 A 加载两个 oauth profile(X 未过期,Y 已过期) + acX := &AggConfig{ + Profile: "px", Active: true, BaseURL: DefaultBaseURL, Timeout: 15, MaxRetryTimes: intPtr(3), + AuthMode: AuthModeOAuth, AccessToken: "x-old-at", RefreshToken: "x-old-rt", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + } + m := newTestManager(t, acX) + acY := &AggConfig{ + Profile: "py", BaseURL: DefaultBaseURL, Timeout: 15, MaxRetryTimes: intPtr(3), + AuthMode: AuthModeOAuth, AccessToken: "y-old-at", RefreshToken: "y-old-rt", + ExpiresAt: time.Now().Unix() - 100, OAuthBaseURL: s.URL, + } + if err := m.Append(acY); err != nil { + t.Fatal(err) + } + + // t1:模拟进程 B 刷新 X 并轮换 refresh_token,直接写盘(不经 A 的 manager) + raw, err := ioutil.ReadFile(".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + var creds []CredentialConfig + if err := json.Unmarshal(raw, &creds); err != nil { + t.Fatal(err) + } + for i := range creds { + if creds[i].Profile == "px" { + creds[i].AccessToken = "x-rotated-at" + creds[i].RefreshToken = "x-rotated-rt" + creds[i].ExpiresAt = time.Now().Add(2 * time.Hour).Unix() + } + } + out, err := json.Marshal(creds) + if err != nil { + t.Fatal(err) + } + if err := ioutil.WriteFile(".ucloud/credential.json", out, 0600); err != nil { + t.Fatal(err) + } + + // t2:进程 A 刷新 Y 并 Save + if err := EnsureFreshToken(acY, m); err != nil { + t.Fatal(err) + } + + diskX, err := readCredentialFromDisk(".ucloud/credential.json", "px") + if err != nil || diskX == nil { + t.Fatalf("reload px from disk failed: %v (%v)", diskX, err) + } + if diskX.AccessToken != "x-rotated-at" || diskX.RefreshToken != "x-rotated-rt" { + t.Errorf("process B's rotated X tokens were overwritten by A's stale copy: access=%s refresh=%s", + diskX.AccessToken, diskX.RefreshToken) + } + diskY, err := readCredentialFromDisk(".ucloud/credential.json", "py") + if err != nil || diskY == nil { + t.Fatalf("reload py from disk failed: %v (%v)", diskY, err) + } + if diskY.AccessToken != "y-new-at" || diskY.RefreshToken != "y-new-rt" { + t.Errorf("Y's refreshed tokens not persisted: access=%s refresh=%s", diskY.AccessToken, diskY.RefreshToken) + } +} + +// 并发刷新仅一次轮换(D3):两个并发 EnsureFreshToken 只允许打一次 /token +func TestConcurrentRefreshSingleRotation(t *testing.T) { + var mu sync.Mutex + refreshCalls := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + refreshCalls++ + n := refreshCalls + mu.Unlock() + time.Sleep(100 * time.Millisecond) // 放大竞争窗口 + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"access_token":"at-%d","refresh_token":"rt-%d","expires_in":3600}`, n, n) + })) + defer s.Close() + + ac := &AggConfig{ + Profile: "pc", Active: true, BaseURL: DefaultBaseURL, Timeout: 15, MaxRetryTimes: intPtr(3), + AuthMode: AuthModeOAuth, AccessToken: "old-at", RefreshToken: "old-rt", + ExpiresAt: time.Now().Unix() - 100, OAuthBaseURL: s.URL, + } + m := newTestManager(t, ac) + + // 模拟两个进程:各自持有独立的 AggConfig 副本与 manager 视图。 + // 注意必须用独立 manager(m2):若复用 m,ac2 的刷新结果不在 m.configs 中, + // Save() 不会落盘,磁盘重读看到的仍是旧凭据,测不出真实的跨进程行为。 + m2, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json") + if err != nil { + t.Fatal(err) + } + ac2, ok := m2.GetAggConfigByProfile("pc") + if !ok { + t.Fatal("profile pc not loaded by second manager") + } + + var wg sync.WaitGroup + errs := make([]error, 2) + wg.Add(2) + go func() { defer wg.Done(); errs[0] = EnsureFreshToken(ac, m) }() + go func() { defer wg.Done(); errs[1] = EnsureFreshToken(ac2, m2) }() + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Errorf("refresher %d failed: %v", i, err) + } + } + if refreshCalls != 1 { + t.Errorf("expect exactly 1 rotation, got %d", refreshCalls) + } +} diff --git a/cmd/internal/platform/oauth_test.go b/cmd/internal/platform/oauth_test.go new file mode 100644 index 0000000000..66ae353117 --- /dev/null +++ b/cmd/internal/platform/oauth_test.go @@ -0,0 +1,171 @@ +// base/oauth_test.go +package platform + +import ( + "os" + "strings" + "testing" + "time" +) + +// /dev/null 是字符设备但不是终端;cron/CI 常用 `ucloud xxx 5 { + return str[:beginChars] + strings.Repeat("*", 5) + str[(r+beginChars):] + } + return strings.Repeat("*", len(str)) +} + +// GetConfigDir 获取配置文件所在目录 +func GetConfigDir() string { + path := common.GetHomePath() + "/" + ConfigPath + if _, err := os.Stat(path); os.IsNotExist(err) { + err = os.MkdirAll(path, 0755) + if err != nil { + panic(err) + } + } + return path +} + +// HandleBizError 处理RetCode != 0 的业务异常 +func HandleBizError(resp response.Common) error { + format := "Something wrong. RetCode:%d. Message:%s\n" + LogError(fmt.Sprintf(format, resp.GetRetCode(), resp.GetMessage())) + return fmt.Errorf(format, resp.GetRetCode(), resp.GetMessage()) +} + +// HandleError 处理错误,业务错误 和 HTTP错误. The console copy goes to the global +// writer (stdout); product code uses ctx.HandleError → HandleErrorTo(stderr). +func HandleError(err error) { HandleErrorTo(out, err) } + +// HandleErrorTo is HandleError with a caller-chosen console writer w, so product +// commands can route errors to stderr and keep stdout machine-clean. +func HandleErrorTo(w io.Writer, err error) { + if uErr, ok := err.(uerr.Error); ok && uErr.Code() != 0 { + format := "Something wrong. RetCode:%d. Message:%s\n" + LogErrorTo(w, fmt.Sprintf(format, uErr.Code(), uErr.Message())) + } else { + LogErrorTo(w, fmt.Sprintf("%v", err)) + } +} + +// ParseError 解析错误为字符串 +func ParseError(err error) string { + if uErr, ok := err.(uerr.Error); ok && uErr.Code() != 0 { + format := "Something wrong. RetCode:%d. Message:%s" + message := uErr.Message() + if uErr.Code() == -1 || uErr.Code() == -2 { + message = "request timeout, retry later please" + } + return fmt.Sprintf(format, uErr.Code(), message) + } + return fmt.Sprintf("Error:%v", err) +} + +// PrintJSON 以JSON格式打印数据集合 +func PrintJSON(dataSet interface{}, out io.Writer) error { + bytes, err := json.MarshalIndent(dataSet, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(out, string(bytes)) + if err != nil { + return err + } + return nil +} + +// PrintTableS 简化版表格打印,无需传表头,根据结构体反射解析 +func PrintTableS(dataSet interface{}) { + dataSetVal := reflect.ValueOf(dataSet) + fieldNameList := make([]string, 0) + if dataSetVal.Len() > 0 { + elemType := dataSetVal.Index(0).Type() + for i := 0; i < elemType.NumField(); i++ { + fieldNameList = append(fieldNameList, elemType.Field(i).Name) + } + } + if kind := dataSetVal.Kind(); kind == reflect.Slice || kind == reflect.Array { + displaySlice(dataSetVal, fieldNameList) + } else { + panic(fmt.Sprintf("Internal error, PrintTableS expect array or slice, accept %T", dataSet)) + } +} + +// PrintList 打印表格或者JSON +func PrintList(dataSet interface{}, out io.Writer) { + if Global.JSON { + PrintJSON(dataSet, out) + } else { + PrintTableS(dataSet) + } +} + +// PrintDescribe 打印详情 +func PrintDescribe(attrs []DescribeTableRow, json bool) { + if json { + PrintJSON(attrs, os.Stdout) + } else { + for _, attr := range attrs { + fmt.Println(attr.Attribute) + fmt.Println(attr.Content) + fmt.Println() + } + } +} + +// PrintTable 以表格方式打印数据集合 +func PrintTable(dataSet interface{}, fieldList []string) { + dataSetVal := reflect.ValueOf(dataSet) + switch dataSetVal.Kind() { + case reflect.Slice, reflect.Array: + displaySlice(dataSetVal, fieldList) + default: + panic(fmt.Sprintf("PrintTable expect array,slice or map, accept %T", dataSet)) + } +} + +func displaySlice(listVal reflect.Value, fieldList []string) { + showFieldMap := make(map[string]int) + for _, field := range fieldList { + showFieldMap[field] = len([]rune(field)) + } + rowList := make([]map[string]interface{}, 0) + for i := 0; i < listVal.Len(); i++ { + elemVal := listVal.Index(i) + elemType := elemVal.Type() + var rows []map[string]interface{} + for j := 0; j < elemVal.NumField(); j++ { + field := elemVal.Field(j) + fieldName := elemType.Field(j).Name + if _, ok := showFieldMap[fieldName]; ok { + if field.Kind() == reflect.Ptr { + field = field.Elem() + } + text := fmt.Sprintf("%v", field.Interface()) + cells := strings.Split(text, "\n") + for i, cell := range cells { + width := calcWidth(cell) + if showFieldMap[fieldName] < width { + showFieldMap[fieldName] = width + } + if len(rows) == i { + rows = append(rows, make(map[string]interface{})) + } + rows[i][fieldName] = cell + } + } + } + rowList = append(rowList, rows...) + } + printTable(rowList, fieldList, showFieldMap) +} + +func printTable(rowList []map[string]interface{}, fieldList []string, fieldWidthMap map[string]int) { + //打印表头 + for _, field := range fieldList { + tmpl := "%-" + strconv.Itoa(fieldWidthMap[field]+GAP) + "s" + fmt.Printf(tmpl, field) + } + if len(fieldList) != 0 { + fmt.Printf("\n") + } + + //打印数据 + for _, row := range rowList { + for _, field := range fieldList { + cutWidth := calcCutWidth(fmt.Sprintf("%v", row[field])) + tmpl := "%-" + strconv.Itoa(fieldWidthMap[field]-cutWidth+GAP) + "v" + if row[field] != nil { + fmt.Printf(tmpl, row[field]) + } else { + fmt.Printf(tmpl, "") + } + } + fmt.Printf("\n") + } +} + +// DescribeTableRow 详情表格通用表格行 +type DescribeTableRow struct { + Attribute string + Content string +} + +func calcCutWidth(text string) int { + set := []*unicode.RangeTable{unicode.Han, unicode.Punct} + width := 0 + for _, r := range text { + if unicode.IsOneOf(set, r) && r > unicode.MaxLatin1 { + width++ + } + } + return width +} + +func calcWidth(text string) int { + set := []*unicode.RangeTable{unicode.Han, unicode.Punct} + width := 0 + for _, r := range text { + if unicode.IsOneOf(set, r) && r > unicode.MaxLatin1 { + width += 2 + } else { + width++ + } + } + return width +} + +// RegionLabel regionlable +var RegionLabel = map[string]string{ + "cn-bj1": "Beijing1", + "cn-bj2": "Beijing2", + "cn-sh2": "Shanghai2", + "cn-gd": "Guangzhou", + "cn-qz": "Quanzhou", + "hk": "Hongkong", + "us-ca": "LosAngeles", + "us-ws": "Washington", + "ge-fra": "Frankfurt", + "th-bkk": "Bangkok", + "kr-seoul": "Seoul", + "sg": "Singapore", + "tw-kh": "Kaohsiung", + "rus-mosc": "Moscow", + "jpn-tky": "Tokyo", + "tw-tp": "TaiPei", + "uae-dubai": "Dubai", + "idn-jakarta": "Jakarta", + "ind-mumbai": "Bombay", + "bra-saopaulo": "SaoPaulo", + "uk-london": "London", + "afr-nigeria": "Lagos", +} + +// PickResourceID uhost-xxx/uhost-name => uhost-xxx +func PickResourceID(str string) string { + if strings.Index(str, "/") > -1 { + return strings.SplitN(str, "/", 2)[0] + } + return str +} + +// WriteJSONFileAtomic 原子写 json 文件:同目录临时文件 + Sync + Rename(D3;对照 botocore#3213 损坏事故) +func WriteJSONFileAtomic(list interface{}, filePath string) error { + byts, err := json.Marshal(list) + if err != nil { + return err + } + dir := filepath.Dir(filePath) + tmp, err := ioutil.TempFile(dir, "."+filepath.Base(filePath)+".tmp") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) // rename 成功后此句为 no-op + if err := tmp.Chmod(LocalFileMode); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(byts); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), filePath) +} + +// Confirm 二次确认 +func Confirm(yes bool, text string) bool { + if yes { + return true + } + sure, err := ui.Prompt(text) + if err != nil { + LogError(err.Error()) + return false + } + return sure +} + +func curGoroutineID() int64 { + var ( + buf [64]byte + n = runtime.Stack(buf[:], false) + stk = strings.TrimPrefix(string(buf[:n]), "goroutine ") + ) + + idField := strings.Fields(stk)[0] + id, err := strconv.Atoi(idField) + if err != nil { + panic(fmt.Errorf("can not get goroutine id: %v", err)) + } + + return int64(id) +} + +func getDefaultRegion(cookie, csrfToken string) (string, string, error) { + cfg := &AggConfig{ + Cookie: cookie, + BaseURL: DefaultBaseURL, + CSRFToken: csrfToken, + Timeout: DefaultTimeoutSec, + MaxRetryTimes: sdk.Int(DefaultMaxRetryTimes), + } + client, err := newUAccountClientForConfig(cfg) + if err != nil { + return "", "", err + } + req := client.NewGetRegionRequest() + resp, err := client.GetRegion(req) + if err != nil { + return "", "", err + } + for _, r := range resp.Regions { + if r.IsDefault { + return r.Region, r.Zone, nil + } + } + return "", "", fmt.Errorf("default region not found") +} + +func getDefaultProject(cookie, csrfToken string) (string, string, error) { + cfg := &AggConfig{ + Cookie: cookie, + BaseURL: DefaultBaseURL, + CSRFToken: csrfToken, + Timeout: DefaultTimeoutSec, + MaxRetryTimes: sdk.Int(DefaultMaxRetryTimes), + } + client, err := newUAccountClientForConfig(cfg) + if err != nil { + return "", "", err + } + + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) + if err != nil { + return "", "", err + } + for _, project := range resp.ProjectSet { + if project.IsDefault == true { + return project.ProjectId, project.ProjectName, nil + } + } + return "", "", fmt.Errorf("default project not found") +} + +func newUAccountClientForConfig(cfg *AggConfig) (*uaccount.UAccountClient, error) { + sdkConfig, credConfig, err := BuildClientRuntime(cfg) + client := uaccount.NewClient(sdkConfig, BuildCredentialFrom(credConfig)) + AttachHandlersWith(client, credConfig, cfg, AggConfigListIns) + return client, err +} diff --git a/cmd/internal/platform/util_test.go b/cmd/internal/platform/util_test.go new file mode 100644 index 0000000000..38a21ff6f9 --- /dev/null +++ b/cmd/internal/platform/util_test.go @@ -0,0 +1,38 @@ +package platform + +import ( + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteJSONFileAtomic(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cred.json") + if err := WriteJSONFileAtomic([]map[string]string{{"k": "v1"}}, path); err != nil { + t.Fatal(err) + } + raw, err := ioutil.ReadFile(path) + if err != nil || !strings.Contains(string(raw), "v1") { + t.Fatalf("content wrong: %s %v", raw, err) + } + fi, _ := os.Stat(path) + if fi.Mode().Perm() != 0600 { + t.Errorf("perm = %v, want 0600", fi.Mode().Perm()) + } + // 覆盖写 + if err := WriteJSONFileAtomic([]map[string]string{{"k": "v2"}}, path); err != nil { + t.Fatal(err) + } + raw, _ = ioutil.ReadFile(path) + if !strings.Contains(string(raw), "v2") { + t.Errorf("overwrite failed: %s", raw) + } + // 同目录无残留临时文件 + entries, _ := ioutil.ReadDir(dir) + if len(entries) != 1 { + t.Errorf("temp files left behind: %v", entries) + } +} diff --git a/cmd/internal/version/version.go b/cmd/internal/version/version.go new file mode 100644 index 0000000000..624e471e7e --- /dev/null +++ b/cmd/internal/version/version.go @@ -0,0 +1,9 @@ +package version + +import "fmt" + +var Version = "dev" + +func UserAgent() string { + return fmt.Sprintf("UCloud-CLI/%s", Version) +} diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 0000000000..f408833f8a --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,324 @@ +// cmd/login.go +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/cmd/internal/platform" +) + +const loginLongHelp = `Log in to UCloud via your browser (OAuth authorization code flow). + +How it works (default): + 1. ucloud-cli opens your browser at the UCloud authorization page. + 2. You log in and approve. The browser is redirected to a local callback + that ucloud-cli is listening on — captured automatically, no copy-paste. + 3. ucloud-cli exchanges the code for tokens, saves them to + ~/.ucloud/credential.json (0600), and auto-configures the default + region/zone/project for this profile. + +Headless / SSH: pass --no-browser. ucloud-cli prints the authorization URL; +open it on any device, log in, then copy the FULL callback URL from the +address bar and paste it back into the terminal. + +Tokens are valid for about 1 hour and renew silently via the refresh token. +OAuth login targets interactive human use. For scripts and CI/CD, use an +AK/SK profile instead: ucloud config --profile --public-key ... --private-key ...` + +// oauthHelpTmpl 在全局 helpTmpl(不渲染 Long)前面补上 Long 段,仅作用于 login/logout +const oauthHelpTmpl = `{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}} + +{{end}}` + helpTmpl + +// NewCmdAuth ucloud auth 命令组:浏览器登录相关子命令 +func NewCmdAuth() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Authenticate ucloud-cli via browser (OAuth)", + Long: "Browser-based OAuth authentication for ucloud-cli. Subcommands: login, logout", + } + cmd.SetHelpTemplate(oauthHelpTmpl) + cmd.AddCommand(NewCmdLogin()) + cmd.AddCommand(NewCmdLogout()) + return cmd +} + +// NewCmdLogin ucloud auth login +func NewCmdLogin() *cobra.Command { + var noBrowser bool + var oauthBaseURL string + cmd := &cobra.Command{ + Use: "login", + Short: "Log in to UCloud via browser (OAuth)", + Long: loginLongHelp, + Args: cobra.NoArgs, + Example: "ucloud auth login\nucloud auth login --no-browser", + Run: func(cmd *cobra.Command, args []string) { + runLogin(noBrowser, oauthBaseURL) + }, + } + cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Print the authorization URL instead of opening a browser (for headless/SSH environments)") + cmd.Flags().StringVar(&oauthBaseURL, "oauth-base-url", "", "Override the OAuth authorization server URL (for non-default environments; persisted to the profile)") + cmd.SetHelpTemplate(oauthHelpTmpl) + return cmd +} + +// resolveLoginOAuthBase 决定登录使用的 OAuth 域:--oauth-base-url flag 最优先, +// 给定时写回 cfg.OAuthBaseURL 以便登录成功后随 profile 持久化(后续刷新沿用); +// 未给定则回退到 profile 配置或内置默认(GetOAuthBaseURL)。 +func resolveLoginOAuthBase(cfg *platform.AggConfig, flagVal string) (string, error) { + if flagVal != "" { + cfg.OAuthBaseURL = strings.TrimSuffix(flagVal, "/") + } + oauthBase, err := platform.GetOAuthBaseURL(cfg) + if err == nil && cfg.OAuthBaseURL == "" { + cfg.OAuthBaseURL = oauthBase + } + return oauthBase, err +} + +func runLogin(noBrowser bool, oauthBaseURL string) { + // AP-1:非 TTY fail-fast + if !platform.IsStdinTTY() { + fmt.Fprintln(os.Stderr, "'ucloud auth login' requires an interactive terminal. For automation/CI, use an AK/SK profile: ucloud config --profile --public-key --private-key ") + os.Exit(1) + } + + cfg := platform.ConfigIns + oauthBase, err := resolveLoginOAuthBase(cfg, oauthBaseURL) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + state, err := platform.GenerateState() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + var code, redirectURI string + if noBrowser { + code, redirectURI = runLoginManual(oauthBase, state) + } else { + code, redirectURI = runLoginAuto(oauthBase, state) + } + + tr, err := platform.ExchangeToken(oauthBase, redirectURI, code) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + // D5:已有 AK/SK 时打印一行告知 + 回退指引 + if cfg.PublicKey != "" || cfg.PrivateKey != "" { + fmt.Printf("Note: profile '%s' had AK/SK configured; it now switches to OAuth (auth_mode=oauth). AK/SK keys are kept; to switch back, run 'ucloud auth logout' then 'ucloud init'\n", cfg.Profile) + } + + platform.ApplyTokenResponse(cfg, tr) + cfg.Active = true + if _, ok := platform.AggConfigListIns.GetAggConfigByProfile(cfg.Profile); ok { + err = platform.AggConfigListIns.UpdateAggConfig(cfg) + } else { + err = platform.AggConfigListIns.Append(cfg) + } + if err != nil { + fmt.Fprintf(os.Stderr, "save credential failed: %v\n", err) + os.Exit(1) + } + + // AP-2:首登补链——自动配置 region/zone/project(Bearer 调用,复用 init 逻辑) + if cfg.Region == "" || cfg.Zone == "" { + region, rerr := fetchRegionWithConfig(cfg) + if rerr != nil { + fmt.Printf("Warning: fetch default region failed (%v). Set it later: ucloud config update --profile %s --region --zone \n", rerr, cfg.Profile) + } else { + cfg.Region = region.DefaultRegion + cfg.Zone = region.DefaultZone + fmt.Printf("Configured default region:%s zone:%s\n", cfg.Region, cfg.Zone) + } + } + // 既有 project_id 也要用新账号的项目列表校验:跨账号/跨站点遗留的 project_id + // 若原样保留,后续业务命令全部 RetCode 292 "Project not exists" + if projects, perr := fetchProjectListWithConfig(cfg); perr != nil { + fmt.Printf("Warning: fetch project list failed (%v). Set it later: ucloud config update --profile %s --project-id \n", perr, cfg.Profile) + } else if id, notice, rerr := resolveLoginProject(cfg.ProjectID, projects); rerr != nil { + fmt.Printf("Warning: resolve default project failed (%v). Set it later: ucloud config update --profile %s --project-id \n", rerr, cfg.Profile) + } else { + cfg.ProjectID = id + if notice != "" { + fmt.Println(notice) + } + } + if err := platform.AggConfigListIns.UpdateAggConfig(cfg); err != nil { + fmt.Printf("Warning: saving default region/project failed (%v). Set them later: ucloud config update --profile %s --region --zone --project-id \n", err, cfg.Profile) + } + + // ⑥ 输出 email + 过期时间(id_token 仅解析不落盘) + until := time.Unix(cfg.ExpiresAt, 0).Format("15:04") + if email, eerr := platform.ParseIDTokenEmail(tr.IDToken); eerr == nil && email != "" { + fmt.Printf("Logged in as %s, token valid until %s\n", email, until) + } else { + fmt.Printf("Logged in, token valid until %s\n", until) + } +} + +// resolveLoginProject 决定登录后 profile 应使用的 project(AP-2 的校验补丁): +// existing 为空 → 选账号默认项目(首登补链);existing 在列表内 → 保持不变,无提示; +// existing 不在列表内(跨账号/跨站点遗留)→ 切到默认项目并返回提示。 +// 返回 (projectID, notice);notice 非空时调用方原样打印。列表无默认项目时返回 errNoDefaultProject。 +func resolveLoginProject(existing string, projects []uaccount.ProjectListInfo) (string, string, error) { + var defaultID, defaultName string + for _, p := range projects { + if existing != "" && p.ProjectId == existing { + return existing, "", nil + } + if p.IsDefault { + defaultID, defaultName = p.ProjectId, p.ProjectName + } + } + if defaultID == "" { + return "", "", errNoDefaultProject + } + if existing == "" { + return defaultID, fmt.Sprintf("Configured default project:%s %s", defaultID, defaultName), nil + } + notice := fmt.Sprintf("Existing project '%s' does not belong to this account; switching to default project '%s' %s", existing, defaultID, defaultName) + return defaultID, notice, nil +} + +// loginCallbackTimeout 自动捕获的等待上限;超时回退到手工粘贴 +const loginCallbackTimeout = 3 * time.Minute + +// runLoginManual --no-browser 手工模式:分配一个 >=1024 端口(仅取号,立即释放 listener), +// 打印 URL,从 stdin 读回调 URL。返回 (code, redirectURI);出错时直接退出。 +func runLoginManual(oauthBase, state string) (string, string) { + ln, port, err := allocateLoopbackListener() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + ln.Close() + redirectURI := platform.BuildLoopbackRedirectURI(port) + authorizeURL := platform.BuildAuthorizeURL(oauthBase, redirectURI, state) + + fmt.Println("Logging in via browser (manual paste). 3 steps:") + fmt.Println(" 1. Open the URL below and finish login & authorization.") + fmt.Println(" 2. The browser will be redirected to a localhost page that CANNOT") + fmt.Printf(" open (%s?...). THIS IS EXPECTED.\n", redirectURI) + fmt.Println(" 3. Copy the FULL URL from the address bar and paste it here.") + fmt.Println() + fmt.Printf("Open this URL in your browser:\n\n %s\n\n", authorizeURL) + + code, err := readCallbackCode(state) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return code, redirectURI +} + +// runLoginAuto 默认模式:起本地回调 server 自动捕获 code,超时回退手工粘贴。 +// 返回 (code, redirectURI);遇到主动错误(拒绝授权/state 不匹配)直接退出。 +func runLoginAuto(oauthBase, state string) (string, string) { + ln, port, err := allocateLoopbackListener() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + redirectURI := platform.BuildLoopbackRedirectURI(port) + authorizeURL := platform.BuildAuthorizeURL(oauthBase, redirectURI, state) + + srv, ch := startCallbackServer(ln, state) + + fmt.Println("A browser window will open; finish the login there and return here — no copy-paste needed.") + fmt.Printf("If it does not open, visit:\n\n %s\n\n", authorizeURL) + openbrowser(authorizeURL) + + select { + case res := <-ch: + srv.Close() + if res.err != nil { + fmt.Fprintln(os.Stderr, res.err) + os.Exit(1) + } + return res.code, redirectURI + case <-time.After(loginCallbackTimeout): + srv.Close() + fmt.Fprintln(os.Stderr, "Automatic capture timed out. Paste the callback URL here as a fallback:") + code, err := readCallbackCode(state) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return code, redirectURI + } +} + +// readCallbackCode 读回调 URL:容忍折行(粘贴的多行一次到达时合并),允许重试 3 次 +func readCallbackCode(state string) (string, error) { + reader := bufio.NewReader(os.Stdin) + for attempt := 1; attempt <= 3; attempt++ { + fmt.Print("Paste the full callback URL here: ") + raw, err := readWrappedLine(reader) + if err != nil { + return "", fmt.Errorf("read input failed: %v", err) + } + code, perr := platform.ParseCallbackURL(raw, state) + if perr == nil { + return code, nil + } + fmt.Fprintln(os.Stderr, perr) + } + return "", fmt.Errorf("too many invalid inputs. Run 'ucloud auth login' again") +} + +// readWrappedLine 读一行;若粘贴内容因终端折行带来多行(缓冲区中仍有数据),继续读完合并 +func readWrappedLine(r *bufio.Reader) (string, error) { + line, err := r.ReadString('\n') + if err != nil && line == "" { + return "", err + } + for r.Buffered() > 0 { + next, nerr := r.ReadString('\n') + line += next + if nerr != nil { + break + } + } + return line, nil +} + +// NewCmdLogout ucloud auth logout +func NewCmdLogout() *cobra.Command { + cmd := &cobra.Command{ + Use: "logout", + Short: "Log out: remove local OAuth tokens of the current profile", + Long: "Log out: remove local OAuth tokens (access_token/refresh_token) of the current profile from ~/.ucloud/credential.json", + Args: cobra.NoArgs, + Example: "ucloud auth logout", + Run: func(cmd *cobra.Command, args []string) { + cfg := platform.ConfigIns + if cfg.AuthMode != platform.AuthModeOAuth && cfg.AccessToken == "" { + fmt.Printf("Profile '%s' is not logged in via OAuth, nothing to do\n", cfg.Profile) + return + } + clearOAuthState(cfg) + if err := platform.AggConfigListIns.UpdateAggConfig(cfg); err != nil { + platform.HandleError(err) + return + } + // AP-4:不加服务端有效期提示(用户裁定,spec 风险 #5) + fmt.Printf("Logged out: local tokens of profile '%s' removed\n", cfg.Profile) + }, + } + cmd.SetHelpTemplate(oauthHelpTmpl) + return cmd +} diff --git a/cmd/login_test.go b/cmd/login_test.go new file mode 100644 index 0000000000..63744d93ff --- /dev/null +++ b/cmd/login_test.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/cmd/internal/platform" +) + +// resolveLoginOAuthBase 决定登录使用的 OAuth 域:--oauth-base-url flag 最优先(去尾斜杠后 +// 写回 cfg.OAuthBaseURL 以便登录成功后随 profile 持久化),未给定则回退到 profile 配置或内置默认。 +func TestResolveLoginOAuthBase(t *testing.T) { + // case 1: 给了 flag → 去尾斜杠后返回,并写回 cfg(证明持久化接线) + cfg := &platform.AggConfig{} + got, err := resolveLoginOAuthBase(cfg, "https://oauth-global.example/") + if err != nil { + t.Fatalf("flag given: unexpected error: %v", err) + } + if got != "https://oauth-global.example" { + t.Errorf("flag given: got = %q, want trailing slash trimmed", got) + } + if cfg.OAuthBaseURL != "https://oauth-global.example" { + t.Errorf("flag given: cfg.OAuthBaseURL = %q, want it set to trimmed flag value", cfg.OAuthBaseURL) + } + + // case 2: flag 为空,cfg 预置 → 返回 profile 值,cfg 不变 + cfg = &platform.AggConfig{OAuthBaseURL: "https://oauth-profile.example"} + got, err = resolveLoginOAuthBase(cfg, "") + if err != nil { + t.Fatalf("flag empty, cfg preset: unexpected error: %v", err) + } + if got != "https://oauth-profile.example" { + t.Errorf("flag empty, cfg preset: got = %q, want profile value", got) + } + if cfg.OAuthBaseURL != "https://oauth-profile.example" { + t.Errorf("flag empty, cfg preset: cfg.OAuthBaseURL = %q, want unchanged", cfg.OAuthBaseURL) + } + + // case 3: flag 为空,cfg 为空 → 返回内置默认,并写回 cfg 以便随 profile 显式落盘 + cfg = &platform.AggConfig{} + got, err = resolveLoginOAuthBase(cfg, "") + if err != nil { + t.Fatalf("flag empty, cfg empty: unexpected error: %v", err) + } + want, _ := platform.GetOAuthBaseURL(&platform.AggConfig{}) + if want == "" { + t.Fatal("flag empty, cfg empty: built-in default is empty, test precondition broken") + } + if got != want { + t.Errorf("flag empty, cfg empty: got = %q, want built-in default %q", got, want) + } + if cfg.OAuthBaseURL != want { + t.Errorf("flag empty, cfg empty: cfg.OAuthBaseURL = %q, want it set to built-in default %q for explicit persist", cfg.OAuthBaseURL, want) + } +} + +// 回归:auth login 后已有 project_id 必须用新账号的项目列表校验。 +// 跨账号/跨站点遗留的 project_id 若原样保留,后续业务命令全部 RetCode 292 "Project not exists"。 +func TestResolveLoginProject(t *testing.T) { + projects := []uaccount.ProjectListInfo{ + {ProjectId: "org-111", ProjectName: "Default", IsDefault: true}, + {ProjectId: "org-222", ProjectName: "Dev"}, + } + + // case 1: project_id 为空 → 选账号默认项目(原有首登补链行为) + id, notice, err := resolveLoginProject("", projects) + if err != nil { + t.Fatalf("empty existing: unexpected error: %v", err) + } + if id != "org-111" { + t.Errorf("empty existing: id = %q, want default org-111", id) + } + if !strings.Contains(notice, "org-111") || !strings.Contains(notice, "Default") { + t.Errorf("empty existing: notice = %q, want it to mention default project id and name", notice) + } + + // case 2: project_id 属于当前账号 → 保持不变且无提示(AP-2 不覆写用户设置) + id, notice, err = resolveLoginProject("org-222", projects) + if err != nil { + t.Fatalf("existing in list: unexpected error: %v", err) + } + if id != "org-222" { + t.Errorf("existing in list: id = %q, want kept org-222", id) + } + if notice != "" { + t.Errorf("existing in list: notice = %q, want empty (no behavior change)", notice) + } + + // case 3: project_id 不属于当前账号 → 切到默认项目并给出明确提示 + id, notice, err = resolveLoginProject("org-stale", projects) + if err != nil { + t.Fatalf("existing not in list: unexpected error: %v", err) + } + if id != "org-111" { + t.Errorf("existing not in list: id = %q, want default org-111", id) + } + if !strings.Contains(notice, "org-stale") || !strings.Contains(notice, "org-111") { + t.Errorf("existing not in list: notice = %q, want it to mention stale id and new default", notice) + } + + // case 4: 列表里没有默认项目 → 返回错误(调用方仅告警,不中断登录) + noDefault := []uaccount.ProjectListInfo{{ProjectId: "org-333", ProjectName: "Solo"}} + if _, _, err = resolveLoginProject("org-stale", noDefault); err == nil { + t.Error("no default project: want error, got nil") + } +} diff --git a/cmd/mysql.go b/cmd/mysql.go deleted file mode 100644 index 0d257e56e1..0000000000 --- a/cmd/mysql.go +++ /dev/null @@ -1,896 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - "strconv" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-sdk-go/services/udb" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/model/status" -) - -var dbVersionList = []string{"mysql-5.1", "mysql-5.5", "mysql-5.6", "mysql-5.7", "percona-5.5", "percona-5.6", "percona-5.7", "mariadb-10.0"} -var dbDiskTypeList = []string{"normal", "sata_ssd", "pcie_ssd"} - -var poller = base.NewSpoller(describeUdbByID, base.Cxt.GetWriter()) - -//NewCmdMysql ucloud mysql -func NewCmdMysql() *cobra.Command { - cmd := &cobra.Command{ - Use: "mysql", - Short: "Manipulate MySQL on UCloud platform", - Long: "Manipulate MySQL on UCloud platform", - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdMysqlDB(out)) - cmd.AddCommand(NewCmdUDBConf()) - cmd.AddCommand(NewCmdUDBBackup()) - cmd.AddCommand(NewCmdUDBLog()) - return cmd -} - -//NewCmdMysqlDB ucloud mysql db -func NewCmdMysqlDB(out io.Writer) *cobra.Command { - cmd := &cobra.Command{ - Use: "db", - Short: "Manange MySQL instances", - Long: "Manange MySQL instances", - } - - cmd.AddCommand(NewCmdUDBList(out)) - cmd.AddCommand(NewCmdMysqlCreate(out)) - cmd.AddCommand(NewCmdUDBDelete(out)) - cmd.AddCommand(NewCmdUDBStart(out)) - cmd.AddCommand(NewCmdUDBStop(out)) - cmd.AddCommand(NewCmdUDBRestart(out)) - cmd.AddCommand(NewCmdUDBResize(out)) - cmd.AddCommand(NewCmdUDBRestore(out)) - cmd.AddCommand(NewCmdUDBResetPassword(out)) - cmd.AddCommand(NewCmdUDBCreateSlave(out)) - cmd.AddCommand(NewCmdUDBPromoteSlave(out)) - // cmd.AddCommand(NewCmdUDBPromoteToHA(out)) - - return cmd -} - -//NewCmdMysqlCreate ucloud mysql create -func NewCmdMysqlCreate(out io.Writer) *cobra.Command { - var confID, diskType string - var backupID int - var async bool - req := base.BizClient.NewCreateUDBInstanceRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create MySQL instance on UCloud platform", - Long: "Create MySQL instance on UCloud platform", - Run: func(c *cobra.Command, args []string) { - confID = base.PickResourceID(confID) - id, err := strconv.Atoi(confID) - if err != nil { - base.HandleError(err) - return - } - req.ParamGroupId = &id - if len(*req.Name) < 6 { - fmt.Fprintln(out, "Error, length of name shoud be larger than 5") - return - } - if *req.DiskSpace > 3000 || *req.DiskSpace < 20 { - fmt.Fprintln(out, "Error, disk-size-gb should be between 20 and 3000") - return - } - if *req.MemoryLimit < 1 || *req.MemoryLimit > 128 { - fmt.Fprintln(out, "Error, memory-size-gb should be between 1 and 128") - return - } - if backupID != -1 { - req.BackupId = &backupID - } - *req.MemoryLimit = *req.MemoryLimit * 1000 - switch diskType { - case "normal": - req.UseSSD = sdk.Bool(false) - case "sata_ssd": - req.UseSSD = sdk.Bool(true) - req.SSDType = sdk.String("SATA") - case "pcie_ssd": - req.UseSSD = sdk.Bool(true) - req.SSDType = sdk.String("PCI-E") - default: - if diskType != "" { - req.UseSSD = sdk.Bool(true) - req.SSDType = sdk.String(diskType) - } - } - resp, err := base.BizClient.CreateUDBInstance(req) - if err != nil { - base.HandleError(err) - return - } - text := fmt.Sprintf("udb[%s] is initializing", resp.DBId) - if async { - fmt.Fprintf(out, "udb[%s] is initializing\n", resp.DBId) - } else { - poller.Spoll(resp.DBId, text, []string{status.UDB_RUNNING, status.UDB_FAIL}) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - req.DBTypeId = flags.String("version", "", "Required. Version of udb instance") - req.Name = flags.String("name", "", "Required. Name of udb instance to create, at least 6 letters") - flags.StringVar(&confID, "conf-id", "", "Required. ConfID of configuration. see 'ucloud mysql conf list'") - req.AdminUser = flags.String("admin-user-name", "root", "Optional. Name of udb instance's administrator") - req.AdminPassword = flags.String("password", "", "Required. Password of udb instance's administrator") - flags.IntVar(&backupID, "backup-id", -1, "Optional. BackupID of the backup which the newly created UDB instance will recover from if specified. See 'ucloud mysql backup list'") - req.Port = flags.Int("port", 3306, "Optional. Port of udb instance") - flags.StringVar(&diskType, "disk-type", "", "Optional. Setting this flag means using SSD disk. Accept values: 'normal','sata_ssd','pcie_ssd'") - req.DiskSpace = flags.Int("disk-size-gb", 20, "Optional. Disk size of udb instance. From 20 to 3000 according to memory size. Unit GB") - req.MemoryLimit = flags.Int("memory-size-gb", 1, "Optional. Memory size of udb instance. From 1 to 128. Unit GB") - req.InstanceMode = flags.String("mode", "Normal", "Optional. Mode of udb instance. Normal or HA, HA means high-availability. Both the normal and high-availability versions can create master-slave synchronization for data redundancy and read/write separation. The high-availability version provides a dual-master hot standby architecture to avoid database unavailability due to downtime or hardware failure. One more thing. It does better job for master-slave synchronization and disaster recovery using the InnoDB engine") - req.VPCId = flags.String("vpc-id", "", "Optional. Resource ID of VPC which the UDB to create belong to. See 'ucloud vpc list'") - req.SubnetId = flags.String("subnet-id", "", "Optional. Resource ID of subnet that the UDB to create belong to. See 'ucloud subnet list'") - flags.BoolVar(&async, "async", false, "Optional. Do not wait for the long-running operation to finish.") - bindChargeType(req, flags) - bindQuantity(req, flags) - - flags.SetFlagValues("version", dbVersionList...) - flags.SetFlagValues("disk-type", dbDiskTypeList...) - flags.SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("subnet-id", func() []string { - return getAllSubnetIDNames(*req.VPCId, *req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("conf-id", func() []string { - return getConfIDList(*req.DBTypeId, *req.ProjectId, *req.Region, *req.Zone) - }) - - cmd.MarkFlagRequired("version") - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("password") - cmd.MarkFlagRequired("conf-id") - return cmd -} - -//UDBMysqlRow 表格行 -type UDBMysqlRow struct { - Name string - ResourceID string - Role string - Status string - Config string - Mode string - DiskType string - IP string - Group string - Zone string - VPC string - Subnet string - // CreateTime string -} - -//NewCmdUDBList ucloud udb list -func NewCmdUDBList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeUDBInstanceRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List MySQL instances", - Long: "List MySQL instances", - Run: func(c *cobra.Command, args []string) { - if *req.DBId != "" { - *req.DBId = base.PickResourceID(*req.DBId) - } - resp, err := base.BizClient.DescribeUDBInstance(req) - if err != nil { - base.HandleError(err) - return - } - list := []UDBMysqlRow{} - for _, ins := range resp.DataSet { - row := UDBMysqlRow{} - row.Name = ins.Name - row.Zone = ins.Zone - row.Role = ins.Role - row.ResourceID = ins.DBId - row.Group = ins.Tag - row.VPC = ins.VPCId - row.Subnet = ins.SubnetId - row.IP = ins.VirtualIP - row.Mode = ins.InstanceMode - row.DiskType = ins.InstanceType - row.Status = ins.State - row.Config = fmt.Sprintf("%s|%dG|%dG", ins.DBTypeId, ins.MemoryLimit/1000, ins.DiskSpace) - list = append(list, row) - for _, slave := range ins.DataSet { - row := UDBMysqlRow{} - row.Name = slave.Name - row.Zone = slave.Zone - row.Role = fmt.Sprintf("\u2b91 %s", slave.Role) - row.ResourceID = slave.DBId - row.Group = slave.Tag - row.VPC = slave.VPCId - row.Subnet = slave.SubnetId - row.IP = slave.VirtualIP - row.Mode = slave.InstanceMode - row.DiskType = slave.InstanceType - row.Config = fmt.Sprintf("%s|%dG|%dG", slave.DBTypeId, slave.MemoryLimit/1000, slave.DiskSpace) - row.Status = slave.State - list = append(list, row) - } - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.DBId = flags.String("udb-id", "", "Optional. List the specified mysql") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - bindLimit(req, flags) - bindOffset(req, flags) - req.IncludeSlaves = flags.Bool("include-slaves", false, "Optional. When specifying the udb-id, whether to display its slaves together. Accept values:true, false") - req.ClassType = sdk.String("sql") - - flags.SetFlagValues("include-slaves", "true", "false") - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "sql", *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -//NewCmdUDBDelete ucloud udb delete -func NewCmdUDBDelete(out io.Writer) *cobra.Command { - var idNames []string - var yes bool - req := base.BizClient.NewDeleteUDBInstanceRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete MySQL instances by udb-id", - Long: "Delete MySQL instances by udb-id", - Run: func(c *cobra.Command, args []string) { - ok := base.Confirm(yes, "Are you sure you want to delete the udb(s)?") - if !ok { - return - } - for _, idname := range idNames { - id := base.PickResourceID(idname) - any, err := describeUdbByID(id) - if err != nil { - base.HandleError(err) - continue - } - req.DBId = &id - ins, ok := any.(*udb.UDBInstanceSet) - if ok && ins.State == status.UDB_RUNNING { - stopReq := base.BizClient.NewStopUDBInstanceRequest() - stopReq.ProjectId = req.ProjectId - stopReq.Region = req.Region - stopReq.Zone = req.Zone - stopReq.DBId = req.DBId - stopUdbIns(stopReq, false, out) - } - _, err = base.BizClient.DeleteUDBInstance(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "udb[%s] deleted\n", idname) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to delete") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.") - - cmd.MarkFlagRequired("udb-id") - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "", *req.ProjectId, *req.Region, *req.Zone) - }) - return cmd -} - -//NewCmdUDBStop ucloud udb stop -func NewCmdUDBStop(out io.Writer) *cobra.Command { - var idNames []string - var async bool - req := base.BizClient.NewStopUDBInstanceRequest() - cmd := &cobra.Command{ - Use: "stop", - Short: "Stop MySQL instances by udb-id", - Long: "Stop MySQL instances by udb-id", - Run: func(c *cobra.Command, args []string) { - for _, idname := range idNames { - req.DBId = sdk.String(base.PickResourceID(idname)) - stopUdbIns(req, async, out) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to stop") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - req.ForceToKill = flags.Bool("force", false, "Optional. Stop UDB instances by force or not") - flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") - - cmd.MarkFlagRequired("udb-id") - - flags.SetFlagValues("force", "true", "false") - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList([]string{status.UDB_RUNNING}, "", *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -//NewCmdUDBStart ucloud udb start -func NewCmdUDBStart(out io.Writer) *cobra.Command { - var async bool - var idNames []string - req := base.BizClient.NewStartUDBInstanceRequest() - cmd := &cobra.Command{ - Use: "start", - Short: "Start MySQL instances by udb-id", - Long: "Start MySQL instances by udb-id", - Run: func(c *cobra.Command, args []string) { - for _, idname := range idNames { - id := base.PickResourceID(idname) - req.DBId = &id - _, err := base.BizClient.StartUDBInstance(req) - if err != nil { - base.HandleError(err) - continue - } - if async { - fmt.Fprintf(out, "udb[%s] is starting\n", idname) - } else { - text := fmt.Sprintf("udb[%s] is starting", idname) - poller.Spoll(*req.DBId, text, []string{status.UDB_RUNNING, status.UDB_FAIL}) - } - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to start") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") - - cmd.MarkFlagRequired("udb-id") - - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList([]string{status.UDB_SHUTOFF}, "", *req.ProjectId, *req.Region, *req.Zone) - }) - return cmd -} - -//NewCmdUDBRestart ucloud udb restart -func NewCmdUDBRestart(out io.Writer) *cobra.Command { - var async bool - var idNames []string - req := base.BizClient.NewRestartUDBInstanceRequest() - cmd := &cobra.Command{ - Use: "restart", - Short: "Restart MySQL instances by udb-id", - Long: "Restart MySQL instances by udb-id", - Run: func(c *cobra.Command, args []string) { - for _, idname := range idNames { - id := base.PickResourceID(idname) - req.DBId = &id - _, err := base.BizClient.RestartUDBInstance(req) - if err != nil { - base.HandleError(err) - continue - } - if async { - fmt.Fprintf(out, "udb[%s] is restarting\n", idname) - } else { - text := fmt.Sprintf("udb[%s] is restarting", idname) - poller.Spoll(*req.DBId, text, []string{status.UDB_RUNNING, status.UDB_FAIL}) - } - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to restart") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") - - cmd.MarkFlagRequired("udb-id") - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "", *req.ProjectId, *req.Region, *req.Zone) - }) - return cmd -} - -//NewCmdUDBResize ucloud udb resize -func NewCmdUDBResize(out io.Writer) *cobra.Command { - var diskTypes = []string{"normal", "sata_ssd", "pcie_ssd", "normal_volume", "sata_ssd_volume", "pcie_ssd_volume"} - var async, yes bool - var idNames []string - var memory, disk int - var diskType string - req := base.BizClient.NewResizeUDBInstanceRequest() - cmd := &cobra.Command{ - Use: "resize", - Short: "Reszie MySQL instances, such as memory size, disk size and disk type", - Long: "Reszie MySQL instances, such as memory size, disk size and disk type", - Run: func(c *cobra.Command, args []string) { - if diskType != "" { - switch diskType { - case "normal": - req.InstanceType = sdk.String("Normal") - case "sata_ssd": - req.InstanceType = sdk.String("SATA_SSD") - case "pcie_ssd": - req.InstanceType = sdk.String("PCIE_SSD") - case "normal_volume": - req.InstanceType = sdk.String("Normal_Volume") - case "sata_ssd_volume": - req.InstanceType = sdk.String("SATA_SSD_Volume") - case "pcie_ssd_volume": - req.InstanceType = sdk.String("PCIE_SSD_Volume") - default: - req.InstanceType = &diskType - } - } - - for _, idname := range idNames { - id := base.PickResourceID(idname) - req.DBId = &id - any, err := describeUdbByID(id) - if err != nil { - base.HandleError(err) - continue - } - - ins, ok := any.(*udb.UDBInstanceSet) - if !ok { - continue - } - - if memory != 0 { - req.MemoryLimit = sdk.Int(memory * 1000) - } else { - req.MemoryLimit = &ins.MemoryLimit - } - if disk != 0 { - req.DiskSpace = &disk - } else { - req.DiskSpace = &ins.DiskSpace - } - - if ins.State == status.UDB_RUNNING { - ok := base.Confirm(yes, fmt.Sprintf("Need to shut down udb[%s] before upgrading, whether to continue?", idname)) - if !ok { - continue - } - stopReq := base.BizClient.NewStopUDBInstanceRequest() - stopReq.ProjectId = req.ProjectId - stopReq.Region = req.Region - stopReq.Zone = req.Zone - stopReq.DBId = req.DBId - stopUdbIns(stopReq, false, out) - } - _, err = base.BizClient.ResizeUDBInstance(req) - if err != nil { - base.HandleError(err) - continue - } - if async { - fmt.Fprintf(out, "udb[%s] is resizing\n", idname) - } else { - text := fmt.Sprintf("udb[%s] is resizing", idname) - poller.Spoll(*req.DBId, text, []string{status.UDB_RUNNING, status.UDB_SHUTOFF, status.UDB_FAIL, status.UDB_UPGRADE_FAIL}) - } - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to restart") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - flags.IntVar(&memory, "memory-size-gb", 0, "Optional. Memory size of udb instance. From 1 to 128. Unit GB") - flags.IntVar(&disk, "disk-size-gb", 0, "Optional. Disk size of udb instance. From 20 to 3000 according to memory size. Unit GB. Step 10GB") - flags.StringVar(&diskType, "disk-type", "", fmt.Sprintf("Optional. Disk type of udb instance. Accept values:%s", strings.Join(diskTypes, ", "))) - req.StartAfterUpgrade = flags.Bool("start-after-upgrade", true, "Optional. Automatic start the UDB instances after upgrade") - flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish") - flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation") - - flags.SetFlagValues("disk-type", diskTypes...) - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "", *req.ProjectId, *req.Region, *req.Zone) - }) - - cmd.MarkFlagRequired("udb-id") - - return cmd -} - -//NewCmdUDBResetPassword ucloud udb reset-password -func NewCmdUDBResetPassword(out io.Writer) *cobra.Command { - var idNames []string - req := base.BizClient.NewModifyUDBInstancePasswordRequest() - cmd := &cobra.Command{ - Use: "reset-password", - Short: "Reset password of MySQL instances", - Long: "Reset password of MySQL instances", - Run: func(c *cobra.Command, args []string) { - for _, idname := range idNames { - id := base.PickResourceID(idname) - req.DBId = &id - _, err := base.BizClient.ModifyUDBInstancePassword(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "udb[%s]'s password modified\n", idname) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to reset password") - req.Password = flags.String("password", "", "Required. New password") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - - cmd.MarkFlagRequired("udb-id") - cmd.MarkFlagRequired("password") - - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "", *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -//NewCmdUDBRestore ucloud udb restore -func NewCmdUDBRestore(out io.Writer) *cobra.Command { - var datetime, diskType string - var async bool - req := base.BizClient.NewCreateUDBInstanceByRecoveryRequest() - cmd := &cobra.Command{ - Use: "restore", - Short: "Create MySQL instance and restore the newly created db to the specified DB at a specified point in time", - Long: "Create MySQL instance and restore the newly created db to the specified DB at a specified point in time", - Run: func(c *cobra.Command, args []string) { - t, err := time.Parse(time.RFC3339, datetime) - if err != nil { - base.HandleError(err) - return - } - req.RecoveryTime = sdk.Int(int(t.Unix())) - req.SrcDBId = sdk.String(base.PickResourceID(*req.SrcDBId)) - if diskType == "" { - any, err := describeUdbByID(*req.SrcDBId) - if err != nil { - base.HandleError(err) - return - } - ins, ok := any.(*udb.UDBInstanceSet) - if !ok { - fmt.Fprintln(out, fmt.Sprintf("fetch udb[%s] instance", *req.SrcDBId)) - } - req.UseSSD = &ins.UseSSD - } else if diskType == "normal" { - req.UseSSD = sdk.Bool(false) - } else if diskType == "ssd" { - req.UseSSD = sdk.Bool(true) - } - resp, err := base.BizClient.CreateUDBInstanceByRecovery(req) - if async { - fmt.Fprintf(out, "udb[%s] is restorting from udb[%s] at time point %s", resp.DBId, *req.SrcDBId, datetime) - } else { - text := fmt.Sprintf("udb[%s] is restorting from udb[%s] at time point %s", resp.DBId, *req.SrcDBId, datetime) - poller.Spoll(resp.DBId, text, []string{status.UDB_RUNNING, status.UDB_RECOVER_FAIL, status.UDB_FAIL}) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.Name = flags.String("name", "", "Required. Name of UDB instance to create") - req.SrcDBId = flags.String("src-udb-id", "", "Required. Resource ID of source UDB") - flags.StringVar(&datetime, "restore-to-time", "", "Required. The date and time to restore the DB to. Value must be a time in Universal Coordinated Time (UTC) format.Example: 2019-02-23T23:45:00Z") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - flags.StringVar(&diskType, "disk-type", "", "Optional. Disk type. The default is to be consistent with the source database. Accept values: normal, ssd") - bindChargeType(req, flags) - bindQuantity(req, flags) - flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish") - - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("src-udb-id") - cmd.MarkFlagRequired("restore-to-time") - - flags.SetFlagValues("disk-type", "noraml", "ssd") - flags.SetFlagValuesFunc("src-udb-id", func() []string { - return getUDBIDList(nil, "sql", *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -//NewCmdUDBCreateSlave ucloud udb create-slave -func NewCmdUDBCreateSlave(out io.Writer) *cobra.Command { - var diskType string - var async bool - req := base.BizClient.NewCreateUDBSlaveRequest() - cmd := &cobra.Command{ - Use: "create-slave", - Short: "Create slave database", - Long: "Create slave database", - Run: func(c *cobra.Command, args []string) { - *req.SrcId = base.PickResourceID(*req.SrcId) - switch diskType { - case "normal": - req.UseSSD = sdk.Bool(false) - case "sata_ssd": - req.UseSSD = sdk.Bool(true) - req.SSDType = sdk.String("SATA") - case "pcie_ssd": - req.UseSSD = sdk.Bool(true) - req.SSDType = sdk.String("PCI-E") - } - *req.MemoryLimit *= 1000 - resp, err := base.BizClient.CreateUDBSlave(req) - if err != nil { - base.HandleError(err) - return - } - if async { - fmt.Fprintf(out, "udb[%s] is initializing\n", resp.DBId) - } else { - poller.Spoll(resp.DBId, fmt.Sprintf("udb[%s] is initializing", resp.DBId), []string{status.UDB_RUNNING, status.UDB_FAIL}) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.SrcId = flags.String("master-udb-id", "", "Required. Resource ID of master UDB instance") - req.Name = flags.String("name", "", "Required. Name of the slave DB to create") - req.Port = flags.Int("port", 3306, "Optional. Port of the slave db service") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - flags.StringVar(&diskType, "disk-type", "Normal", fmt.Sprintf("Optional. Setting this flag means using SSD disk. Accept values: %s", strings.Join(dbDiskTypeList, ", "))) - req.MemoryLimit = flags.Int("memory-size-gb", 1, "Optional. Memory size of udb instance. From 1 to 128. Unit GB") - flags.BoolVar(&async, "async", false, "Optional. Do not wait for the long-running operation to finish") - req.IsLock = flags.Bool("is-lock", false, "Optional. Lock master DB or not") - - cmd.MarkFlagRequired("master-udb-id") - cmd.MarkFlagRequired("name") - - flags.SetFlagValues("disk-type", dbDiskTypeList...) - flags.SetFlagValuesFunc("master-udb-id", func() []string { - return getUDBIDList(nil, "", *req.ProjectId, *req.Region, *req.Zone) - }) - return cmd -} - -//NewCmdUDBPromoteSlave ucloud udb promote-slave -func NewCmdUDBPromoteSlave(out io.Writer) *cobra.Command { - var ids []string - req := base.BizClient.NewPromoteUDBSlaveRequest() - cmd := &cobra.Command{ - Use: "promote-slave", - Short: "Promote slave db to master", - Long: "Promote slave db to master", - Run: func(c *cobra.Command, args []string) { - for _, id := range ids { - req.DBId = sdk.String(id) - _, err := base.BizClient.PromoteUDBSlave(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "udb[%s] was promoted\n", *req.DBId) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&ids, "udb-id", nil, "Required. Resource ID of slave db to promote") - req.IsForce = flags.Bool("is-force", false, "Optional. Force to promote slave db or not. If the slave db falls behind, the force promote may lose some data") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("udb-id") - - return cmd -} - -//NewCmdUDBPromoteToHA ucloud udb promote-to-ha 低频操作 暂不开放 -func NewCmdUDBPromoteToHA(out io.Writer) *cobra.Command { - var idNames []string - req := base.BizClient.NewPromoteUDBInstanceToHARequest() - cmd := &cobra.Command{ - Use: "promote-to-ha", - Short: "Promote db of normal mode to high availability db. ", - Long: "Promote db of normal mode to high availability db", - Run: func(c *cobra.Command, args []string) { - for _, idname := range idNames { - id := base.PickResourceID(idname) - req.DBId = &id - _, err := base.BizClient.PromoteUDBInstanceToHA(req) - if err != nil { - base.HandleError(err) - continue - } - poller.Spoll(id, fmt.Sprintf("udb[%s] is synchronizing data", id), []string{status.UDB_TOBE_SWITCH, status.UDB_FAIL}) - any, err := describeUdbByID(id) - if err != nil { - fmt.Fprintf(out, "udb[%s] promoted failed, please contact technical support; %v\n", idname, err) - continue - } - ins, ok := any.(*udb.UDBInstanceSet) - if !ok { - fmt.Fprintf(out, "udb[%s] promoted failed, please contact technical support. \n", idname) - continue - } - if ins.State != status.UDB_TOBE_SWITCH { - fmt.Fprintf(out, "udb[%s] promoted failed, please contact technical support. udb[%s]'s status:%s\n", idname, idname, ins.State) - continue - } - switchReq := base.BizClient.NewSwitchUDBInstanceToHARequest() - switchReq.DBId = &id - switchReq.Region = req.Region - switchReq.ProjectId = req.ProjectId - switchReq.ChargeType = &ins.ChargeType - switchReq.Quantity = sdk.String("0") - switchReq.Zone = &base.ConfigIns.Zone - switchResp, err := base.BizClient.SwitchUDBInstanceToHA(switchReq) - if err != nil { - fmt.Fprintf(out, "udb[%s] promoted failed, please contact technical support; %v\n", idname, err) - continue - } - poller.Spoll(switchResp.DBId, fmt.Sprintf("udb[%s] is switching to high availability mode", switchResp.DBId), []string{status.UDB_RUNNING, status.UDB_FAIL}) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to be promoted as high availability mode") - - cmd.MarkFlagRequired("udb-id") - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "", *req.ProjectId, *req.Region, "") - }) - return cmd -} - -func stopUdbIns(req *udb.StopUDBInstanceRequest, async bool, out io.Writer) { - _, err := base.BizClient.StopUDBInstance(req) - if err != nil { - base.HandleError(err) - return - } - text := fmt.Sprintf("udb[%s] is stopping", *req.DBId) - if async { - fmt.Fprintln(out, text) - } else { - poller.Spoll(*req.DBId, text, []string{status.UDB_SHUTOFF, status.UDB_FAIL}) - } -} - -func getUDBIDList(states []string, dbType, project, region, zone string) []string { - udbs, err := getUDBList(states, dbType, project, region, zone) - if err != nil { - return nil - } - list := []string{} - for _, db := range udbs { - list = append(list, fmt.Sprintf("%s/%s", db.DBId, db.Name)) - } - return list -} - -func getUDBList(states []string, dbType, project, region, zone string) ([]udb.UDBInstanceSet, error) { - req := base.BizClient.NewDescribeUDBInstanceRequest() - if dbType == "" { - dbType = "sql" - } - req.ClassType = &dbType - req.ProjectId = &project - req.Region = ®ion - req.Zone = &zone - list := []udb.UDBInstanceSet{} - for offset, limit := 0, 50; ; offset += limit { - req.Offset = sdk.Int(offset) - req.Limit = sdk.Int(limit) - resp, err := base.BizClient.DescribeUDBInstance(req) - if err != nil { - return nil, err - } - for _, ins := range resp.DataSet { - if states != nil { - for _, s := range states { - if s == ins.State { - list = append(list, ins) - } - } - } else { - list = append(list, ins) - } - } - if offset+limit >= resp.TotalCount { - break - } - } - return list, nil -} - -func describeUdbByID(udbID string) (interface{}, error) { - req := base.BizClient.NewDescribeUDBInstanceRequest() - req.DBId = sdk.String(udbID) - resp, err := base.BizClient.DescribeUDBInstance(req) - if err != nil { - return nil, err - } - if len(resp.DataSet) < 1 { - return nil, fmt.Errorf("udb[%s] may not exist", udbID) - } - return &resp.DataSet[0], nil -} diff --git a/cmd/output_format_test.go b/cmd/output_format_test.go new file mode 100644 index 0000000000..c06218c3ab --- /dev/null +++ b/cmd/output_format_test.go @@ -0,0 +1,173 @@ +package cmd + +import ( + "bytes" + "os" + "testing" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func TestDecideOutputFormat(t *testing.T) { + tests := []struct { + name string + outputFlag string + jsonFlag bool + writer func() interface{ Write([]byte) (int, error) } // io.Writer + want cli.OutputFormat + }{ + { + name: "explicit --output yaml", + outputFlag: "yaml", + want: cli.OutputYAML, + }, + { + name: "explicit --output json", + outputFlag: "json", + want: cli.OutputJSON, + }, + { + name: "explicit --output table", + outputFlag: "table", + want: cli.OutputTable, + }, + { + name: "explicit --output JSON (case insensitive)", + outputFlag: "JSON", + want: cli.OutputJSON, + }, + { + name: "--json true (no --output)", + jsonFlag: true, + want: cli.OutputJSON, + }, + { + name: "non-TTY writer, no flags -> JSON", + // bytes.Buffer is not a TTY + want: cli.OutputJSON, + }, + { + name: "--output wins over --json", + outputFlag: "yaml", + jsonFlag: true, + want: cli.OutputYAML, + }, + { + name: "--output table wins over --json", + outputFlag: "table", + jsonFlag: true, + want: cli.OutputTable, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Save and restore global state. + origOutput := global.Output + origJSON := global.JSON + t.Cleanup(func() { + global.Output = origOutput + global.JSON = origJSON + }) + + global.Output = tc.outputFlag + global.JSON = tc.jsonFlag + + // Use a bytes.Buffer as a non-TTY writer. + got := decideOutputFormat(&bytes.Buffer{}) + if got != tc.want { + t.Errorf("decideOutputFormat() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestSyncLegacyJSONFlag(t *testing.T) { + origOutput := global.Output + origJSON := global.JSON + t.Cleanup(func() { + global.Output = origOutput + global.JSON = origJSON + }) + + tests := []struct { + name string + outputFlag string + jsonFlag bool + wantJSON bool + }{ + {name: "--output json enables legacy JSON", outputFlag: "json", wantJSON: true}, + {name: "--output table disables legacy JSON", outputFlag: "table", jsonFlag: true, wantJSON: false}, + {name: "--json still enables legacy JSON", jsonFlag: true, wantJSON: true}, + {name: "non-TTY default enables legacy JSON", wantJSON: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + global.Output = tc.outputFlag + global.JSON = tc.jsonFlag + + syncLegacyJSONFlag(&bytes.Buffer{}) + + if global.JSON != tc.wantJSON { + t.Errorf("global.JSON = %v, want %v", global.JSON, tc.wantJSON) + } + }) + } +} + +// TestProductCtxFormatFinalize is a regression test for the bug where the +// product cli.Context's output format was frozen at command-tree construction +// time (buildContext, before cobra parses --output), so an explicit +// `--output table` never took effect on product commands. The fix finalizes the +// format in initialize() (PersistentPreRun) via productCtx.SetFormat after flag +// parsing. This test mirrors that finalize step and asserts productCtx.Format() +// tracks the PARSED --output value. +// +// buildContext() is used to populate productCtx directly instead of +// addChildren(NewCmdRoot()): addChildren also constructs every platform command +// and touches base globals as a side effect, none of which this test needs. +// buildContext() only reads os.Stdin/Stdout/Stderr and base singletons; it +// performs no API calls and runs offline. +// +// Without the fix (no SetFormat call after parsing), productCtx would stay at +// the construction-time value (JSON, since test stdout is non-TTY) and the +// table/yaml assertions below would fail. +func TestProductCtxFormatFinalize(t *testing.T) { + // Save and restore both the global flag and the package-level productCtx + // so other tests are unaffected. + origOutput := global.Output + origCtx := productCtx + t.Cleanup(func() { + global.Output = origOutput + productCtx = origCtx + }) + + productCtx = buildContext() + + tests := []struct { + name string + outputFlag string + want cli.OutputFormat + }{ + {name: "--output table", outputFlag: "table", want: cli.OutputTable}, + {name: "--output json", outputFlag: "json", want: cli.OutputJSON}, + {name: "--output yaml", outputFlag: "yaml", want: cli.OutputYAML}, + // Empty --output on a non-TTY stdout falls back to JSON. + {name: "empty --output (non-TTY default)", outputFlag: "", want: cli.OutputJSON}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + global.Output = tc.outputFlag + + // Mirror what initialize() (PersistentPreRun) now does after cobra + // parses --output. + productCtx.SetFormat(decideOutputFormat(os.Stdout)) + + if got := productCtx.Format(); got != tc.want { + t.Errorf("productCtx.Format() = %v, want %v (--output=%q)", got, tc.want, tc.outputFlag) + } + }) + } +} diff --git a/cmd/pathx.go b/cmd/pathx.go deleted file mode 100644 index d6b00d202d..0000000000 --- a/cmd/pathx.go +++ /dev/null @@ -1,556 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - "strconv" - "strings" - - "github.com/spf13/cobra" - - ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" - - "github.com/ucloud/ucloud-cli/base" -) - -//NewCmdPathx ucloud pathx -func NewCmdPathx() *cobra.Command { - cmd := &cobra.Command{ - Use: "pathx", - Short: "Manipulate uga and upath instances", - Long: "Manipulate uga and upath instances", - } - cmd.AddCommand(NewCmdUGA()) - cmd.AddCommand(NewCmdUpath()) - return cmd -} - -//NewCmdUpath ucloud pathx upath -func NewCmdUpath() *cobra.Command { - cmd := &cobra.Command{ - Use: "upath", - Short: "List pathx upath instances", - Long: "List pathx upath instances", - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdUpathList(out)) - return cmd -} - -type upathRow struct { - ResourceID string - UPathName string - AcceleratedPath string - BoundUGA string -} - -//NewCmdUpathList ucloud pathx upath list -func NewCmdUpathList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeUPathRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "list upath instances", - Long: "list upath instances", - Run: func(c *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeUPath(req) - if err != nil { - base.HandleError(err) - return - } - list := make([]upathRow, 0) - for _, ins := range resp.UPathSet { - row := upathRow{ - ResourceID: ins.UPathId, - UPathName: ins.Name, - AcceleratedPath: fmt.Sprintf("%s->%s %dM", ins.LineFromName, ins.LineToName, ins.Bandwidth), - } - ids := []string{} - for _, ga := range ins.UGAList { - ids = append(ids, ga.UGAId) - } - row.BoundUGA = strings.Join(ids, ",") - list = append(list, row) - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - bindProjectID(req, flags) - req.UPathId = flags.String("upath-id", "", "Optional. Resource ID of upath instance to list") - - return cmd -} - -//NewCmdUGA ucloud uga -func NewCmdUGA() *cobra.Command { - cmd := &cobra.Command{ - Use: "uga", - Short: "Create,list,update and delete pathx uga instances", - Long: `Create,list,update and delete pathx uga instances`, - } - - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdUGAList(out)) - cmd.AddCommand(NewCmdUGADescribe(out)) - cmd.AddCommand(NewCmdUGACreate(out)) - cmd.AddCommand(NewCmdUGADelete(out)) - cmd.AddCommand(NewCmdUGAAddPort(out)) - cmd.AddCommand(NewCmdUGARemovePort(out)) - - return cmd -} - -//UGARow 表格行 -type UGARow struct { - ResourceID string - UGAName string - CName string - Origin string - AcceleratedPath string -} - -var protocols = []string{"tcp", "udp"} - -//NewCmdUGAList ucloud uga list -func NewCmdUGAList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeUGAInstanceRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "list uga instances", - Long: "list uga instances", - Run: func(c *cobra.Command, args []string) { - *req.UGAId = base.PickResourceID(*req.UGAId) - resp, err := base.BizClient.DescribeUGAInstance(req) - if err != nil { - base.HandleError(err) - return - } - - list := make([]UGARow, 0) - for _, ins := range resp.UGAList { - row := UGARow{ - ResourceID: ins.UGAId, - UGAName: ins.UGAName, - CName: ins.CName, - Origin: fmt.Sprintf("%s%s", strings.Join(ins.IPList, ","), ins.Domain), - } - row.AcceleratedPath = getUpathStr(ins.UPathSet) - list = append(list, row) - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.UGAId = flags.String("uga-id", "", "Optional. Resource ID of uga instance") - bindProjectID(req, flags) - - return cmd -} - -func getUpathStr(list []ppathx.UPathSet) string { - paths := make([]string, 0) - for _, p := range list { - paths = append(paths, fmt.Sprintf("%s->%s %dM", p.LineFromName, p.LineToName, p.Bandwidth)) - } - return strings.Join(paths, "\n") -} - -func getOutIPStr(list []ppathx.OutPublicIpInfo) string { - strs := make([]string, 0) - for _, p := range list { - strs = append(strs, fmt.Sprintf("%s %s", p.IP, base.RegionLabel[p.Area])) - } - return strings.Join(strs, "\n") -} - -func getPortStr(list []ppathx.UGAATask) string { - strs := make([]string, 0) - for _, t := range list { - strs = append(strs, fmt.Sprintf("%s %d", t.Protocol, t.Port)) - } - return strings.Join(strs, "\n") -} - -//NewCmdUGADescribe ucloud uga describe -func NewCmdUGADescribe(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeUGAInstanceRequest() - cmd := &cobra.Command{ - Use: "describe", - Short: "Display detail informations about uga instances", - Long: "Display detail informations about uga instances", - Run: func(c *cobra.Command, args []string) { - *req.UGAId = base.PickResourceID(*req.UGAId) - resp, err := base.BizClient.DescribeUGAInstance(req) - if err != nil { - base.HandleError(err) - return - } - if len(resp.UGAList) != 1 { - base.HandleError(fmt.Errorf("uga[%s] may not exist", *req.UGAId)) - return - } - - ins := resp.UGAList[0] - list := []base.DescribeTableRow{ - base.DescribeTableRow{"ResourceID", ins.UGAId}, - base.DescribeTableRow{"UGAName", ins.UGAName}, - base.DescribeTableRow{"Origin", fmt.Sprintf("%s%s", ins.Domain, strings.Join(ins.IPList, ","))}, - base.DescribeTableRow{"CName", ins.CName}, - base.DescribeTableRow{"AcceleratedPath", getUpathStr(ins.UPathSet)}, - base.DescribeTableRow{"OutIP", getOutIPStr(ins.OutPublicIpList)}, - base.DescribeTableRow{"Port", getPortStr(ins.TaskSet)}, - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.UGAId = flags.String("uga-id", "", "Required. Resource ID of uga instance") - bindProjectID(req, flags) - - cmd.MarkFlagRequired("uga-id") - flags.SetFlagValuesFunc("uga-id", func() []string { - return getUGAIDList(*req.ProjectId) - }) - - return cmd -} - -func formatPortList(userPorts []string) ([]string, error) { - portList := make([]string, 0) - for _, port := range userPorts { - if strings.Contains(port, "-") { - portRange := strings.Split(port, "-") - if len(portRange) != 2 { - return nil, fmt.Errorf("port %s is invalid, it's pattern should be like 3000-3100", port) - } - min, err := strconv.Atoi(portRange[0]) - if err != nil { - return nil, fmt.Errorf("parse port failed: %v", err) - } - max, err := strconv.Atoi(portRange[1]) - if err != nil { - return nil, fmt.Errorf("parse port failed: %v", err) - } - - for i := min; i <= max; i++ { - portList = append(portList, strconv.Itoa(i)) - } - } else { - portList = append(portList, port) - } - } - return portList, nil -} - -//NewCmdUGACreate ucloud uga create -func NewCmdUGACreate(out io.Writer) *cobra.Command { - var protocol string - var ports, lines []string - req := base.BizClient.NewCreateUGAInstanceRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create uga instance", - Long: "Create uga instance", - Example: "ucloud pathx uga create --name testcli1 --protocol tcp --origin-location 中国 --origin-domain lixiaojun.xyz --upath-id upath-auvfexxx/test_0 --port 80-90,100,110-115", - Run: func(c *cobra.Command, args []string) { - if *req.IPList == "" && *req.Domain == "" { - fmt.Fprintln(out, "origin-ip and origin-domain can not be both empty") - return - } - - portList, err := formatPortList(ports) - if err != nil { - base.HandleError(err) - return - } - - switch strings.ToLower(protocol) { - case "tcp": - req.TCP = portList - case "udp": - req.UDP = portList - case "http": - req.HTTP = portList - case "https": - req.HTTPS = portList - default: - fmt.Fprintf(out, "protocol should be one of %s, received:%s\n", strings.Join(protocols, ","), protocol) - } - - resp, err := base.BizClient.CreateUGAInstance(req) - if err != nil { - if uErr, ok := err.(uerr.Error); ok && uErr.Code() == 33756 { - fmt.Fprintf(out, "The number of ports added exceeds the limit(50). We recommend that you could reduce the number of ports, then create an uga instance, \nand then add the remaining ports by executing 'ucloud pathx uga add-port --protocol %s --uga-id --port '\n", protocol) - } - return - } - - fmt.Fprintf(out, "uga[%s] created\n", resp.UGAId) - - for _, path := range lines { - p := base.PickResourceID(path) - bindReq := base.BizClient.NewUGABindUPathRequest() - bindReq.ProjectId = req.ProjectId - bindReq.UGAId = sdk.String(resp.UGAId) - bindReq.UPathId = &p - _, err := base.BizClient.UGABindUPath(bindReq) - if err != nil { - fmt.Fprintf(out, "bind uga[%s] and upath[%s] failed: %v\n", resp.UGAId, p, err) - } else { - fmt.Fprintf(out, "bound uga[%s] and upath[%s]\n", resp.UGAId, p) - } - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - bindProjectID(req, flags) - req.Name = flags.String("name", "", "Required. Name of uga instance to create") - req.IPList = flags.String("origin-ip", "", "Required if origin-domain is empty. IP address of origin. multiple IP address separated by ','") - req.Domain = flags.String("origin-domain", "", "Required if origin-ip is empty.") - req.Location = flags.String("origin-location", "", "Required. Location of origin ip or domain. accpet valeus:'中国','洛杉矶','法兰克福','中国香港','雅加达','孟买','东京','莫斯科','新加坡','曼谷','中国台北','华盛顿','首尔'") - flags.StringVar(&protocol, "protocol", "", fmt.Sprintf("Required. accept values: %s", strings.Join(protocols, ","))) - flags.StringSliceVar(&ports, "port", nil, "Required. Single port or port range, separated by ',', for example 80,3000-3010") - flags.StringSliceVar(&lines, "upath-id", nil, "Required. Accelerated path to bind with the uga instance to create. multiple upath-id separated by ','; see 'ucloud pathx upath list") - - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("origin-location") - cmd.MarkFlagRequired("protocol") - cmd.MarkFlagRequired("port") - cmd.MarkFlagRequired("upath-id") - - flags.SetFlagValues("origin-location", "中国", "洛杉矶", "法兰克福", "中国香港", "雅加达", "孟买", "东京", "莫斯科", "新加坡", "曼谷", "中国台北", "华盛顿", "首尔") - flags.SetFlagValues("protocol", protocols...) - flags.SetFlagValuesFunc("upath-id", func() []string { - return getUpathIDList(*req.ProjectId) - }) - - return cmd -} - -//NewCmdUGADelete ucloud uga delete -func NewCmdUGADelete(out io.Writer) *cobra.Command { - idNames := []string{} - req := base.BizClient.NewDeleteUGAInstanceRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete uga instances", - Long: "Delete uga instances", - Run: func(c *cobra.Command, args []string) { - for _, idname := range idNames { - id := base.PickResourceID(idname) - req.UGAId = &id - _, err := base.BizClient.DeleteUGAInstance(req) - if err != nil { - base.HandleError(err) - } else { - fmt.Fprintf(out, "uga[%s] deleted\n", id) - } - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - bindProjectID(req, flags) - flags.StringSliceVar(&idNames, "uga-id", nil, "Required. Resource ID of uga instances to delete. Multiple resource ids separated by comma") - - cmd.MarkFlagRequired("uga-id") - flags.SetFlagValuesFunc("uga-id", func() []string { - return getUGAIDList(*req.ProjectId) - }) - - return cmd -} - -//NewCmdUGAAddPort ucloud pathx uga add-port -func NewCmdUGAAddPort(out io.Writer) *cobra.Command { - var ports []string - var protocol string - req := base.BizClient.NewAddUGATaskRequest() - cmd := &cobra.Command{ - Use: "add-port", - Short: "Add port for uga instance", - Long: "Add port for uga instance", - Run: func(c *cobra.Command, args []string) { - portList, err := formatPortList(ports) - if err != nil { - base.HandleError(err) - return - } - - switch strings.ToLower(protocol) { - case "tcp": - req.TCP = portList - case "udp": - req.UDP = portList - case "http": - req.HTTP = portList - case "https": - req.HTTPS = portList - default: - fmt.Fprintf(out, "protocol should be one of %s, received:%s\n", strings.Join(protocols, ","), protocol) - } - - *req.UGAId = base.PickResourceID(*req.UGAId) - _, err = base.BizClient.AddUGATask(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "port %v added\n", ports) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - bindProjectID(req, flags) - req.UGAId = flags.String("uga-id", "", "Required. Resource ID of uga instance to add port") - flags.StringVar(&protocol, "protocol", "", fmt.Sprintf("Required. accept values: %s", strings.Join(protocols, ","))) - flags.StringSliceVar(&ports, "port", nil, "Required. Single port or port range, separated by ',', for example 80,3000-3010") - - cmd.MarkFlagRequired("protocol") - cmd.MarkFlagRequired("uga-id") - cmd.MarkFlagRequired("port") - - flags.SetFlagValues("protocol", protocols...) - flags.SetFlagValuesFunc("uga-id", func() []string { - return getUGAIDList(*req.ProjectId) - }) - - return cmd -} - -//NewCmdUGARemovePort ucloud pathx uga delete-port -func NewCmdUGARemovePort(out io.Writer) *cobra.Command { - var ports []string - var protocol string - req := base.BizClient.NewDeleteUGATaskRequest() - cmd := &cobra.Command{ - Use: "delete-port", - Short: "Delete port for uga instance", - Long: "Delete port for uga instance", - Run: func(c *cobra.Command, args []string) { - portList, err := formatPortList(ports) - if err != nil { - base.HandleError(err) - return - } - - switch strings.ToLower(protocol) { - case "tcp": - req.TCP = portList - case "udp": - req.UDP = portList - case "http": - req.HTTP = portList - case "https": - req.HTTPS = portList - default: - fmt.Fprintf(out, "protocol should be one of %s, received:%s\n", strings.Join(protocols, ","), protocol) - } - - *req.UGAId = base.PickResourceID(*req.UGAId) - _, err = base.BizClient.DeleteUGATask(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "port %v deleted\n", ports) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - bindProjectID(req, flags) - req.UGAId = flags.String("uga-id", "", "Required. Resource ID of uga instance to delete port") - flags.StringVar(&protocol, "protocol", "", fmt.Sprintf("Required. accept values: %s", strings.Join(protocols, ","))) - flags.StringSliceVar(&ports, "port", nil, "Required. Single port or port range, separated by ',', for example 80,3000-3010") - - cmd.MarkFlagRequired("protocol") - cmd.MarkFlagRequired("uga-id") - cmd.MarkFlagRequired("port") - - flags.SetFlagValues("protocol", protocols...) - flags.SetFlagValuesFunc("uga-id", func() []string { - return getUGAIDList(*req.ProjectId) - }) - - return cmd -} - -func getUGAList(project string) ([]ppathx.UGAAInfo, error) { - req := base.BizClient.NewDescribeUGAInstanceRequest() - req.ProjectId = &project - resp, err := base.BizClient.DescribeUGAInstance(req) - if err != nil { - return nil, err - } - return resp.UGAList, nil -} - -func getUGAIDList(project string) []string { - list, err := getUGAList(project) - if err != nil { - base.LogError(fmt.Sprintf("getUDGAIDList filed:%v", err)) - return nil - } - strs := make([]string, 0) - for _, ins := range list { - strs = append(strs, fmt.Sprintf("%s/%s", ins.UGAId, ins.UGAName)) - } - return strs -} - -func getUpathList(project string) ([]ppathx.UPathInfo, error) { - req := base.BizClient.NewDescribeUPathRequest() - req.ProjectId = &project - resp, err := base.BizClient.DescribeUPath(req) - if err != nil { - return nil, err - } - return resp.UPathSet, nil -} - -func getUpathIDList(project string) []string { - list, err := getUpathList(project) - if err != nil { - base.LogError(fmt.Sprintf("getUpathIDList failed:%v", err)) - return nil - } - strs := make([]string, 0) - for _, ins := range list { - strs = append(strs, fmt.Sprintf("%s/%s", ins.UPathId, ins.Name)) - } - return strs -} diff --git a/cmd/platform_runtime_guard_test.go b/cmd/platform_runtime_guard_test.go new file mode 100644 index 0000000000..da4b6c4901 --- /dev/null +++ b/cmd/platform_runtime_guard_test.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +const guardModuleRoot = "github.com/ucloud/ucloud-cli" + +func TestProductionCodeDoesNotUseAggregateBaseClient(t *testing.T) { + repoRoot := ".." + var violations []string + err := filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case ".git", "docs", "vendor": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, err := filepath.Rel(repoRoot, path) + if err != nil { + return err + } + if strings.HasPrefix(rel, "ux/") { + return nil + } + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return err + } + inPlatformPackage := strings.HasPrefix(rel, "cmd/internal/platform/") + ast.Inspect(file, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.SelectorExpr: + if ident, ok := x.X.(*ast.Ident); ok && ident.Name == "base" && x.Sel.Name == "BizClient" { + violations = append(violations, fset.Position(x.Pos()).String()+": platform.BizClient is forbidden") + } + case *ast.TypeSpec: + if inPlatformPackage && x.Name.Name == "Client" { + violations = append(violations, fset.Position(x.Pos()).String()+": platform.Client aggregate type is forbidden") + } + case *ast.ValueSpec: + if inPlatformPackage { + for _, name := range x.Names { + if name.Name == "BizClient" { + violations = append(violations, fset.Position(name.Pos()).String()+": platform.BizClient global is forbidden") + } + } + } + case *ast.FuncDecl: + if inPlatformPackage && (x.Name.Name == "NewClient" || x.Name.Name == "GetBizClient") { + violations = append(violations, fset.Position(x.Pos()).String()+": aggregate client constructor is forbidden") + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(violations) > 0 { + t.Fatalf("aggregate base client usage remains:\n%s", strings.Join(violations, "\n")) + } +} + +func TestProductionCodeDoesNotImportLegacyTopLevelPackages(t *testing.T) { + repoRoot := ".." + forbidden := map[string]string{ + guardModuleRoot + "/base": "legacy top-level base package is forbidden", + guardModuleRoot + "/ux": "legacy top-level ux package is forbidden", + guardModuleRoot + "/ansi": "legacy top-level ansi package is forbidden", + } + + var violations []string + err := filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case ".git", "docs", "vendor": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imp := range file.Imports { + importPath, err := strconv.Unquote(imp.Path.Value) + if err != nil { + return err + } + if msg, ok := forbidden[importPath]; ok { + violations = append(violations, fset.Position(imp.Path.Pos()).String()+": "+msg) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(violations) > 0 { + t.Fatalf("legacy top-level imports remain:\n%s", strings.Join(violations, "\n")) + } +} + +func TestLegacyTopLevelPackageDirectoriesDoNotExist(t *testing.T) { + for _, dir := range []string{"../base", "../ux", "../ansi"} { + if _, err := os.Stat(dir); err == nil { + t.Fatalf("legacy top-level package directory still exists: %s", dir) + } + } +} diff --git a/cmd/product_registration_test.go b/cmd/product_registration_test.go new file mode 100644 index 0000000000..27fb0c3211 --- /dev/null +++ b/cmd/product_registration_test.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "os" + "sort" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +type multiCommandProductForTest struct{} + +func (p multiCommandProductForTest) Metadata() cli.Metadata { + return cli.Metadata{Name: "multi", Commands: []string{"alpha", "beta"}} +} + +func (p multiCommandProductForTest) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{ + {Use: "alpha"}, + {Use: "beta"}, + } +} + +func TestRegisteredProductsUseCommandDirectoryProducts(t *testing.T) { + products := registeredProducts() + + byName := make(map[string]cli.Product, len(products)) + for _, p := range products { + byName[p.Metadata().Name] = p + } + + for _, tt := range []struct { + product string + commands []string + }{ + {product: "sharedbw", commands: []string{"bw"}}, + {product: "eip", commands: []string{"eip", "ext"}}, + {product: "firewall", commands: []string{"firewall"}}, + {product: "globalssh", commands: []string{"gssh"}}, + {product: "image", commands: []string{"image"}}, + {product: "memcache", commands: []string{"memcache"}}, + {product: "mysql", commands: []string{"mysql"}}, + {product: "pathx", commands: []string{"pathx"}}, + {product: "redis", commands: []string{"redis"}}, + {product: "subnet", commands: []string{"subnet"}}, + {product: "udisk", commands: []string{"udisk"}}, + {product: "udpn", commands: []string{"udpn"}}, + {product: "uhost", commands: []string{"uhost"}}, + {product: "ulb", commands: []string{"ulb"}}, + {product: "umodelverse", commands: []string{"umodelverse"}}, + {product: "uphost", commands: []string{"uphost"}}, + {product: "utidb", commands: []string{"utidb"}}, + {product: "vpc", commands: []string{"vpc"}}, + } { + assertProductCommands(t, byName, tt.product, tt.commands) + } + + for _, removedName := range []string{"bw", "gssh", "udb", "umem", "unet"} { + if _, ok := byName[removedName]; ok { + t.Fatalf("registeredProducts includes grouped/stale product %q; want existing top-level commands in independent directories", removedName) + } + } +} + +func TestAddProductCommandsRegistersAllProductCommands(t *testing.T) { + root := &cobra.Command{Use: "ucloud"} + + addProductCommands(root, []cli.Product{multiCommandProductForTest{}}, cli.NewContext(cli.Deps{})) + + for _, name := range []string{"alpha", "beta"} { + if _, _, err := root.Find([]string{name}); err != nil { + t.Fatalf("product command %q was not registered: %v", name, err) + } + } +} + +func TestAddPlatformCommandsExcludesMigratedProductCommands(t *testing.T) { + src, err := os.ReadFile("root.go") + if err != nil { + t.Fatalf("read root.go: %v", err) + } + for _, constructor := range []string{ + "NewCmdUDPN(", + "NewCmdGssh(", + "NewCmdPathx(", + "NewCmdBandwidth(", + "NewCmdRedis(", + "NewCmdMemcache(", + "NewCmdULB(", + "NewCmdSubnet(", + "NewCmdVpc(", + "NewCmdExt(", + } { + if strings.Contains(string(src), constructor) { + t.Fatalf("addPlatformCommands must not register %s after product migration", constructor) + } + } +} + +func assertProductCommands(t *testing.T, products map[string]cli.Product, name string, want []string) { + t.Helper() + + p, ok := products[name] + if !ok { + t.Fatalf("registeredProducts missing product %q", name) + } + + got := append([]string(nil), p.Metadata().Commands...) + sort.Strings(got) + sort.Strings(want) + if len(got) != len(want) { + t.Fatalf("product %q commands = %v, want %v", name, got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("product %q commands = %v, want %v", name, got, want) + } + } +} diff --git a/cmd/products.gen.go b/cmd/products.gen.go new file mode 100644 index 0000000000..f539d1327c --- /dev/null +++ b/cmd/products.gen.go @@ -0,0 +1,86 @@ +// Code generated by hack/gen-products; DO NOT EDIT. +package cmd + +import ( + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/products/cloudwatch" + "github.com/ucloud/ucloud-cli/products/css" + "github.com/ucloud/ucloud-cli/products/eip" + "github.com/ucloud/ucloud-cli/products/firewall" + "github.com/ucloud/ucloud-cli/products/globalssh" + "github.com/ucloud/ucloud-cli/products/image" + "github.com/ucloud/ucloud-cli/products/memcache" + "github.com/ucloud/ucloud-cli/products/mysql" + "github.com/ucloud/ucloud-cli/products/nlb" + "github.com/ucloud/ucloud-cli/products/pathx" + "github.com/ucloud/ucloud-cli/products/pgsql" + "github.com/ucloud/ucloud-cli/products/redis" + "github.com/ucloud/ucloud-cli/products/sharedbw" + "github.com/ucloud/ucloud-cli/products/sqlserver" + "github.com/ucloud/ucloud-cli/products/subnet" + "github.com/ucloud/ucloud-cli/products/uclickhouse" + "github.com/ucloud/ucloud-cli/products/udac" + "github.com/ucloud/ucloud-cli/products/uddos" + "github.com/ucloud/ucloud-cli/products/udisk" + "github.com/ucloud/ucloud-cli/products/udns" + "github.com/ucloud/ucloud-cli/products/udpn" + "github.com/ucloud/ucloud-cli/products/ufs" + "github.com/ucloud/ucloud-cli/products/ugn" + "github.com/ucloud/ucloud-cli/products/uhadoop" + "github.com/ucloud/ucloud-cli/products/uhost" + "github.com/ucloud/ucloud-cli/products/uk8s" + "github.com/ucloud/ucloud-cli/products/ukafka" + "github.com/ucloud/ucloud-cli/products/ulb" + "github.com/ucloud/ucloud-cli/products/ulhost" + "github.com/ucloud/ucloud-cli/products/umodelverse" + "github.com/ucloud/ucloud-cli/products/umongodb" + "github.com/ucloud/ucloud-cli/products/upfs" + "github.com/ucloud/ucloud-cli/products/uphost" + "github.com/ucloud/ucloud-cli/products/urocketmq" + "github.com/ucloud/ucloud-cli/products/usnap" + "github.com/ucloud/ucloud-cli/products/utidb" + "github.com/ucloud/ucloud-cli/products/vpc" +) + +// registeredProducts returns the platform-registered products. +func registeredProducts() []cli.Product { + return []cli.Product{ + cloudwatch.New(), + css.New(), + eip.New(), + firewall.New(), + globalssh.New(), + image.New(), + memcache.New(), + mysql.New(), + nlb.New(), + pathx.New(), + pgsql.New(), + redis.New(), + sharedbw.New(), + sqlserver.New(), + subnet.New(), + uclickhouse.New(), + udac.New(), + uddos.New(), + udisk.New(), + udns.New(), + udpn.New(), + ufs.New(), + ugn.New(), + uhadoop.New(), + uhost.New(), + uk8s.New(), + ukafka.New(), + ulb.New(), + ulhost.New(), + umodelverse.New(), + umongodb.New(), + upfs.New(), + uphost.New(), + urocketmq.New(), + usnap.New(), + utidb.New(), + vpc.New(), + } +} diff --git a/cmd/project.go b/cmd/project.go index aa124b18a8..8c59271ba7 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -21,10 +21,10 @@ import ( "github.com/ucloud/ucloud-sdk-go/services/uaccount" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" ) -//NewCmdProject ucloud project +// NewCmdProject ucloud project func NewCmdProject() *cobra.Command { var cmd = &cobra.Command{ Use: "project", @@ -32,7 +32,7 @@ func NewCmdProject() *cobra.Command { Long: "List,create,update and delete project", Example: "ucloud project", } - out := base.Cxt.GetWriter() + out := platform.Cxt.GetWriter() cmd.AddCommand(NewCmdProjectList(out)) cmd.AddCommand(NewCmdProjectCreate()) cmd.AddCommand(NewCmdProjectUpdate()) @@ -40,7 +40,7 @@ func NewCmdProject() *cobra.Command { return cmd } -//NewCmdProjectList ucloud project list +// NewCmdProjectList ucloud project list func NewCmdProjectList(out io.Writer) *cobra.Command { cmd := &cobra.Command{ Use: "list", @@ -54,23 +54,24 @@ func NewCmdProjectList(out io.Writer) *cobra.Command { return cmd } -//NewCmdProjectCreate ucloud project create +// NewCmdProjectCreate ucloud project create func NewCmdProjectCreate() *cobra.Command { - req := base.BizClient.NewCreateProjectRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewCreateProjectRequest() cmd := &cobra.Command{ Use: "create", Short: "Create project", Long: "Create project", Example: "ucloud project create --name xxx", Run: func(cmd *cobra.Command, args []string) { - resp, err := base.BizClient.CreateProject(req) + resp, err := client.CreateProject(req) if err != nil { - base.Cxt.PrintErr(err) + platform.Cxt.PrintErr(err) } else { if resp.RetCode != 0 { - base.HandleBizError(resp) + platform.HandleBizError(resp) } else { - base.Cxt.Printf("Project:%q created\n", resp.ProjectId) + platform.Cxt.Printf("Project:%q created\n", resp.ProjectId) } } }, @@ -81,23 +82,24 @@ func NewCmdProjectCreate() *cobra.Command { return cmd } -//NewCmdProjectUpdate ucloud project update +// NewCmdProjectUpdate ucloud project update func NewCmdProjectUpdate() *cobra.Command { - req := base.BizClient.NewModifyProjectRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewModifyProjectRequest() cmd := &cobra.Command{ Use: "update", Short: "Update project name", Long: "Update project name", Example: "ucloud project update --id org-xxx --name new_name", Run: func(cmd *cobra.Command, args []string) { - resp, err := base.BizClient.ModifyProject(req) + resp, err := client.ModifyProject(req) if err != nil { - base.Cxt.PrintErr(err) + platform.Cxt.PrintErr(err) } else { if resp.RetCode != 0 { - base.HandleBizError(resp) + platform.HandleBizError(resp) } else { - base.Cxt.Printf("Project:%s updated\n", *req.ProjectId) + platform.Cxt.Printf("Project:%s updated\n", *req.ProjectId) } } }, @@ -109,23 +111,24 @@ func NewCmdProjectUpdate() *cobra.Command { return cmd } -//NewCmdProjectDelete ucloud project delete +// NewCmdProjectDelete ucloud project delete func NewCmdProjectDelete() *cobra.Command { - req := base.BizClient.NewTerminateProjectRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewTerminateProjectRequest() cmd := &cobra.Command{ Use: "delete", Short: "Delete project", Long: "Delete project", Example: "ucloud project delete --id org-xxx", Run: func(cmd *cobra.Command, args []string) { - resp, err := base.BizClient.TerminateProject(req) + resp, err := client.TerminateProject(req) if err != nil { - base.Cxt.PrintErr(err) + platform.Cxt.PrintErr(err) } else { if resp.RetCode != 0 { - base.HandleBizError(resp) + platform.HandleBizError(resp) } else { - base.Cxt.Printf("Project:%s deleted\n", *req.ProjectId) + platform.Cxt.Printf("Project:%s deleted\n", *req.ProjectId) } } }, @@ -136,25 +139,26 @@ func NewCmdProjectDelete() *cobra.Command { } func listProject(out io.Writer) error { - req := &uaccount.GetProjectListRequest{} - resp, err := base.BizClient.GetProjectList(req) + client := newServiceClient(uaccount.NewClient) + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) if err != nil { return err } if resp.RetCode != 0 { - return base.HandleBizError(resp) + return platform.HandleBizError(resp) } if global.JSON { - base.PrintJSON(resp.ProjectSet, out) - } else { - base.PrintTable(resp.ProjectSet, []string{"ProjectId", "ProjectName"}) + return platform.PrintJSON(resp.ProjectSet, out) } + platform.PrintTable(resp.ProjectSet, []string{"ProjectId", "ProjectName"}) return nil } func getProjectList() []string { - req := &uaccount.GetProjectListRequest{} - resp, err := base.BizClient.GetProjectList(req) + client := newServiceClient(uaccount.NewClient) + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) if err != nil { return nil } diff --git a/cmd/region.go b/cmd/region.go index 313b9e73ff..4755293c2b 100644 --- a/cmd/region.go +++ b/cmd/region.go @@ -16,6 +16,7 @@ package cmd import ( "encoding/json" + "errors" "fmt" "io" "io/ioutil" @@ -25,10 +26,10 @@ import ( "github.com/ucloud/ucloud-sdk-go/services/uaccount" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" ) -//NewCmdRegion ucloud region +// NewCmdRegion ucloud region func NewCmdRegion(out io.Writer) *cobra.Command { cmd := &cobra.Command{ Use: "region", @@ -38,28 +39,29 @@ func NewCmdRegion(out io.Writer) *cobra.Command { Run: func(cmd *cobra.Command, args []string) { regionIns, err := fetchRegion() if err != nil { - base.HandleError(err) + platform.HandleError(err) return } regionList := make([]RegionTable, 0) for region, zones := range regionIns.Labels { regionList = append(regionList, RegionTable{region, strings.Join(zones, ", ")}) } - base.PrintList(regionList, out) + platform.PrintList(regionList, out) }, } return cmd } -//RegionTable 为显示region表格创建的类型 +// RegionTable 为显示region表格创建的类型 type RegionTable struct { Region string Zones string } func getDefaultRegion() (string, string, error) { - req := &uaccount.GetRegionRequest{} - resp, err := base.BizClient.GetRegion(req) + client := newServiceClient(uaccount.NewClient) + req := client.NewGetRegionRequest() + resp, err := client.GetRegion(req) if err != nil { return "", "", err } @@ -74,7 +76,7 @@ func getDefaultRegion() (string, string, error) { return "", "", fmt.Errorf("No default region") } -//Region region, zone, isDefault +// Region region, zone, isDefault type Region struct { Labels map[string][]string DefaultRegion string @@ -82,8 +84,9 @@ type Region struct { } func fetchRegion() (*Region, error) { - req := base.BizClient.NewGetRegionRequest() - resp, err := base.BizClient.GetRegion(req) + client := newServiceClient(uaccount.NewClient) + req := client.NewGetRegionRequest() + resp, err := client.GetRegion(req) if err != nil { return nil, err } @@ -100,13 +103,13 @@ func fetchRegion() (*Region, error) { return region, nil } -func fetchRegionWithConfig(cfg *base.AggConfig) (*Region, error) { - bc, err := base.GetBizClient(cfg) - req := bc.NewGetRegionRequest() +func fetchRegionWithConfig(cfg *platform.AggConfig) (*Region, error) { + client, err := newServiceClientForConfig(cfg, uaccount.NewClient) if err != nil { return nil, err } - resp, err := bc.GetRegion(req) + req := client.NewGetRegionRequest() + resp, err := client.GetRegion(req) if err != nil { return nil, err } @@ -135,7 +138,7 @@ func getAllRegions() ([]string, error) { return list, nil } -//仅在命令补全中使用,忽略错误 +// 仅在命令补全中使用,忽略错误 func getRegionList() []string { regionIns, err := fetchRegion() if err != nil { @@ -164,10 +167,13 @@ func getZoneList(region string) []string { return list } +var errNoDefaultProject = errors.New("No default project") + func getDefaultProject() (string, string, error) { - req := base.BizClient.NewGetProjectListRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewGetProjectListRequest() - resp, err := base.BizClient.GetProjectList(req) + resp, err := client.GetProjectList(req) if err != nil { return "", "", err } @@ -176,16 +182,17 @@ func getDefaultProject() (string, string, error) { return project.ProjectId, project.ProjectName, nil } } - return "", "", fmt.Errorf("No default project") + return "", "", errNoDefaultProject } -func getDefaultProjectWithConfig(cfg *base.AggConfig) (string, string, error) { - bc, err := base.GetBizClient(cfg) + +func getDefaultProjectWithConfig(cfg *platform.AggConfig) (string, string, error) { + client, err := newServiceClientForConfig(cfg, uaccount.NewClient) if err != nil { return "", "", err } - req := bc.NewGetProjectListRequest() - resp, err := bc.GetProjectList(req) + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) if err != nil { return "", "", err } @@ -194,17 +201,32 @@ func getDefaultProjectWithConfig(cfg *base.AggConfig) (string, string, error) { return project.ProjectId, project.ProjectName, nil } } - return "", "", fmt.Errorf("No default project") + return "", "", errNoDefaultProject +} + +// fetchProjectListWithConfig 用指定 profile 的凭证拉取完整项目列表(含默认标记) +func fetchProjectListWithConfig(cfg *platform.AggConfig) ([]uaccount.ProjectListInfo, error) { + client, err := newServiceClientForConfig(cfg, uaccount.NewClient) + if err != nil { + return nil, err + } + + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) + if err != nil { + return nil, err + } + return resp.ProjectSet, nil } -func fetchProjectWithConfig(cfg *base.AggConfig) (map[string]bool, error) { - bc, err := base.GetBizClient(cfg) +func fetchProjectWithConfig(cfg *platform.AggConfig) (map[string]bool, error) { + client, err := newServiceClientForConfig(cfg, uaccount.NewClient) if err != nil { return nil, err } - req := bc.NewGetProjectListRequest() - resp, err := bc.GetProjectList(req) + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) if err != nil { return nil, err } @@ -216,7 +238,7 @@ func fetchProjectWithConfig(cfg *base.AggConfig) (map[string]bool, error) { return projects, nil } -func getReasonableProject(cfg *base.AggConfig) (string, error) { +func getReasonableProject(cfg *platform.AggConfig) (string, error) { if cfg.ProjectID == "" { id, _, err := getDefaultProjectWithConfig(cfg) if err != nil { @@ -241,9 +263,10 @@ func isUserCertified(userInfo *uaccount.UserInfo) bool { } func getUserInfo() (*uaccount.UserInfo, error) { - req := base.BizClient.NewGetUserInfoRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewGetUserInfoRequest() var userInfo uaccount.UserInfo - resp, err := base.BizClient.GetUserInfo(req) + resp, err := client.GetUserInfo(req) if err != nil { return nil, err @@ -258,7 +281,7 @@ func getUserInfo() (*uaccount.UserInfo, error) { if err != nil { return nil, err } - fileFullPath := base.GetConfigDir() + "/user.json" + fileFullPath := platform.GetConfigDir() + "/user.json" err = ioutil.WriteFile(fileFullPath, bytes, 0600) if err != nil { return nil, err diff --git a/cmd/root.go b/cmd/root.go index c1f1d1a79e..8922d1133d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,32 +16,49 @@ package cmd import ( "fmt" + "io" "os" "strconv" + "strings" + "time" "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" + "github.com/ucloud/ucloud-cli/cmd/internal/version" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/pkg/ui" "github.com/ucloud/ucloud-sdk-go/ucloud/log" - - "github.com/ucloud/ucloud-cli/base" ) -var global = &base.Global +var global = &platform.Global -//NewCmdRoot 创建rootCmd rootCmd represents the base command when called without any subcommands +// NewCmdRoot 创建rootCmd rootCmd represents the base command when called without any subcommands func NewCmdRoot() *cobra.Command { cmd := &cobra.Command{ Use: "ucloud", - Short: "UCloud CLI v" + base.Version, + Short: "UCloud CLI v" + version.Version, Long: `UCloud CLI - manage UCloud resources and developer workflow`, DisableAutoGenTag: true, + // PersistentPreRun runs the per-invocation auth/config init for the + // executing command. Replaces the fork's OnInitialize(func(*cobra.Command)) + // (upstream OnInitialize takes func() and can't receive the command). It is + // inherited by all subcommands (none override PersistentPreRun), so it runs + // before every runnable command as the old OnInitialize did. The `api` + // command keeps bypassing this via the direct-Run path in Execute(). + PersistentPreRun: func(c *cobra.Command, args []string) { + initialize(c) + }, Run: func(cmd *cobra.Command, args []string) { + syncLegacyJSONFlag(os.Stdout) if global.Version { - base.Cxt.Printf("ucloud cli %s\n", base.Version) + platform.Cxt.Printf("ucloud cli %s\n", version.Version) } else if global.Completion { NewCmdCompletion().Run(cmd, args) } else if global.Config { - base.ListAggConfig(global.JSON) + platform.ListAggConfig(global.JSON) } else if global.Signup { NewCmdSignup().Run(cmd, args) } else { @@ -52,13 +69,15 @@ func NewCmdRoot() *cobra.Command { cmd.PersistentFlags().BoolVarP(&global.Debug, "debug", "d", false, "Running in debug mode") cmd.PersistentFlags().BoolVarP(&global.JSON, "json", "j", false, "Print result in JSON format whenever possible") + cmd.PersistentFlags().StringVar(&global.Output, "output", "", "Output format: table, json, or yaml. Defaults to json when stdout is not a TTY, else table") cmd.PersistentFlags().StringVarP(&global.Profile, "profile", "p", global.Profile, "Specifies the configuration for the operation") cmd.Flags().BoolVarP(&global.Version, "version", "v", false, "Display version") cmd.Flags().BoolVar(&global.Completion, "completion", false, "Turn on auto completion according to the prompt") cmd.Flags().BoolVar(&global.Config, "config", false, "Display configuration") cmd.Flags().BoolVar(&global.Signup, "signup", false, "Launch UCloud sign up page in browser") - cmd.PersistentFlags().SetFlagValuesFunc("profile", func() []string { return base.AggConfigListIns.GetProfileNameList() }) + command.SetPersistentCompletion(cmd, "profile", func() []string { return platform.AggConfigListIns.GetProfileNameList() }) + command.SetPersistentCompletion(cmd, "output", func() []string { return []string{"json", "table", "yaml"} }) cmd.SetHelpTemplate(helpTmpl) cmd.SetUsageTemplate(usageTmpl) resetHelpFunc(cmd) @@ -96,96 +115,294 @@ Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} ` -//概要帮助信息模板 +// 概要帮助信息模板 const usageTmpl = `Usage:{{if .Runnable}} {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} [command] {{if $size:=len .Commands}} {{"command may be" | printf "%-20s"}} {{range $index,$cmd:= .Commands}}{{if .IsAvailableCommand}}{{$cmd.Name}}{{if gt $size (add $index 1)}} | {{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableFlags}} - {{"flags may be" | printf "%-20s"}} {{.Flags.FlagNames}} + {{"flags may be" | printf "%-20s"}} {{flagNames .Flags}} Use "{{.CommandPath}} --help" for details.{{end}} ` +func newSchemaCmd() *cobra.Command { + return &cobra.Command{ + Use: "__schema", + Short: "Print a machine-readable schema of all commands (for tools/AI)", + Hidden: true, + Run: func(c *cobra.Command, args []string) { + out, err := cli.RenderSchemaJSON(c.Root()) + if err != nil { + platform.HandleError(err) + return + } + fmt.Fprintln(platform.Cxt.GetWriter(), out) + }, + } +} + +// productCtx is the cli.Context shared by all product commands. Its output +// format is finalized in initialize() (PersistentPreRun) after cobra parses +// --output; buildContext() runs at tree-construction time, before flag parsing, +// so the format it computes is provisional. +var productCtx *cli.Context + func addChildren(root *cobra.Command) { - out := base.Cxt.GetWriter() + addPlatformCommands(root) + productCtx = buildContext() + addProductCommands(root, registeredProducts(), productCtx) + applyGlobalOverrideFlags(root) +} + +// addPlatformCommands registers all built-in platform commands onto root. +// The set and order of AddCommand calls must stay identical to preserve +// the command-tree golden (hack/snapshot/testdata/cmdtree.golden). +func addPlatformCommands(root *cobra.Command) { + out := platform.Cxt.GetWriter() root.AddCommand(NewCmdInit()) + root.AddCommand(NewCmdAuth()) root.AddCommand(NewCmdDoc(out)) root.AddCommand(NewCmdConfig()) root.AddCommand(NewCmdRegion(out)) root.AddCommand(NewCmdProject()) - root.AddCommand(NewCmdUHost()) - root.AddCommand(NewCmdUPHost()) - root.AddCommand(NewCmdUImage()) - root.AddCommand(NewCmdSubnet()) - root.AddCommand(NewCmdVpc()) - root.AddCommand(NewCmdFirewall()) - root.AddCommand(NewCmdDisk()) - root.AddCommand(NewCmdEIP()) - root.AddCommand(NewCmdBandwidth()) - root.AddCommand(NewCmdUDPN(out)) - root.AddCommand(NewCmdULB()) - root.AddCommand(NewCmdGssh()) - root.AddCommand(NewCmdPathx()) - root.AddCommand(NewCmdMysql()) - root.AddCommand(NewCmdRedis()) - root.AddCommand(NewCmdMemcache()) - root.AddCommand(NewCmdExt()) + // uhost migrated to products/uhost (Part 6); registered via products.gen.go. + root.AddCommand(NewCmdAPI(out)) + root.AddCommand(NewCmdSignature()) + root.AddCommand(newSchemaCmd()) +} + +// addProductCommands registers product-package commands onto root. +// Each cli.Product contributes one or more top-level cobra commands. This runs +// after addPlatformCommands so product commands sort after platform ones +// when cobra.EnableCommandSorting is false. +func addProductCommands(root *cobra.Command, products []cli.Product, ctx *cli.Context) { + for _, p := range products { + root.AddCommand(p.NewCommand(ctx)...) + } +} + +// applyGlobalOverrideFlags adds the per-invocation override flags to +// every top-level command that is not in the exempt list. Running this after +// both addPlatformCommands and addProductCommands ensures product commands +// also receive the flags. +// +// Each flag registered here must also be scanned from os.Args in initGlobals: +// mergeConfigIns reads global.* before cobra parses flags, so registration +// alone is not enough for the override to take effect. +func applyGlobalOverrideFlags(root *cobra.Command) { for _, c := range root.Commands() { - if c.Name() != "init" && c.Name() != "gendoc" && c.Name() != "config" { + if c.Name() != "init" && c.Name() != "gendoc" && c.Name() != "config" && c.Name() != "auth" { c.PersistentFlags().StringVar(&global.PublicKey, "public-key", global.PublicKey, "Set public-key to override the public-key in local config file") c.PersistentFlags().StringVar(&global.PrivateKey, "private-key", global.PrivateKey, "Set private-key to override the private-key in local config file") c.PersistentFlags().StringVar(&global.BaseURL, "base-url", "", "Set base-url to override the base-url in local config file") + c.PersistentFlags().StringVar(&global.ChannelKey, "channel-key", "", "Set channel-key to override the channel-key in local config file") c.PersistentFlags().IntVar(&global.Timeout, "timeout-sec", 0, "Set timeout-sec to override the timeout-sec in local config file") + c.PersistentFlags().IntVar(&global.WaitTimeout, "wait-timeout-sec", 0, "Set the total timeout in seconds for synchronous wait/poll (e.g. cluster/host create). 0 uses the built-in default (600s)") c.PersistentFlags().IntVar(&global.MaxRetryTimes, "max-retry-times", -1, "Set max-retry-times to override the max-retry-times in local config file") } } } +// buildContext constructs the platform-level cli.Context from base globals +// and the cmd-package completion providers. Safe to call both under Execute +// (post-InitConfig) and AddChildrenForSnapshot (stubbed values). +func buildContext() *cli.Context { + return cli.NewContext(cli.Deps{ + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + Format: decideOutputFormat(os.Stdout), + DefaultsProvider: runtimeDefaults, + RegionList: getRegionList, + ZoneList: getZoneList, + ProjectList: getProjectList, + AllRegions: getAllRegions, + ClientConfig: runtimeClientConfig, + BuildCredential: runtimeCredential, + AttachHandlers: attachRuntimeHandlers, + HandleError: platform.HandleErrorTo, + LogInfo: platform.LogInfo, + LogPrint: platform.LogPrintTo, + LogWarn: platform.LogWarnTo, + LogError: platform.LogErrorTo, + LogFilePath: platform.GetLogFilePath, + NewPoller: cli.NewPoller, + }) +} + // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { + // Phase 3 脱敏扩面:panic 路径兜底,避免 panic 消息(可能含 token/header)原样落到 stderr + defer func() { + if r := recover(); r != nil { + fmt.Fprintln(os.Stderr, platform.Redact(fmt.Sprintf("panic: %v", r))) + os.Exit(1) + } + }() cmd := NewCmdRoot() - base.InitConfig() + if platform.InCloudShell { + err := platform.InitConfigInCloudShell() + if err != nil { + platform.HandleError(err) + return + } + } + platform.InitConfig() + if global.WaitTimeout > 0 { + cli.SetUserPollTimeout(time.Duration(global.WaitTimeout) * time.Second) + } + setActiveRuntimeFromBaseGlobals() + mode := os.Getenv("UCLOUD_CLI_DEBUG") + if mode == "on" || global.Debug { + if rt := ensureRuntime(); rt.SDKConfig != nil { + rt.SDKConfig.LogLevel = log.DebugLevel + } + } + addChildren(cmd) + + targetCmd, flags, err := cmd.Find(os.Args[1:]) + if err == nil { + if targetCmd.Use == "api" { + if targetCmd.RunE != nil { + if err := targetCmd.RunE(targetCmd, flags); err != nil { + os.Exit(1) + } + return + } + if targetCmd.Run != nil { + targetCmd.Run(targetCmd, flags) + } + return + } + } + if err := cmd.Execute(); err != nil { os.Exit(1) } + // A product command that reported an error via ctx.HandleError but used + // cobra Run (no return value) would otherwise exit 0. Set a non-zero code + // here. Exclude completion invocations: their dynamic-completion helpers + // call ctx.HandleError on transient API failures but must still yield + // candidates (or none) with rc=0 per shell-completion convention. + if productCtx != nil && productCtx.Failed() && !isCompletionInvocation(cmd) { + os.Exit(1) + } } func init() { + // usageTmpl uses the `add` template function (the command-list separator). + // The forked cobra registered it; upstream cobra (C2) does not, so without + // this the usage template fails to parse ("function \"add\" not defined") + // and panics whenever it renders — e.g. on any required-flag error. Register + // it once here so usage rendering works for every command. + cobra.AddTemplateFunc("add", func(a, b int) int { return a + b }) + // usageTmpl also used pflag's fork-only FlagSet.FlagNames; upstream pflag + // has no such method, so the template errored at render time. Provide an + // equivalent template func that lists the flag names. + cobra.AddTemplateFunc("flagNames", func(fs *pflag.FlagSet) string { + var names []string + fs.VisitAll(func(f *pflag.Flag) { names = append(names, f.Name) }) + return strings.Join(names, ", ") + }) + //-1表示不覆盖配置文件中的MaxRetryTimes参数 global.MaxRetryTimes = -1 - for idx, arg := range os.Args { - if arg == "--profile" && len(os.Args) > idx+1 && os.Args[idx+1] != "" { - global.Profile = os.Args[idx+1] + // 启动期预扫描:在 cobra 解析前把连接类参数落到 global.*,供 InitClientRuntime 用。 + // 每个 flag 都识别 `--flag value` 与 `--flag=value` 两形式(scanFlagValue)。 + // --profile 额外识别短选项 -p:漏识别会让 ConfigIns 指向错误 profile, + // OAuth 刷新可能把别人的 Bearer 重放到当前请求(见 platform/client.go 注释)。 + if v, ok := scanFlagValue(os.Args, "--profile", "-p"); ok { + global.Profile = v + } + if v, ok := scanFlagValue(os.Args, "--public-key"); ok { + global.PublicKey = v + } + if v, ok := scanFlagValue(os.Args, "--private-key"); ok { + global.PrivateKey = v + } + if v, ok := scanFlagValue(os.Args, "--base-url"); ok { + global.BaseURL = v + } + if v, ok := scanFlagValue(os.Args, "--channel-key"); ok { + global.ChannelKey = v + } + if v, ok := scanFlagValue(os.Args, "--timeout-sec"); ok { + sec, err := strconv.Atoi(v) + if err != nil { + fmt.Printf("parse timeout-sec failed: %v\n", err) + } else { + global.Timeout = sec } - if arg == "--public-key" && len(os.Args) > idx+1 && os.Args[idx+1] != "" { - global.PublicKey = os.Args[idx+1] + } + if v, ok := scanFlagValue(os.Args, "--max-retry-times"); ok { + times, err := strconv.Atoi(v) + if err != nil { + fmt.Printf("parse max-retry-times failed: %v\n", err) + } else { + global.MaxRetryTimes = times } - if arg == "--private-key" && len(os.Args) > idx+1 && os.Args[idx+1] != "" { - global.PrivateKey = os.Args[idx+1] + } + if sec, found, err := parseWaitTimeoutSec(os.Args); found { + if err != nil { + fmt.Printf("parse wait-timeout-sec failed: %v\n", err) + } else { + global.WaitTimeout = sec } - if arg == "--base-url" && len(os.Args) > idx+1 && os.Args[idx+1] != "" { - global.BaseURL = os.Args[idx+1] + } + cobra.EnableCommandSorting = false +} + +// scanFlagValue finds the value of any of names in args, recognizing both the +// space form (`--profile foo`) and the equals form (`--profile=foo`). Multiple +// names allow aliases, e.g. scanFlagValue(args, "--profile", "-p"). The first +// (leftmost) hit wins; an empty value (`--profile=` or a trailing `--profile` +// with no following arg) counts as no hit and scanning continues. +// +// This is the startup pre-scan that must run before cobra parses flags, because +// connection-class params (base-url/channel-key/profile/keys) must be settled +// before InitClientRuntime builds sdk.Config. Exact-comparison-only scans missed +// the equals form — the same #119 bug parseWaitTimeoutSec already fixed for +// --wait-timeout-sec. Attached/combined shorthand (`-pfoo`, `-dpfoo`) is out of +// scope: hand-parsing combined shorthand is error-prone and its residual risk +// matches today's behavior (the pre-scan never recognized `-p` at all). +func scanFlagValue(args []string, names ...string) (string, bool) { + for i, arg := range args { + for _, name := range names { + if arg == name { + if i+1 < len(args) && args[i+1] != "" { + return args[i+1], true + } + } else if v, ok := strings.CutPrefix(arg, name+"="); ok && v != "" { + return v, true + } } - if arg == "--timeout-sec" && len(os.Args) > idx+1 && os.Args[idx+1] != "" { - sec, err := strconv.Atoi(os.Args[idx+1]) - if err != nil { - fmt.Printf("parse timeout-sec failed: %v\n", err) - } else { - global.Timeout = sec + } + return "", false +} + +// parseWaitTimeoutSec scans args for --wait-timeout-sec in either +// `--wait-timeout-sec N` or `--wait-timeout-sec=N` form. found=true when +// the flag is present with a value; err is set when that value isn't an int. +func parseWaitTimeoutSec(args []string) (sec int, found bool, err error) { + const flag = "--wait-timeout-sec" + for idx, arg := range args { + if arg == flag { + if len(args) > idx+1 && args[idx+1] != "" { + sec, err = strconv.Atoi(args[idx+1]) + return sec, true, err } + continue } - if arg == "--max-retry-times" && len(os.Args) > idx+1 && os.Args[idx+1] != "" { - times, err := strconv.Atoi(os.Args[idx+1]) - if err != nil { - fmt.Printf("parse max-retry-times failed: %v\n", err) - } else { - global.MaxRetryTimes = times + if val, ok := strings.CutPrefix(arg, flag+"="); ok { + if val == "" { + continue } + sec, err = strconv.Atoi(val) + return sec, true, err } } - cobra.EnableCommandSorting = false - cobra.OnInitialize(initialize) + return 0, false, nil } func resetHelpFunc(cmd *cobra.Command) { @@ -197,36 +414,155 @@ func resetHelpFunc(cmd *cobra.Command) { } func initialize(cmd *cobra.Command) { + syncLegacyJSONFlag(os.Stdout) + + // Finalize the product output format now that cobra has parsed --output. + // buildContext() ran before flag parsing, so the format it set was + // provisional (always JSON for non-TTY stdout, ignoring an explicit + // --output). Recompute it here so `--output table` etc. take effect. + if productCtx != nil { + productCtx.SetFormat(decideOutputFormat(os.Stdout)) + } + flags := cmd.Flags() project, err := flags.GetString("project-id") if err == nil { - base.ClientConfig.ProjectId = project + if rt := ensureRuntime(); rt.SDKConfig != nil { + rt.SDKConfig.ProjectId = project + } } region, err := flags.GetString("region") if err == nil { - base.ClientConfig.Region = region + if rt := ensureRuntime(); rt.SDKConfig != nil { + rt.SDKConfig.Region = region + } } zone, err := flags.GetString("zone") if err == nil { - base.ClientConfig.Zone = zone + if rt := ensureRuntime(); rt.SDKConfig != nil { + rt.SDKConfig.Zone = zone + } } - mode := os.Getenv("UCLOUD_CLI_DEBUG") - if mode == "on" || global.Debug { - base.ClientConfig.LogLevel = log.DebugLevel - base.BizClient = base.NewClient(base.ClientConfig, base.AuthCredential) + if isAuthSkippedCmd(cmd) { + return + } + if platform.InCloudShell { + return } - if (cmd.Name() != "config" && cmd.Name() != "init" && cmd.Name() != "version") && (cmd.Parent() != nil && cmd.Parent().Name() != "config") { - if base.ConfigIns.PrivateKey == "" { - base.Cxt.Println("private-key is empty. Execute command 'ucloud init|config' to configure it or run 'ucloud config list' to check your configurations") - os.Exit(0) + rt := ensureRuntime() + if rt.Config.AuthMode == platform.AuthModeOAuth { + // AP-1:oauth 凭据缺失/失效 → stderr + 非零退出(不复制下方 aksk 路径的 exit 0 反模式) + isTTY := platform.IsStdinTTY() + if msg, ok := platform.CheckOAuthRunnable(rt.Config, isTTY); !ok { + fmt.Fprintln(os.Stderr, msg) + os.Exit(1) + } + if err := platform.EnsureFreshToken(rt.Config, rt.Configs); err != nil { + fmt.Fprintln(os.Stderr, platform.OAuthRefreshFailedHint(rt.Config.Profile, isTTY, err)) + os.Exit(1) } - if base.ConfigIns.PublicKey == "" { - base.Cxt.Println("public-key is empty. Execute command 'ucloud init|config' to configure it or run 'ucloud config list' to check your configurations") - os.Exit(0) + debugOn := rt.SDKConfig != nil && rt.SDKConfig.LogLevel == log.DebugLevel + if err := platform.InitClientRuntime(rt.Config); err != nil { + platform.HandleError(err) + } + setActiveRuntimeFromBaseGlobals() + if debugOn { + ensureRuntime().SDKConfig.LogLevel = log.DebugLevel + } + return + } + + // 既有 AK/SK 检查,原样保留(CRITICAL 回归约束:行为与文案零变化) + if rt.Config.PrivateKey == "" { + platform.Cxt.Println("private-key is empty. Execute command 'ucloud init|config' to configure it or run 'ucloud config list' to check your configurations") + os.Exit(0) + } + if rt.Config.PublicKey == "" { + platform.Cxt.Println("public-key is empty. Execute command 'ucloud init|config' to configure it or run 'ucloud config list' to check your configurations") + os.Exit(0) + } +} + +func syncLegacyJSONFlag(out io.Writer) { + global.JSON = decideOutputFormat(out) == cli.OutputJSON +} + +// decideOutputFormat resolves the effective output format: explicit --output +// wins; then legacy --json; otherwise JSON for non-TTY stdout, Table for TTY. +func decideOutputFormat(out io.Writer) cli.OutputFormat { + switch strings.ToLower(global.Output) { + case "json": + return cli.OutputJSON + case "yaml": + return cli.OutputYAML + case "table": + return cli.OutputTable + } + if global.JSON { + return cli.OutputJSON + } + if ui.IsTTY(out) { + return cli.OutputTable + } + return cli.OutputJSON +} + +// isAuthSkippedCmd 启动凭据检查跳过清单(D7:login/logout/help/version/config/init) +func isAuthSkippedCmd(cmd *cobra.Command) bool { + if cmd.Parent() == nil { + return true // root 命令本身(--version/--config/help),与历史行为一致 + } + switch cmd.Name() { + case "config", "init", "version", "login", "logout", "help", "auth", "__schema": + return true + } + if cmd.Parent() != nil && (cmd.Parent().Name() == "config" || cmd.Parent().Name() == "auth") { + return true + } + return false +} + +// isCompletionInvocation reports whether this process is a shell-completion +// request, whose exit code must stay 0 regardless of transient +// completion-helper errors (shell-completion convention). +// +// Two cases are covered: +// - The cobra dynamic-completion hot path (ucloud __complete / __completeNoDesc +// ...): always invoked flag-free by the shell, so a literal os.Args[1] check +// is sufficient and cheap. +// - The user-typed `completion` subcommand (e.g. `ucloud completion zsh`), +// which may carry leading global flags (`ucloud --debug completion zsh`) so a +// positional os.Args check is not enough. We re-resolve the target command +// via cobra Find (which strips flags) and treat it as completion when the +// resolved command or any ancestor is a completion command. Find is called +// after cmd.Execute() has run, by which point cobra has lazily registered the +// default `completion` command onto the tree. +func isCompletionInvocation(root *cobra.Command) bool { + if len(os.Args) >= 2 { + switch os.Args[1] { + case "__complete", "__completeNoDesc": + return true + } + } + if t, _, err := root.Find(os.Args[1:]); err == nil && isCompletionCommand(t) { + return true + } + return false +} + +// isCompletionCommand reports whether c or any of its ancestors is a +// shell-completion command (the cobra `completion` generator or the dynamic +// `__complete`/`__completeNoDesc` helpers). +func isCompletionCommand(c *cobra.Command) bool { + for ; c != nil; c = c.Parent() { + switch c.Name() { + case "__complete", "__completeNoDesc", "completion": + return true } } + return false } diff --git a/cmd/root_completion_test.go b/cmd/root_completion_test.go new file mode 100644 index 0000000000..3dcce4dfc9 --- /dev/null +++ b/cmd/root_completion_test.go @@ -0,0 +1,14 @@ +package cmd + +import "testing" + +// TestProfileCompletionRegistered guards against the regression where --profile +// completion was dropped. "profile" is a persistent flag; upstream cobra's +// RegisterFlagCompletionFunc (via command.SetPersistentCompletion) registers the +// completion on the command and GetFlagCompletionFunc resolves it. +func TestProfileCompletionRegistered(t *testing.T) { + root := NewCmdRoot() + if _, ok := root.GetFlagCompletionFunc("profile"); !ok { + t.Fatal("profile completion not registered for persistent --profile flag") + } +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000000..a18b02b347 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,182 @@ +package cmd + +import "testing" + +// TestParseWaitTimeoutSec covers the --wait-timeout-sec startup pre-scan +// helper, which must accept both the space form (--wait-timeout-sec 1800) +// and the equals form (--wait-timeout-sec=1800). See issue #119: the equals +// form was previously silently ignored because init()'s manual os.Args scan +// only matched arg == "--wait-timeout-sec" exactly. +func TestParseWaitTimeoutSec(t *testing.T) { + tests := []struct { + name string + args []string + wantSec int + wantFound bool + wantErr bool + }{ + { + name: "space form", + args: []string{"ucloud", "uhost", "--wait-timeout-sec", "1800"}, + wantSec: 1800, + wantFound: true, + wantErr: false, + }, + { + name: "equals form", + args: []string{"ucloud", "uhost", "--wait-timeout-sec=1800"}, + wantSec: 1800, + wantFound: true, + wantErr: false, + }, + { + name: "absent", + args: []string{"ucloud", "uhost"}, + wantSec: 0, + wantFound: false, + wantErr: false, + }, + { + name: "equals with bad int", + args: []string{"ucloud", "--wait-timeout-sec=abc"}, + wantSec: 0, + wantFound: true, + wantErr: true, + }, + { + name: "space with bad int", + args: []string{"ucloud", "--wait-timeout-sec", "abc"}, + wantSec: 0, + wantFound: true, + wantErr: true, + }, + { + name: "trailing flag no value", + args: []string{"ucloud", "--wait-timeout-sec"}, + wantSec: 0, + wantFound: false, + wantErr: false, + }, + { + name: "empty equals", + args: []string{"ucloud", "--wait-timeout-sec="}, + wantSec: 0, + wantFound: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sec, found, err := parseWaitTimeoutSec(tt.args) + if sec != tt.wantSec { + t.Errorf("sec = %d, want %d", sec, tt.wantSec) + } + if found != tt.wantFound { + t.Errorf("found = %v, want %v", found, tt.wantFound) + } + if (err != nil) != tt.wantErr { + t.Errorf("err = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestScanFlagValue covers the generalized startup pre-scan helper. Same #119 +// bug as parseWaitTimeoutSec (equals form silently ignored), now for the +// connection-class flags. The critical -pfoo case pins the R3 boundary: +// attached/combined shorthand is intentionally NOT recognized. +func TestScanFlagValue(t *testing.T) { + tests := []struct { + name string + args []string + names []string + wantVal string + wantFound bool + }{ + { + name: "long space form", + args: []string{"ucloud", "config", "--profile", "foo"}, + names: []string{"--profile", "-p"}, + wantVal: "foo", + wantFound: true, + }, + { + name: "long equals form", + args: []string{"ucloud", "config", "--profile=foo"}, + names: []string{"--profile", "-p"}, + wantVal: "foo", + wantFound: true, + }, + { + name: "short space form", + args: []string{"ucloud", "config", "-p", "foo"}, + names: []string{"--profile", "-p"}, + wantVal: "foo", + wantFound: true, + }, + { + name: "short equals form", + args: []string{"ucloud", "config", "-p=foo"}, + names: []string{"--profile", "-p"}, + wantVal: "foo", + wantFound: true, + }, + { + // R3 boundary: attached shorthand is out of scope, must NOT match. + name: "attached shorthand not recognized", + args: []string{"ucloud", "config", "-pfoo"}, + names: []string{"--profile", "-p"}, + wantVal: "", + wantFound: false, + }, + { + name: "empty equals is no hit", + args: []string{"ucloud", "config", "--profile="}, + names: []string{"--profile", "-p"}, + wantVal: "", + wantFound: false, + }, + { + name: "trailing flag no value", + args: []string{"ucloud", "config", "--profile"}, + names: []string{"--profile", "-p"}, + wantVal: "", + wantFound: false, + }, + { + name: "absent", + args: []string{"ucloud", "config"}, + names: []string{"--profile", "-p"}, + wantVal: "", + wantFound: false, + }, + { + name: "base-url equals form single name", + args: []string{"ucloud", "uhost", "list", "--base-url=http://x/"}, + names: []string{"--base-url"}, + wantVal: "http://x/", + wantFound: true, + }, + { + // leftmost hit wins (pre-scan only needs one early value, no override) + name: "leftmost wins", + args: []string{"ucloud", "--profile", "a", "--profile", "b"}, + names: []string{"--profile", "-p"}, + wantVal: "a", + wantFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, found := scanFlagValue(tt.args, tt.names...) + if val != tt.wantVal { + t.Errorf("val = %q, want %q", val, tt.wantVal) + } + if found != tt.wantFound { + t.Errorf("found = %v, want %v", found, tt.wantFound) + } + }) + } +} diff --git a/cmd/runtime.go b/cmd/runtime.go new file mode 100644 index 0000000000..694b12b8ed --- /dev/null +++ b/cmd/runtime.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "fmt" + + "github.com/ucloud/ucloud-cli/cmd/internal/platform" + "github.com/ucloud/ucloud-cli/pkg/command" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" +) + +type runtimeState struct { + Configs *platform.AggConfigManager + Config *platform.AggConfig + SDKConfig *sdk.Config + Credential *platform.CredentialConfig +} + +var activeRuntime *runtimeState +var runtimeAutoStub = true + +func buildRuntimeFromBaseGlobals() *runtimeState { + return &runtimeState{ + Configs: platform.AggConfigListIns, + Config: platform.ConfigIns, + SDKConfig: platform.ClientConfig, + Credential: platform.AuthCredential, + } +} + +func ensureRuntime() *runtimeState { + if activeRuntime == nil { + activeRuntime = buildRuntimeFromBaseGlobals() + } + if runtimeAutoStub && activeRuntime.SDKConfig == nil { + activeRuntime.SDKConfig = &sdk.Config{BaseUrl: platform.DefaultBaseURL} + platform.ClientConfig = activeRuntime.SDKConfig + } + if runtimeAutoStub && activeRuntime.Credential == nil { + activeRuntime.Credential = &platform.CredentialConfig{} + platform.AuthCredential = activeRuntime.Credential + } + return activeRuntime +} + +func setActiveRuntimeFromBaseGlobals() { + runtimeAutoStub = true + activeRuntime = buildRuntimeFromBaseGlobals() +} + +func runtimeDefaults() command.Defaults { + rt := ensureRuntime() + if rt == nil || rt.Config == nil { + return command.Defaults{} + } + return command.Defaults{Region: rt.Config.Region, Zone: rt.Config.Zone, ProjectID: rt.Config.ProjectID} +} + +func runtimeClientConfig() *sdk.Config { + rt := ensureRuntime() + if rt == nil { + return nil + } + if !runtimeAutoStub && rt.SDKConfig == nil { + panic("cmd runtime disabled for snapshot completion") + } + return rt.SDKConfig +} + +func runtimeCredential() *auth.Credential { + rt := ensureRuntime() + if rt == nil { + return platform.BuildCredentialFrom(nil) + } + return platform.BuildCredentialFrom(rt.Credential) +} + +func attachRuntimeHandlers(sc sdk.ServiceClient) { + rt := ensureRuntime() + if rt == nil { + platform.AttachHandlersWith(sc, nil, nil, nil) + return + } + platform.AttachHandlersWith(sc, rt.Credential, rt.Config, rt.Configs) +} + +func newServiceClient[T sdk.ServiceClient](ctor func(*sdk.Config, *auth.Credential) T) T { + rt := ensureRuntime() + if rt == nil || rt.SDKConfig == nil { + panic("cmd runtime is not initialized") + } + client := ctor(rt.SDKConfig, platform.BuildCredentialFrom(rt.Credential)) + platform.AttachHandlersWith(client, rt.Credential, rt.Config, rt.Configs) + return client +} + +func newServiceClientForConfig[T sdk.ServiceClient](cfg *platform.AggConfig, ctor func(*sdk.Config, *auth.Credential) T) (T, error) { + var zero T + sdkConfig, credConfig, err := platform.BuildClientRuntime(cfg) + if sdkConfig == nil { + return zero, fmt.Errorf("build sdk config failed") + } + client := ctor(sdkConfig, platform.BuildCredentialFrom(credConfig)) + rt := ensureRuntime() + var manager *platform.AggConfigManager + if rt != nil { + manager = rt.Configs + } + platform.AttachHandlersWith(client, credConfig, cfg, manager) + return client, err +} diff --git a/cmd/schema_test.go b/cmd/schema_test.go new file mode 100644 index 0000000000..aecd9d91bf --- /dev/null +++ b/cmd/schema_test.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func TestSchemaJSON(t *testing.T) { + root := NewCmdRoot() + AddChildrenForSnapshot(root) + + out, err := cli.RenderSchemaJSON(root) + if err != nil { + t.Fatalf("RenderSchemaJSON error: %v", err) + } + + // Must be valid JSON. + if !json.Valid([]byte(out)) { + t.Fatalf("output is not valid JSON:\n%s", out) + } + + // Must contain the deep mysql db create path. + if !strings.Contains(out, "mysql db create") { + t.Fatalf("output missing 'mysql db create':\n%s", out[:min(len(out), 500)]) + } + + // Must contain at least one known flag from mysql db create. + if !strings.Contains(out, `"charge-type"`) && !strings.Contains(out, `"vpc-id"`) { + t.Fatalf("output missing expected flags from mysql db create (charge-type or vpc-id):\n%s", out[:min(len(out), 500)]) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cmd/signature.go b/cmd/signature.go new file mode 100644 index 0000000000..1e4bcc2c53 --- /dev/null +++ b/cmd/signature.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "bytes" + "fmt" + "net/url" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" +) + +func NewCmdSignature() *cobra.Command { + var ( + rawParams []string + privateKey string + rawURL string + ) + cmd := &cobra.Command{ + Use: "signature", + Short: "Calculate ucloud signature", + Long: "Calculate ucloud signature", + + Aliases: []string{"sign"}, + + Run: func(cmd *cobra.Command, args []string) { + var params map[string]interface{} + if rawURL != "" { + // Parse params from exists url + parsedURL, err := url.Parse(rawURL) + if err != nil { + fmt.Printf("error: failed to parse url %q: %v\n", rawURL, err) + return + } + query := parsedURL.Query() + params = make(map[string]interface{}, len(query)) + for key, values := range query { + if key == "Signature" { + fmt.Println("error: the `Signature` cannot be placed in url") + return + } + if len(values) == 0 { + continue + } + val := values[0] + params[key] = val + } + } + if len(rawParams) > 0 { + if params == nil { + params = make(map[string]interface{}, len(rawParams)) + } + for _, rawParam := range rawParams { + kv := strings.Split(rawParam, "=") + if len(kv) != 2 { + fmt.Printf("error: param %q is invalid\n", rawParam) + return + } + params[kv[0]] = kv[1] + } + } + if len(params) == 0 { + fmt.Println("error: missing param") + return + } + + r := auth.CalculateSignature(params, privateKey) + + var colorParamBuf bytes.Buffer + for _, key := range r.SortedKeys { + val := params[key] + colorParamBuf.WriteString(color.GreenString(key)) + colorParamBuf.WriteString(color.CyanString("%v", val)) + } + colorParamBuf.WriteString(color.MagentaString(privateKey)) + fmt.Println("") + fmt.Printf("ParamStr: %s\n", colorParamBuf.String()) + fmt.Println("") + + fmt.Printf("Signature: %s\n", color.BlueString(r.Sign)) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + flags.StringArrayVarP(&rawParams, "param", "m", nil, "Request params") + flags.StringVarP(&privateKey, "private-key", "k", "", "Private key") + flags.StringVarP(&rawURL, "url", "u", "", "Request url without signature") + cmd.MarkFlagRequired("private-key") + + return cmd +} diff --git a/cmd/signup.go b/cmd/signup.go index 6f5cb2546d..4a3ffd9391 100644 --- a/cmd/signup.go +++ b/cmd/signup.go @@ -22,7 +22,7 @@ import ( "github.com/spf13/cobra" ) -//NewCmdSignup ucloud signup +// NewCmdSignup ucloud signup func NewCmdSignup() *cobra.Command { var cmd = &cobra.Command{ Use: "signup", diff --git a/cmd/snapshot_export.go b/cmd/snapshot_export.go new file mode 100644 index 0000000000..a699333b52 --- /dev/null +++ b/cmd/snapshot_export.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/spf13/cobra" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/cmd/internal/platform" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// AddChildrenForSnapshot builds the full command tree for the structure golden, +// without InitConfig/network side effects. Test-only helper. +// +// Some NewCmdXxx constructors create service-specific SDK requests at +// construction time, so runtime SDK config and credential must be non-nil. We +// initialise them with zero-credential stubs when InitConfig was skipped. +func AddChildrenForSnapshot(root *cobra.Command) { + runtimeAutoStub = true + if platform.ClientConfig == nil { + platform.ClientConfig = &sdk.Config{BaseUrl: platform.DefaultBaseURL} + } + if platform.AuthCredential == nil { + platform.AuthCredential = &platform.CredentialConfig{} + } + setActiveRuntimeFromBaseGlobals() + addChildren(root) +} + +// DisableRuntimeForSnapshotCompletion poisons runtime-backed dynamic +// completions after command construction, so snapshot rendering does not issue +// real network calls. It mirrors the old test behavior of nil-ing platform.BizClient +// after AddChildrenForSnapshot. +func DisableRuntimeForSnapshotCompletion() { + platform.ClientConfig = nil + platform.AuthCredential = nil + runtimeAutoStub = false + activeRuntime = buildRuntimeFromBaseGlobals() +} + +// ProductsForSnapshot exposes the registered product list to the snapshot +// golden tests (hack/snapshot): each product's subtree is rendered against +// the golden the product team owns (products//testdata/). Test-only. +func ProductsForSnapshot() []cli.Product { return registeredProducts() } diff --git a/cmd/test_helpers_test.go b/cmd/test_helpers_test.go new file mode 100644 index 0000000000..030a324ea6 --- /dev/null +++ b/cmd/test_helpers_test.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func subCmd(t *testing.T, root *cobra.Command, name string) *cobra.Command { + t.Helper() + for _, c := range root.Commands() { + if c.Use == name { + return c + } + } + t.Fatalf("uhost subcommand %q not found", name) + return nil +} + +func topLevelCmd(t *testing.T, commands []*cobra.Command, name string) *cobra.Command { + t.Helper() + for _, c := range commands { + if c.Use == name { + return c + } + } + t.Fatalf("product top-level command %q not found", name) + return nil +} diff --git a/cmd/udb.go b/cmd/udb.go deleted file mode 100644 index 9dc0adf1f9..0000000000 --- a/cmd/udb.go +++ /dev/null @@ -1,639 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "bufio" - "encoding/base64" - "fmt" - "io" - "os" - "strconv" - "strings" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-sdk-go/services/udb" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/model/status" -) - -//NewCmdUDBConf ucloud udb conf -func NewCmdUDBConf() *cobra.Command { - cmd := &cobra.Command{ - Use: "conf", - Short: "List and manipulate configuration files of MySQL instances", - Long: "List and manipulate configuration files of MySQL instances", - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdUDBConfList(out)) - cmd.AddCommand(NewCmdUDBConfDescribe(out)) - cmd.AddCommand(NewCmdUDBConfClone(out)) - cmd.AddCommand(NewCmdUDBConfUpload(out)) - cmd.AddCommand(NewCmdUDBConfUpdate(out)) - cmd.AddCommand(NewCmdUDBConfDelete(out)) - cmd.AddCommand(NewCmdUDBConfApply(out)) - cmd.AddCommand(NewCmdUDBConfDownload(out)) - return cmd -} - -//UDBConfRow 表格行 -type UDBConfRow struct { - ConfID int - DBVersion string - Name string - Description string - Modifiable bool - Zone string -} - -var dbTypeMap = map[string]string{ - "mysql": "sql", - "mongodb": "nosql", - "postgresql": "postgresql", - "sqlserver": "sqlserver", -} - -var dbTypeList = []string{"mysql", "mongodb", "postgresql", "sqlserver"} - -//NewCmdUDBConfList ucloud mysql conf list -func NewCmdUDBConfList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeUDBParamGroupRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List configuartion files of MySQL instances", - Long: "List configuartion files of MySQL instances", - Run: func(c *cobra.Command, args []string) { - if *req.GroupId == 0 { - req.GroupId = nil - } - resp, err := base.BizClient.DescribeUDBParamGroup(req) - if err != nil { - base.HandleError(err) - return - } - list := []UDBConfRow{} - for _, ins := range resp.DataSet { - row := UDBConfRow{ - ConfID: ins.GroupId, - Name: ins.GroupName, - Zone: ins.Zone, - DBVersion: ins.DBTypeId, - Description: ins.Description, - Modifiable: ins.Modifiable, - } - list = append(list, row) - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - bindOffset(req, flags) - bindLimit(req, flags) - req.GroupId = flags.Int("conf-id", 0, "Optional. Configuration identifier for the configuration to be described") - req.ClassType = sdk.String("sql") - - flags.SetFlagValuesFunc("conf-id", func() []string { - return getConfIDList(*req.ClassType, *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -//UDBConfParamRow 参数配置展示表格行 -type UDBConfParamRow struct { - Key string - Value string -} - -//NewCmdUDBConfDescribe ucloud udb conf describe -func NewCmdUDBConfDescribe(out io.Writer) *cobra.Command { - var confID string - req := base.BizClient.NewDescribeUDBParamGroupRequest() - req.RegionFlag = sdk.Bool(false) - cmd := &cobra.Command{ - Use: "describe", - Short: "Display details about a configuration file of MySQL instance", - Long: "Display details about a configuration file of MySQL instance", - Run: func(c *cobra.Command, args []string) { - id, err := strconv.Atoi(base.PickResourceID(confID)) - if err != nil { - base.HandleError(err) - return - } - req.GroupId = &id - resp, err := base.BizClient.DescribeUDBParamGroup(req) - if err != nil { - base.HandleError(err) - return - } - if len(resp.DataSet) != 1 { - fmt.Fprintf(out, "Error, conf-id[%d] may not be exist\n", req.GroupId) - return - } - conf := resp.DataSet[0] - attrs := []base.DescribeTableRow{ - base.DescribeTableRow{Attribute: "ConfID", Content: strconv.Itoa(conf.GroupId)}, - base.DescribeTableRow{Attribute: "DBVersion", Content: conf.DBTypeId}, - base.DescribeTableRow{Attribute: "Name", Content: conf.GroupName}, - base.DescribeTableRow{Attribute: "Description", Content: conf.Description}, - base.DescribeTableRow{Attribute: "Modifiable", Content: strconv.FormatBool(conf.Modifiable)}, - base.DescribeTableRow{Attribute: "Zone", Content: conf.Zone}, - } - fmt.Fprintln(out, "Attributes:") - base.PrintList(attrs, out) - - params := []UDBConfParamRow{} - for _, p := range conf.ParamMember { - if p.Value == "" { - continue - } - row := UDBConfParamRow{ - Key: p.Key, - Value: p.Value, - } - params = append(params, row) - } - fmt.Fprintln(out, "\nParameters:") - base.PrintList(params, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVar(&confID, "conf-id", "", "Requried. Configuration identifier for the configuration to be described") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("conf-id") - flags.SetFlagValuesFunc("conf-id", func() []string { - return getConfIDList("sql", *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -//NewCmdUDBConfClone ucloud udb conf clone -func NewCmdUDBConfClone(out io.Writer) *cobra.Command { - var srcConfID string - req := base.BizClient.NewCreateUDBParamGroupRequest() - cmd := &cobra.Command{ - Use: "clone", - Short: "Create configuration file by cloning existed configuration", - Long: "Create configuration file by cloning existed configuration", - Run: func(c *cobra.Command, args []string) { - id, err := strconv.Atoi(base.PickResourceID(srcConfID)) - if err != nil { - base.HandleError(err) - return - } - if *req.DBTypeId == "" { - confIns, err := getConfByID(id, *req.ProjectId, *req.Region, *req.Zone) - if err != nil { - base.HandleError(err) - return - } - req.DBTypeId = sdk.String(confIns.DBTypeId) - } - req.SrcGroupId = &id - resp, err := base.BizClient.CreateUDBParamGroup(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "conf[%d] created\n", resp.GroupId) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.DBTypeId = flags.String("db-version", "", fmt.Sprintf("Required. Version of DB. Accept values:%s", strings.Join(dbVersionList, ", "))) - req.GroupName = flags.String("name", "", "Required. Name of configuration. It's length should be between 6 and 63") - req.Description = flags.String("description", " ", "Optional. Description of the configuration to clone") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - flags.StringVar(&srcConfID, "src-conf-id", "", "Optional. The ConfID of source configuration which to be cloned from") - - flags.SetFlagValues("db-version", dbVersionList...) - flags.SetFlagValuesFunc("src-conf-id", func() []string { - return getConfIDList("sql", *req.ProjectId, *req.Region, *req.Zone) - }) - - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("src-conf-id") - return cmd -} - -var udbSubtypeMap = map[string]int{ - "unknow": 0, - "Shardsvr-MMAPv1": 1, - "Shardsvr-WiredTiger": 2, - "Configsvr-MMAPv1": 3, - "Configsvr-WiredTiger": 4, - "Mongos": 5, - "Mysql": 10, - "Postgresql": 20, -} - -var subtypeList = []string{"Shardsvr-MMAPv1", "Shardsvr-WiredTiger", "Configsvr-MMAPv1", "Configsvr-WiredTiger", "Mongos", "Mysql", "Postgresql"} - -//NewCmdUDBConfUpload ucloud udb conf upload -func NewCmdUDBConfUpload(out io.Writer) *cobra.Command { - var file string - req := base.BizClient.NewUploadUDBParamGroupRequest() - cmd := &cobra.Command{ - Use: "upload", - Short: "Create configuration file by uploading local DB configuration file", - Long: "Create configuration file by uploading local DB configuration file", - Run: func(c *cobra.Command, args []string) { - content, err := readFile(file) - if err != nil { - base.HandleError(err) - return - } - if l := len(*req.GroupName); l < 6 || l > 63 { - fmt.Fprintln(out, "Error, length of name shoud be between 6 and 63") - return - } - req.Content = sdk.String(base64.StdEncoding.EncodeToString([]byte(content))) - req.ParamGroupTypeId = sdk.Int(10) - resp, err := base.BizClient.UploadUDBParamGroup(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "conf[%d] uploaded\n", resp.GroupId) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVar(&file, "conf-file", "", "Required. Path of local configuration file") - req.DBTypeId = flags.String("db-version", "", fmt.Sprintf("Required. Version of DB. Accept values:%s", strings.Join(dbVersionList, ", "))) - req.GroupName = flags.String("name", "", "Required. Name of configuration. It's length should be between 6 and 63") - req.Description = flags.String("description", " ", "Optional. Description of the configuration to clone") - // flags.StringVar(&subtype, "db-type", "", fmt.Sprintf("Optional. DB type. Accept values: %s", strings.Join(subtypeList, ", "))) - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("conf-file") - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("db-version") - // cmd.MarkFlagRequired("db-type") - - flags.SetFlagValues("db-version", dbVersionList...) - // flags.SetFlagValues("db-type", subtypeList...) - flags.SetFlagValuesFunc("conf-file", func() []string { - return base.GetFileList("") - }) - return cmd -} - -//NewCmdUDBConfUpdate ucloud udb conf update -func NewCmdUDBConfUpdate(out io.Writer) *cobra.Command { - var confID, key, value, file string - req := base.BizClient.NewUpdateUDBParamGroupRequest() - cmd := &cobra.Command{ - Use: "update", - Short: "Update parameters of DB's configuration", - Long: "Update parameters of DB's configuration", - Run: func(c *cobra.Command, args []string) { - id, err := strconv.Atoi(base.PickResourceID(confID)) - if err != nil { - base.HandleError(err) - return - } - req.GroupId = &id - - if key != "" && value != "" { - req.Key = &key - req.Value = &value - _, err := base.BizClient.UpdateUDBParamGroup(req) - if err != nil { - base.HandleError(err) - } else { - fmt.Printf("conf[%s]'sparameter[%s = %s] updated\n", confID, key, value) - } - } - if file != "" { - params, err := parseParam(file) - if err != nil { - base.HandleError(err) - return - } - for _, p := range params { - req.Key = sdk.String(p.Key) - req.Value = sdk.String(p.Value) - _, err := base.BizClient.UpdateUDBParamGroup(req) - if err != nil { - fmt.Printf("conf[%s]'sparameter[%s = %s] failed\n", confID, p.Key, p.Value) - base.HandleError(err) - } else { - fmt.Printf("conf[%s]'sparameter[%s = %s] updated\n", confID, p.Key, p.Value) - } - fmt.Println("") - } - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - - flags.StringVar(&confID, "conf-id", "", "Required. ConfID of configuration to update") - flags.StringVar(&key, "key", "", "Optional. Key of parameter") - flags.StringVar(&value, "value", "", "Optional. Value of parameter") - flags.StringVar(&file, "file", "", "Optional. Path of file in which each parameter occupies one line with format 'key = value'") - - flags.SetFlagValuesFunc("conf-id", func() []string { - return getModifiableConfIDList("", *req.ProjectId, *req.Region, *req.Zone) - }) - flags.SetFlagValuesFunc("file", func() []string { - return base.GetFileList("") - }) - - cmd.MarkFlagRequired("conf-id") - return cmd -} - -//NewCmdUDBConfDelete ucloud udb conf delete -func NewCmdUDBConfDelete(out io.Writer) *cobra.Command { - var confID string - req := base.BizClient.NewDeleteUDBParamGroupRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete configuration of udb by conf-id", - Long: "Delete configuration of udb by conf-id", - Run: func(c *cobra.Command, args []string) { - id, err := strconv.Atoi(base.PickResourceID(confID)) - if err != nil { - base.HandleError(err) - return - } - req.GroupId = &id - _, err = base.BizClient.DeleteUDBParamGroup(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "conf[%s] deleted\n", confID) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVar(&confID, "conf-id", "", "Required. ConfID of the configuration to delete") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("conf-id") - flags.SetFlagValuesFunc("conf-id", func() []string { - return getModifiableConfIDList("", *req.ProjectId, *req.Region, *req.Zone) - }) - return cmd -} - -// NewCmdUDBConfApply ucloud udb conf apply -func NewCmdUDBConfApply(out io.Writer) *cobra.Command { - var confID string - var udbIDs []string - var restart, yes, async bool - - req := base.BizClient.NewChangeUDBParamGroupRequest() - cmd := &cobra.Command{ - Use: "apply", - Short: "Apply configuration for UDB instances", - Long: "Apply configuration for UDB instances", - Run: func(c *cobra.Command, args []string) { - req.GroupId = sdk.String(base.PickResourceID(confID)) - for _, idname := range udbIDs { - req.DBId = sdk.String(base.PickResourceID(idname)) - _, err := base.BizClient.ChangeUDBParamGroup(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "conf[%s] has applied for udb[%s]\n", confID, idname) - if !restart { - continue - } - ok := base.Confirm(yes, fmt.Sprintf("udb[%s] is about to restart, do you want to continue?", idname)) - if !ok { - continue - } - restartReq := base.BizClient.NewRestartUDBInstanceRequest() - restartReq.Region = req.Region - restartReq.Zone = req.Zone - restartReq.ProjectId = req.ProjectId - restartReq.DBId = req.DBId - _, err = base.BizClient.RestartUDBInstance(restartReq) - if err != nil { - base.HandleError(err) - continue - } - if async { - fmt.Fprintf(out, "udb[%s] is restarting\n", idname) - } else { - text := fmt.Sprintf("udb[%s] is restarting", idname) - poller.Spoll(*req.DBId, text, []string{status.UDB_FAIL, status.UDB_RUNNING}) - } - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVar(&confID, "conf-id", "", "Required. ConfID of the configuration to be applied") - flags.StringSliceVar(&udbIDs, "udb-id", nil, "Required. Resource ID of UDB instances to change configuration") - flags.BoolVar(&restart, "restart-after-apply", true, "Optional. The new configuration will take effect after DB restarts") - flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation") - flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("conf-id") - cmd.MarkFlagRequired("udb-id") - - flags.SetFlagValuesFunc("conf-id", func() []string { - return getModifiableConfIDList("", *req.ProjectId, *req.Region, *req.Zone) - }) - flags.SetFlagValuesFunc("udb-id", func() []string { - return getUDBIDList(nil, "", *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -//NewCmdUDBConfDownload ucloud udb conf download -func NewCmdUDBConfDownload(out io.Writer) *cobra.Command { - var confID string - req := base.BizClient.NewExtractUDBParamGroupRequest() - cmd := &cobra.Command{ - Use: "download", - Short: "Download UDB configuration", - Long: "Download UDB configuration", - Run: func(c *cobra.Command, args []string) { - id, err := strconv.Atoi(base.PickResourceID(confID)) - if err != nil { - base.HandleError(err) - return - } - - req.GroupId = &id - resp, err := base.BizClient.ExtractUDBParamGroup(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprint(out, resp.Content) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVar(&confID, "conf-id", "", "Required. ConfID of configuration to download") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("conf-id") - - flags.SetFlagValuesFunc("conf-id", func() []string { - return getConfIDList("sql", *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -type confParam struct { - Key string - Value string -} - -func parseParam(filePath string) ([]confParam, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer file.Close() - params := []confParam{} - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - line = strings.TrimSpace(line) - if len(line) == 0 { - continue - } - strs := strings.SplitN(line, "=", 2) - if len(strs) < 2 { - continue - } - param := confParam{ - Key: strings.TrimSpace(strs[0]), - Value: strings.TrimSpace(strs[1]), - } - params = append(params, param) - } - if err := scanner.Err(); err != nil { - return nil, err - } - return params, nil -} - -func getConfByID(confID int, project, region, zone string) (*udb.UDBParamGroupSet, error) { - req := base.BizClient.NewDescribeUDBParamGroupRequest() - req.ProjectId = &project - req.Region = ®ion - req.Zone = &zone - req.GroupId = &confID - resp, err := base.BizClient.DescribeUDBParamGroup(req) - if err != nil { - return nil, err - } - if len(resp.DataSet) != 1 { - return nil, fmt.Errorf("conf-id[%d] may not exist", *req.GroupId) - } - return &resp.DataSet[0], nil -} - -func getConfList(dbType, project, region, zone string) ([]udb.UDBParamGroupSet, error) { - req := base.BizClient.NewDescribeUDBParamGroupRequest() - req.ClassType = &dbType - req.ProjectId = &project - req.Region = ®ion - req.Zone = &zone - list := []udb.UDBParamGroupSet{} - for offset, limit := 0, 50; ; offset += limit { - req.Offset = sdk.Int(offset) - req.Limit = sdk.Int(limit) - resp, err := base.BizClient.DescribeUDBParamGroup(req) - if err != nil { - return nil, err - } - for _, conf := range resp.DataSet { - list = append(list, conf) - } - if resp.TotalCount <= offset+limit { - break - } - } - return list, nil -} - -func getModifiableConfIDList(dbType, project, region, zone string) []string { - confs, err := getConfList(dbType, project, region, zone) - if err != nil { - return nil - } - list := []string{} - for _, conf := range confs { - if conf.Modifiable == true { - list = append(list, fmt.Sprintf("%d/%s", conf.GroupId, conf.GroupName)) - } - } - return list -} - -func getConfIDList(dbType, project, region, zone string) []string { - confs, err := getConfList(dbType, project, region, zone) - if err != nil { - return nil - } - list := []string{} - for _, conf := range confs { - list = append(list, fmt.Sprintf("%d/%s", conf.GroupId, conf.GroupName)) - } - return list -} diff --git a/cmd/uhost.go b/cmd/uhost.go deleted file mode 100644 index f5ba623b86..0000000000 --- a/cmd/uhost.go +++ /dev/null @@ -1,1620 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "encoding/base64" - "fmt" - "io" - "regexp" - "strings" - "sync" - "time" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-sdk-go/services/uhost" - "github.com/ucloud/ucloud-sdk-go/services/unet" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - "github.com/ucloud/ucloud-sdk-go/ucloud/request" - - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/model/cli" - "github.com/ucloud/ucloud-cli/model/status" - "github.com/ucloud/ucloud-cli/ux" -) - -var uhostSpoller = base.NewSpoller(sdescribeUHostByID, base.Cxt.GetWriter()) - -//NewCmdUHost ucloud uhost -func NewCmdUHost() *cobra.Command { - cmd := &cobra.Command{ - Use: "uhost", - Short: "List,create,delete,stop,restart,poweroff or resize UHost instance", - Long: `List,create,delete,stop,restart,poweroff or resize UHost instance`, - Args: cobra.NoArgs, - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdUHostList(out)) - cmd.AddCommand(NewCmdUHostCreate()) - cmd.AddCommand(NewCmdUHostDelete(out)) - cmd.AddCommand(NewCmdUHostStop(out)) - cmd.AddCommand(NewCmdUHostStart(out)) - cmd.AddCommand(NewCmdUHostReboot(out)) - cmd.AddCommand(NewCmdUHostPoweroff(out)) - cmd.AddCommand(NewCmdUHostResize(out)) - cmd.AddCommand(NewCmdUHostClone(out)) - cmd.AddCommand(NewCmdUhostResetPassword(out)) - cmd.AddCommand(NewCmdUhostReinstallOS(out)) - cmd.AddCommand(NewCmdUhostCreateImage(out)) - cmd.AddCommand(NewCmdIsolation(out)) - cmd.AddCommand(NewCmdUhostLeaveIsolationGroup(out)) - - return cmd -} - -//UHostRow UHost表格行 -type UHostRow struct { - UHostName string - Remark string - ResourceID string - Group string - PrivateIP string - PublicIP string - Config string - DiskSet string - Zone string - Image string - VPC string - Subnet string - Type string - State string - CreationTime string -} - -func listUhost(uhosts []uhost.UHostInstanceSet, out io.Writer, output string) { - list := make([]UHostRow, 0) - for _, host := range uhosts { - row := UHostRow{} - row.UHostName = host.Name - row.Remark = host.Remark - row.ResourceID = host.UHostId - row.Group = host.Tag - for _, ip := range host.IPSet { - if row.PublicIP != "" { - row.PublicIP += " | " - } - if ip.Type == "Private" { - row.PrivateIP = ip.IP - row.VPC = ip.VPCId - row.Subnet = ip.SubnetId - } else { - row.PublicIP += fmt.Sprintf("%s", ip.IP) - } - } - cupCore := host.CPU - memorySize := host.Memory / 1024 - diskSize := 0 - var disks []string - for _, disk := range host.DiskSet { - if disk.Type == "Data" || disk.Type == "Udisk" { - diskSize += disk.Size - } - disks = append(disks, fmt.Sprintf("%s:%s:%dG", disk.Type, disk.DiskType, disk.Size)) - } - row.Zone = host.Zone - row.DiskSet = strings.Join(disks, "|") - row.Config = fmt.Sprintf("cpu:%d memory:%dG disk:%dG", cupCore, memorySize, diskSize) - row.Image = fmt.Sprintf("%s|%s", host.BasicImageId, host.BasicImageName) - row.CreationTime = base.FormatDate(host.CreateTime) - row.State = host.State - row.Type = host.MachineType + "/" + host.HostType - if host.HotplugFeature { - row.Type += "/HotPlug" - } - list = append(list, row) - } - if global.JSON { - base.PrintJSON(list, out) - } else { - var cols []string - if output == "wide" { - cols = []string{"UHostName", "Remark", "ResourceID", "Group", "PrivateIP", "PublicIP", "Config", "DiskSet", "Zone", "Image", "VPC", "Subnet", "Type", "State", "CreationTime"} - } else { - cols = []string{"UHostName", "ResourceID", "Group", "PrivateIP", "PublicIP", "Config", "Image", "Type", "State", "CreationTime"} - } - base.PrintTable(list, cols) - } -} - -func listUhostID(uhosts []uhost.UHostInstanceSet, out io.Writer) { - ids := make([]string, 0) - for _, u := range uhosts { - ids = append(ids, u.UHostId) - } - fmt.Fprintln(out, strings.Join(ids, ",")) -} - -func fetchUHosts(req *uhost.DescribeUHostInstanceRequest) ([]uhost.UHostInstanceSet, int, error) { - resp, err := base.BizClient.DescribeUHostInstance(req) - if err != nil { - return nil, 0, err - } - return resp.UHostSet, resp.TotalCount, nil -} - -func fetchUHostsPageOff(req *uhost.DescribeUHostInstanceRequest) ([]uhost.UHostInstanceSet, error) { - _req := *req - result := make([]uhost.UHostInstanceSet, 0) - for limit, offset := 50, 0; ; offset += limit { - _req.Offset = sdk.Int(offset) - _req.Limit = sdk.Int(limit) - uhosts, total, err := fetchUHosts(&_req) - if err != nil { - return nil, err - } - result = append(result, uhosts...) - if offset+limit >= total { - break - } - } - return result, nil -} - -func getAllUHosts(req *uhost.DescribeUHostInstanceRequest, pageOff bool, allRegion bool) ([]uhost.UHostInstanceSet, error) { - if allRegion { - result := make([]uhost.UHostInstanceSet, 0) - regions, err := getAllRegions() - if err != nil { - return nil, err - } - for _, region := range regions { - _req := *req - _req.Region = sdk.String(region) - //如果要获取所有region的主机,则不分页 - uhosts, err := fetchUHostsPageOff(&_req) - if err != nil { - return nil, err - } - result = append(result, uhosts...) - } - return result, nil - } - - if pageOff { - _req := *req - uhosts, err := fetchUHostsPageOff(&_req) - if err != nil { - return nil, err - } - return uhosts, nil - } - - uhosts, _, err := fetchUHosts(req) - if err != nil { - return nil, err - } - return uhosts, nil -} - -//NewCmdUHostList [ucloud uhost list] -func NewCmdUHostList(out io.Writer) *cobra.Command { - var allRegion, pageOff, idOnly bool - var output string - var uhostIds []string - req := base.BizClient.NewDescribeUHostInstanceRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List all UHost Instances", - Long: `List all UHost Instances`, - Run: func(cmd *cobra.Command, args []string) { - *req.VPCId = base.PickResourceID(*req.VPCId) - *req.SubnetId = base.PickResourceID(*req.SubnetId) - *req.IsolationGroup = base.PickResourceID(*req.IsolationGroup) - for _, uhost := range uhostIds { - req.UHostIds = append(req.UHostIds, base.PickResourceID(uhost)) - } - - uhosts, err := getAllUHosts(req, pageOff, allRegion) - if err != nil { - base.HandleError(err) - return - } - if idOnly { - listUhostID(uhosts, out) - } else { - listUhost(uhosts, out, output) - } - }, - } - cmd.Flags().SortFlags = false - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign region.") - req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") - req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0") - req.Limit = cmd.Flags().Int("limit", 50, "Optional. Limit default 50, max value 100") - req.VPCId = cmd.Flags().String("vpc-id", "", "Optional. Resource ID of VPC. List uhost instances of the specified VPC") - req.SubnetId = cmd.Flags().String("subnet-id", "", "Optional. Resource ID of Subnet. List uhost instances of the specified Subnet") - req.IsolationGroup = cmd.Flags().String("isolation-group", "", "Optional. Resource ID of isolation group. List uhost instances of the specified isolation group") - cmd.Flags().StringSliceVar(&uhostIds, "uhost-id", make([]string, 0), "Optional. Resource ID of uhost instances, multiple values separated by comma(without space)") - cmd.Flags().BoolVar(&allRegion, "all-region", false, "Optional. Accpet values: true or false. List uhost instances of all regions when assigned true") - cmd.Flags().BoolVar(&pageOff, "page-off", false, "Optional. Paging or not. If all-region is specified this flag will be true. Accept values: true or false. If assigned, the limit flag will be disabled and list all uhost instances") - cmd.Flags().BoolVar(&idOnly, "uhost-id-only", false, "Optional. Just display resource id of uhost") - cmd.Flags().StringVarP(&output, "output", "o", "", "Optional. Accept values: wide. Display more information about uhost such as DiskSet and Zone") - bindGroup(req, cmd.Flags()) - - cmd.Flags().SetFlagValues("page-off", "true", "false") - cmd.Flags().SetFlagValues("uhost-id-only", "true", "false") - cmd.Flags().SetFlagValues("output", "wide") - cmd.Flags().SetFlagValuesFunc("project-id", getProjectList) - cmd.Flags().SetFlagValuesFunc("region", getRegionList) - cmd.Flags().SetFlagValuesFunc("zone", func() []string { - return getZoneList(req.GetRegion()) - }) - - flags := cmd.Flags() - flags.SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("subnet-id", func() []string { - return getAllSubnetIDNames(*req.VPCId, *req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("isolation-group", func() []string { - return getIsolationGroupList(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList(nil, *req.ProjectId, *req.Region, *req.Zone) - }) - - return cmd -} - -//NewCmdUHostCreate [ucloud uhost create] -func NewCmdUHostCreate() *cobra.Command { - var bindEipIDs []string - var hotPlug string - var async bool - var count int - var hotPlugImageFlag bool - - req := base.BizClient.NewCreateUHostInstanceRequest() - eipReq := base.BizClient.NewAllocateEIPRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create UHost instance", - Long: "Create UHost instance", - Run: func(cmd *cobra.Command, args []string) { - *req.Memory *= 1024 - req.LoginMode = sdk.String("Password") - req.ImageId = sdk.String(base.PickResourceID(*req.ImageId)) - req.VPCId = sdk.String(base.PickResourceID(*req.VPCId)) - req.SubnetId = sdk.String(base.PickResourceID(*req.SubnetId)) - req.SecurityGroupId = sdk.String(base.PickResourceID(*req.SecurityGroupId)) - req.IsolationGroup = sdk.String(base.PickResourceID(*req.IsolationGroup)) - if hotPlug == "true" { - req.HotplugFeature = sdk.Bool(true) - any, err := describeImageByID(*req.ImageId, *req.ProjectId, *req.Region, *req.Zone) - if err != nil { - base.LogError(fmt.Sprintf("check image support hot-plug failed: %v", err)) - } else { - image, ok := any.(*uhost.UHostImageSet) - if !ok { - base.LogError(fmt.Sprintf("check image support hot-plug failed, image %s may not exist", *req.ImageId)) - } - for _, feature := range image.Features { - if feature == "HotPlug" { - hotPlugImageFlag = true - } - } - } - if !hotPlugImageFlag { - base.LogWarn(fmt.Sprintf("warning. image %s does not support hot-plug", *req.ImageId)) - req.HotplugFeature = sdk.Bool(false) - } - } - - wg := &sync.WaitGroup{} - tokens := make(chan struct{}, 20) - wg.Add(count) - if count <= 5 { - for i := 0; i < count; i++ { - bindEipID := "" - if len(bindEipIDs) > i { - bindEipID = bindEipIDs[i] - } - go createUhostWrapper(req, eipReq, bindEipID, async, make(chan bool, count), wg, tokens, i) - } - } else { - retCh := make(chan bool, count) - ux.Doc.Disable() - refresh := ux.NewRefresh() - - go func() { - for i := 0; i < count; i++ { - bindEipID := "" - if len(bindEipIDs) > i { - bindEipID = bindEipIDs[i] - } - go createUhostWrapper(req, eipReq, bindEipID, async, retCh, wg, tokens, i) - } - }() - - go func() { - var success, fail int - refresh.Do(fmt.Sprintf("uhost creating, total:%d, success:%d, fail:%d", count, success, fail)) - for ret := range retCh { - if ret { - success++ - } else { - fail++ - } - refresh.Do(fmt.Sprintf("uhost creating, total:%d, success:%d, fail:%d", count, success, fail)) - if count == success+fail && fail > 0 { - fmt.Printf("Check logs in %s\n", base.GetLogFilePath()) - } - } - }() - } - wg.Wait() - }, - } - - req.Disks = make([]uhost.UHostDisk, 2) - req.Disks[0].IsBoot = sdk.String("True") - req.Disks[1].IsBoot = sdk.String("False") - - flags := cmd.Flags() - flags.SortFlags = false - req.CPU = flags.Int("cpu", 4, "Required. The count of CPU cores. Optional parameters: {1, 2, 4, 8, 12, 16, 24, 32}") - req.Memory = flags.Int("memory-gb", 8, "Required. Memory size. Unit: GB. Range: [1, 128], multiple of 2") - req.Password = flags.String("password", "", "Required. Password of the uhost user(root/ubuntu)") - req.ImageId = flags.String("image-id", "", "Required. The ID of image. see 'ucloud image list'") - flags.BoolVar(&async, "async", false, "Optional. Do not wait for the long-running operation to finish.") - flags.IntVar(&count, "count", 1, "Optional. Number of uhost to create.") - req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID. This field is required under VPC2.0. See 'ucloud vpc list'") - req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID. This field is required under VPC2.0. See 'ucloud subnet list'") - req.Name = flags.String("name", "UHost", "Optional. UHost instance name") - flags.StringSliceVar(&bindEipIDs, "bind-eip", nil, "Optional. Resource ID or IP Address of eip that will be bound to the new created uhost") - eipReq.OperatorName = flags.String("create-eip-line", "", "Optional. BGP for regions in the chinese mainland and International for overseas regions") - eipReq.Bandwidth = flags.Int("create-eip-bandwidth-mb", 0, "Optional. Required if you want to create new EIP. Bandwidth(Unit:Mbps).The range of value related to network charge mode. By traffic [1, 300]; by bandwidth [1,800] (Unit: Mbps); it could be 0 if the eip belong to the shared bandwidth") - eipReq.PayMode = flags.String("create-eip-traffic-mode", "Bandwidth", "Optional. 'Traffic','Bandwidth' or 'ShareBandwidth'") - eipReq.ShareBandwidthId = flags.String("shared-bw-id", "", "Optional. Resource ID of shared bandwidth. It takes effect when create-eip-traffic-mode is ShareBandwidth ") - eipReq.Name = flags.String("create-eip-name", "", "Optional. Name of created eip to bind with the uhost") - eipReq.Remark = flags.String("create-eip-remark", "", "Optional.Remark of your EIP.") - - req.ChargeType = flags.String("charge-type", "Month", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") - req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - - req.MachineType = flags.String("machine-type", "", "Optional. Accept values: N, C, G, O. Forward to https://docs.ucloud.cn/api/uhost-api/uhost_type for details") - req.MinimalCpuPlatform = flags.String("minimal-cpu-platform", "", "Optional. Accpet values: Intel/Auto, Intel/IvyBridge, Intel/Haswell, Intel/Broadwell, Intel/Skylake, Intel/Cascadelake") - req.UHostType = flags.String("type", "", "Optional. Accept values: N1, N2, N3, G1, G2, G3, I1, I2, C1. Forward to https://docs.ucloud.cn/api/uhost-api/uhost_type for details") - req.GPU = flags.Int("gpu", 0, "Optional. The count of GPU cores.") - req.NetCapability = flags.String("net-capability", "Normal", "Optional. Default is 'Normal', also support 'Super' which will enhance multiple times network capability as before") - flags.StringVar(&hotPlug, "hot-plug", "true", "Optional. Enable hot plug feature or not. Accept values: true or false") - req.Disks[0].Type = flags.String("os-disk-type", "CLOUD_SSD", "Optional. Enumeration value. 'LOCAL_NORMAL', Ordinary local disk; 'CLOUD_NORMAL', Ordinary cloud disk; 'LOCAL_SSD',local ssd disk; 'CLOUD_SSD',cloud ssd disk; 'EXCLUSIVE_LOCAL_DISK',big data. The disk only supports a limited combination.") - req.Disks[0].Size = flags.Int("os-disk-size-gb", 20, "Optional. Default 20G. Windows should be bigger than 40G Unit GB") - req.Disks[0].BackupType = flags.String("os-disk-backup-type", "NONE", "Optional. Enumeration value, 'NONE' or 'DATAARK'. DataArk supports real-time backup, which can restore the disk back to any moment within the last 12 hours. (Normal Local Disk and Normal Cloud Disk Only)") - req.Disks[1].Type = flags.String("data-disk-type", "CLOUD_SSD", "Optional. Enumeration value. 'LOCAL_NORMAL', Ordinary local disk; 'CLOUD_NORMAL', Ordinary cloud disk; 'LOCAL_SSD',local ssd disk; 'CLOUD_SSD',cloud ssd disk; 'EXCLUSIVE_LOCAL_DISK',big data. The disk only supports a limited combination.") - req.Disks[1].Size = flags.Int("data-disk-size-gb", 20, "Optional. Disk size. Unit GB") - req.Disks[1].BackupType = flags.String("data-disk-backup-type", "NONE", "Optional. Enumeration value, 'NONE' or 'DATAARK'. DataArk supports real-time backup, which can restore the disk back to any moment within the last 12 hours. (Normal Local Disk and Normal Cloud Disk Only)") - req.SecurityGroupId = flags.String("firewall-id", "", "Optional. Firewall Id, default: Web recommended firewall. see 'ucloud firewall list'.") - req.Tag = flags.String("group", "Default", "Optional. Business group") - req.IsolationGroup = flags.String("isolation-group", "", "Optional. Resource ID of isolation group. see 'ucloud uhost isolation-group list") - - flags.MarkDeprecated("type", "please use --machine-type instead") - flags.SetFlagValues("charge-type", "Month", "Year", "Dynamic", "Trial") - flags.SetFlagValues("hot-plug", "true", "false") - flags.SetFlagValues("cpu", "1", "2", "4", "8", "12", "16", "24", "32") - flags.SetFlagValues("type", "N2", "N1", "N3", "I2", "I1", "C1", "G1", "G2", "G3") - flags.SetFlagValues("machine-type", "N", "C", "G", "O") - flags.SetFlagValues("minimal-cpu-platform", "Intel/Auto", "Intel/IvyBridge", "Intel/Haswell", "Intel/Broadwell", "Intel/Skylake", "Intel/Cascadelake") - flags.SetFlagValues("net-capability", "Normal", "Super") - flags.SetFlagValues("os-disk-type", "LOCAL_NORMAL", "CLOUD_NORMAL", "LOCAL_SSD", "CLOUD_SSD", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK") - flags.SetFlagValues("os-disk-backup-type", "NONE", "DATAARK") - flags.SetFlagValues("data-disk-type", "LOCAL_NORMAL", "CLOUD_NORMAL", "LOCAL_SSD", "CLOUD_SSD", "EXCLUSIVE_LOCAL_DISK") - flags.SetFlagValues("data-disk-backup-type", "NONE", "DATAARK") - flags.SetFlagValues("create-eip-line", "BGP", "International") - flags.SetFlagValues("create-eip-traffic-mode", "Bandwidth", "Traffic", "ShareBandwidth") - - flags.SetFlagValuesFunc("image-id", func() []string { - return getImageList([]string{status.IMAGE_AVAILABLE}, cli.IMAGE_BASE, *req.ProjectId, *req.Region, *req.Zone) - }) - flags.SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("bind-eip", func() []string { - return getAllEip(*req.ProjectId, *req.Region, []string{status.EIP_FREE}, nil) - }) - flags.SetFlagValuesFunc("firewall-id", func() []string { - return getFirewallIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("subnet-id", func() []string { - return getAllSubnetIDNames(*req.VPCId, *req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("isolation-group", func() []string { - return getIsolationGroupList(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("cpu") - cmd.MarkFlagRequired("memory-gb") - cmd.MarkFlagRequired("password") - cmd.MarkFlagRequired("image-id") - - return cmd -} - -//createUhostWrapper 处理UI和并发控制 -func createUhostWrapper(req *uhost.CreateUHostInstanceRequest, eipReq *unet.AllocateEIPRequest, bindEipID string, async bool, retCh chan<- bool, wg *sync.WaitGroup, tokens chan struct{}, idx int) { - //控制并发数量 - tokens <- struct{}{} - defer func() { - <-tokens - //设置延时,使报错能渲染出来 - time.Sleep(time.Second / 5) - wg.Done() - }() - - success, logs := createUhost(req, eipReq, bindEipID, async) - retCh <- success - logs = append(logs, fmt.Sprintf("index:%d, result:%t", idx, success)) - base.LogInfo(logs...) -} - -func createUhost(req *uhost.CreateUHostInstanceRequest, eipReq *unet.AllocateEIPRequest, bindEipID string, async bool) (bool, []string) { - resp, err := base.BizClient.CreateUHostInstance(req) - block := ux.NewBlock() - ux.Doc.Append(block) - logs := []string{"=================================================="} - logs = append(logs, fmt.Sprintf("api:CreateUHostInstance, request:%v", base.ToQueryMap(req))) - if err != nil { - logs = append(logs, fmt.Sprintf("err:%v", err)) - block.Append(base.ParseError(err)) - return false, logs - } - - logs = append(logs, fmt.Sprintf("resp:%#v", resp)) - if len(resp.UHostIds) != 1 { - block.Append(fmt.Sprintf("expect uhost count 1 , accept %d", len(resp.UHostIds))) - return false, logs - } - - text := fmt.Sprintf("uhost[%s] is initializing", resp.UHostIds[0]) - if async { - block.Append(text) - } else { - uhostSpoller.Sspoll(resp.UHostIds[0], text, []string{status.HOST_RUNNING, status.HOST_FAIL}, block) - } - - if bindEipID != "" { - eip := base.PickResourceID(bindEipID) - logs = append(logs, fmt.Sprintf("bind eip: %s", eip)) - eipLogs, err := sbindEIP(sdk.String(resp.UHostIds[0]), sdk.String("uhost"), &eip, req.ProjectId, req.Region) - logs = append(logs, eipLogs...) - if err != nil { - block.Append(fmt.Sprintf("bind eip[%s] with uhost[%s] failed: %v", eip, resp.UHostIds[0], err)) - return false, logs - } - block.Append(fmt.Sprintf("bind eip[%s] with uhost[%s] successfully", eip, resp.UHostIds[0])) - } else if *eipReq.Bandwidth != 0 { - eipReq.ChargeType = req.ChargeType - eipReq.Tag = req.Tag - eipReq.Quantity = req.Quantity - eipReq.Region = req.Region - eipReq.ProjectId = req.ProjectId - logs = append(logs, fmt.Sprintf("create eip request: %v", base.ToQueryMap(eipReq))) - if *eipReq.OperatorName == "" { - *eipReq.OperatorName = getEIPLine(*req.Region) - } - eipResp, err := base.BizClient.AllocateEIP(eipReq) - - if err != nil { - logs = append(logs, fmt.Sprintf("create eip error: %#v", err)) - block.Append(base.ParseError(err)) - } else { - logs = append(logs, fmt.Sprintf("create eip resp: %#v", eipResp)) - for _, eip := range eipResp.EIPSet { - block.Append(fmt.Sprintf("allocate EIP[%s] ", eip.EIPId)) - for _, ip := range eip.EIPAddr { - block.Append(fmt.Sprintf("IP:%s Line:%s", ip.IP, ip.OperatorName)) - } - if len(resp.UHostIds) == 1 { - eipLogs, err := sbindEIP(sdk.String(resp.UHostIds[0]), sdk.String("uhost"), sdk.String(eip.EIPId), req.ProjectId, req.Region) - logs = append(logs, eipLogs...) - if err != nil { - block.Append(fmt.Sprintf("bind eip[%s] with uhost[%s] failed: %v", eip, resp.UHostIds[0], err)) - return false, logs - } - block.Append(fmt.Sprintf("bind eip[%s] with uhost[%s] successfully", eip, resp.UHostIds[0])) - } - } - } - } - return true, logs -} - -//NewCmdUHostDelete ucloud uhost delete -func NewCmdUHostDelete(out io.Writer) *cobra.Command { - var uhostIDs *[]string - var isDestroy = sdk.Bool(false) - var yes *bool - - req := base.BizClient.NewTerminateUHostInstanceRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete Uhost instance", - Long: "Delete Uhost instance", - Run: func(cmd *cobra.Command, args []string) { - if !*yes { - sure, err := ux.Prompt("Are you sure you want to delete the host(s)?") - if err != nil { - base.Cxt.Println(err) - return - } - if !sure { - return - } - } - if *isDestroy { - req.Destroy = sdk.Int(1) - } else { - req.Destroy = sdk.Int(0) - } - - reqs := make([]request.Common, len(*uhostIDs)) - for idx, id := range *uhostIDs { - _req := *req - id = base.PickResourceID(id) - _req.UHostId = sdk.String(id) - reqs[idx] = &_req - } - coAction := newConcurrentAction(reqs, 50, deleteUHost) - coAction.Do() - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "Requried. ResourceIDs(UhostIds) of the uhost instance") - bindRegion(req, flags) - bindProjectID(req, flags) - req.Zone = cmd.Flags().String("zone", "", "Optional. availability zone") - isDestroy = cmd.Flags().Bool("destroy", false, "Optional. false,the uhost instance will be thrown to UHost recycle if you have permission; true,the uhost instance will be deleted directly") - req.ReleaseEIP = cmd.Flags().Bool("release-eip", true, "Optional. false,Unbind EIP only; true, Unbind EIP and release it") - req.ReleaseUDisk = cmd.Flags().Bool("delete-cloud-disk", true, "Optional. false, detach cloud disk only; true, detach cloud disk and delete it") - yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") - cmd.Flags().SetFlagValues("destroy", "true", "false") - cmd.Flags().SetFlagValues("release-eip", "true", "false") - cmd.Flags().SetFlagValues("delete-cloud-disk", "true", "false") - cmd.Flags().SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_RUNNING, status.HOST_STOPPED, status.HOST_FAIL}, *req.ProjectId, *req.Region, *req.Zone) - }) - cmd.MarkFlagRequired("uhost-id") - - return cmd -} - -func deleteUHost(creq request.Common) (bool, []string) { - req := creq.(*uhost.TerminateUHostInstanceRequest) - block := ux.NewBlock() - ux.Doc.Append(block) - logs := []string{} - hostIns, err := sdescribeUHostByID(*req.UHostId) - if err != nil { - logs = append(logs, fmt.Sprintf("describe uhost[%s] failed: %s", *req.UHostId, base.ParseError(err))) - return false, logs - } - - if hostIns == nil { - logs = append(logs, fmt.Sprintf("uhost[%s] does not exist", *req.UHostId)) - return false, logs - } - - ins := hostIns.(*uhost.UHostInstanceSet) - if ins.State == "Running" { - _req := base.BizClient.NewStopUHostInstanceRequest() - _req.ProjectId = req.ProjectId - _req.Region = req.Region - _req.Zone = req.Zone - _req.UHostId = req.UHostId - stopUhostInsV2(_req, false, block) - } - - logs = append(logs, fmt.Sprintf("api:TerminateUHostInstance, request:%v", base.ToQueryMap(req))) - resp, err := base.BizClient.TerminateUHostInstance(req) - if err != nil { - block.Append(base.ParseError(err)) - logs = append(logs, fmt.Sprintf("delete uhost[%s] failed: %s", *req.UHostId, base.ParseError(err))) - return false, logs - } - text := fmt.Sprintf("uhost[%s] deleted", resp.UHostId) - logs = append(logs, text) - block.Append(text) - return true, logs -} - -//NewCmdUHostStop ucloud uhost stop -func NewCmdUHostStop(out io.Writer) *cobra.Command { - var uhostIDs *[]string - var async *bool - req := base.BizClient.NewStopUHostInstanceRequest() - cmd := &cobra.Command{ - Use: "stop", - Short: "Shut down uhost instance", - Long: "Shut down uhost instance", - Example: "ucloud uhost stop --uhost-id uhost-xxx1,uhost-xxx2", - Run: func(cmd *cobra.Command, args []string) { - for _, id := range *uhostIDs { - id = base.PickResourceID(id) - req.UHostId = &id - stopUhostIns(req, *async, out) - } - }, - } - cmd.Flags().SortFlags = false - uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "Required. ResourceIDs(UHostIds) of the uhost instances") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") - async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") - cmd.Flags().SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_RUNNING}, *req.ProjectId, *req.Region, *req.Zone) - }) - cmd.MarkFlagRequired("uhost-id") - - return cmd -} - -func promptStopUhostIns(req *uhost.StopUHostInstanceRequest, yes, async bool, promptText string, out io.Writer) bool { - if !yes { - agreeClose, err := ux.Prompt(promptText) - if err != nil { - base.LogError(err.Error()) - return false - } - if !agreeClose { - return false - } - } - return stopUhostIns(req, false, out) -} - -func stopUhostIns(req *uhost.StopUHostInstanceRequest, async bool, out io.Writer) bool { - resp, err := base.BizClient.StopUHostInstance(req) - if err != nil { - base.HandleError(err) - return false - } - - text := fmt.Sprintf("uhost [%v] is shutting down", resp.UhostId) - if async { - fmt.Fprintln(out, text) - return false - } - poller := base.NewPoller(describeUHostByID, out) - return poller.Poll(resp.UhostId, *req.ProjectId, *req.Region, *req.Zone, text, []string{status.HOST_STOPPED, status.HOST_FAIL}) -} - -//可并发调用版本 -func stopUhostInsV2(req *uhost.StopUHostInstanceRequest, async bool, block *ux.Block) { - resp, err := base.BizClient.StopUHostInstance(req) - if err != nil { - block.Append(base.ParseError(err)) - return - } - - text := fmt.Sprintf("uhost[%v] is shutting down", resp.UhostId) - if async { - block.Append(text) - } else { - uhostSpoller.Sspoll(resp.UhostId, text, []string{status.HOST_STOPPED, status.HOST_FAIL}, block) - } -} - -//NewCmdUHostStart ucloud uhost start -func NewCmdUHostStart(out io.Writer) *cobra.Command { - var async *bool - var uhostIDs *[]string - req := base.BizClient.NewStartUHostInstanceRequest() - cmd := &cobra.Command{ - Use: "start", - Short: "Start Uhost instance", - Long: "Start Uhost instance", - Example: "ucloud uhost start --uhost-id uhost-xxx1,uhost-xxx2", - Run: func(cmd *cobra.Command, args []string) { - for _, id := range *uhostIDs { - id := base.PickResourceID(id) - req.UHostId = &id - resp, err := base.BizClient.StartUHostInstance(req) - if err != nil { - base.HandleError(err) - } else { - text := fmt.Sprintf("uhost[%v] is starting", resp.UhostId) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewPoller(describeUHostByID, out) - poller.Poll(resp.UhostId, *req.ProjectId, *req.Region, *req.Zone, text, []string{status.HOST_RUNNING, status.HOST_FAIL}) - } - } - } - }, - } - cmd.Flags().SortFlags = false - uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "Requried. ResourceIDs(UHostIds) of the uhost instance") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") - async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") - cmd.Flags().SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) - }) - cmd.MarkFlagRequired("uhost-id") - return cmd -} - -//NewCmdUHostReboot ucloud uhost restart -func NewCmdUHostReboot(out io.Writer) *cobra.Command { - var uhostIDs *[]string - var async *bool - req := base.BizClient.NewRebootUHostInstanceRequest() - cmd := &cobra.Command{ - Use: "restart", - Short: "Restart uhost instance", - Long: "Restart uhost instance", - Example: "ucloud uhost restart --uhost-id uhost-xxx1,uhost-xxx2", - Run: func(cmd *cobra.Command, args []string) { - for _, id := range *uhostIDs { - id = base.PickResourceID(id) - req.UHostId = &id - resp, err := base.BizClient.RebootUHostInstance(req) - if err != nil { - base.HandleError(err) - } else { - text := fmt.Sprintf("uhost[%v] is restarting", resp.UhostId) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewPoller(describeUHostByID, out) - poller.Poll(resp.UhostId, *req.ProjectId, *req.Region, *req.Zone, text, []string{status.HOST_RUNNING, status.HOST_FAIL}) - } - } - } - }, - } - cmd.Flags().SortFlags = false - uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "Required. ResourceIDs(UHostIds) of the uhost instance") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") - req.DiskPassword = cmd.Flags().String("disk-password", "", "Optional. Encrypted disk password") - async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") - cmd.Flags().SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_FAIL, status.HOST_RUNNING, status.HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) - }) - cmd.MarkFlagRequired("uhost-id") - return cmd -} - -//NewCmdUHostPoweroff ucloud uhost poweroff -func NewCmdUHostPoweroff(out io.Writer) *cobra.Command { - var yes *bool - var uhostIDs *[]string - req := base.BizClient.NewPoweroffUHostInstanceRequest() - cmd := &cobra.Command{ - Use: "poweroff", - Short: "Analog power off Uhost instnace", - Long: "Analog power off Uhost instnace", - Example: "ucloud uhost poweroff --uhost-id uhost-xxx1,uhost-xxx2", - Run: func(cmd *cobra.Command, args []string) { - if !*yes { - confirmText := "Danger, it may affect data integrity. Are you sure you want to poweroff this uhost?" - if len(*uhostIDs) > 1 { - confirmText = "Danger, it may affect data integrity. Are you sure you want to poweroff those uhosts?" - } - sure, err := ux.Prompt(confirmText) - if err != nil { - fmt.Fprintln(out, err) - return - } - if !sure { - return - } - } - for _, id := range *uhostIDs { - id = base.PickResourceID(id) - req.UHostId = &id - resp, err := base.BizClient.PoweroffUHostInstance(req) - if err != nil { - base.HandleError(err) - } else { - fmt.Fprintf(out, "uhost[%v] is power off\n", resp.UhostId) - } - } - }, - } - cmd.Flags().SortFlags = false - uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "ResourceIDs(UHostIds) of the uhost instance") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Assign project-id") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Assign region") - req.Zone = cmd.Flags().String("zone", "", "Assign availability zone") - yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") - - cmd.Flags().SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_FAIL, status.HOST_RUNNING, status.HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) - }) - cmd.MarkFlagRequired("uhost-id") - - return cmd -} - -func resizeAttachedDisk(out io.Writer, req *uhost.ResizeAttachedDiskRequest, host *uhost.UHostInstanceSet, yes, async bool, promptText string) error { - req.UHostId = &host.UHostId - if host.State == status.HOST_RUNNING { - err := tryStopUhost(req, host.UHostId, promptText, yes, async, out) - if err != nil { - return fmt.Errorf("try to stop uhost error :%w", err) - } - } - req.DryRun = sdk.Bool(false) - _, err := base.BizClient.ResizeAttachedDisk(req) - if err != nil { - return err - } - text := fmt.Sprintf("uhost [%s] disk [%s] resize", host.UHostId, *req.DiskId) - if async { - fmt.Fprintln(out, text) - } else { - poller := base.NewPoller(describeUHostByID, out) - poller.Poll(host.UHostId, *req.ProjectId, *req.Region, *req.Zone, text, []string{status.HOST_RUNNING, status.HOST_STOPPED, status.HOST_FAIL}) - } - return nil -} - -func tryStopUhost(req *uhost.ResizeAttachedDiskRequest, uhostID, promptText string, yes, async bool, out io.Writer) error { - req.DryRun = sdk.Bool(true) - resp, err := base.BizClient.ResizeAttachedDisk(req) - if err != nil { - return err - } - if resp.NeedRestart { - stopReq := base.BizClient.NewStopUHostInstanceRequest() - stopReq.UHostId = &uhostID - promptStopUhostIns(stopReq, yes, async, promptText, out) - } - return nil -} - -//NewCmdUHostResize ucloud uhost resize -func NewCmdUHostResize(out io.Writer) *cobra.Command { - var yes, async *bool - var bootDiskSize, dataDiskSize int - var dataDiskID string - var uhostIDs *[]string - req := base.BizClient.NewResizeUHostInstanceRequest() - cmd := &cobra.Command{ - Use: "resize", - Short: "Resize uhost instance,such as cpu core count, memory size and disk size", - Long: "Resize uhost instance,such as cpu core count, memory size and disk size", - Example: "ucloud uhost resize --uhost-id uhost-xxx1,uhost-xxx2 --cpu 4 --memory-gb 8", - Run: func(cmd *cobra.Command, args []string) { - if *req.CPU == 0 { - req.CPU = nil - } - if *req.Memory == 0 { - req.Memory = nil - } else { - *req.Memory *= 1024 - } - for _, id := range *uhostIDs { - id = base.PickResourceID(id) - req.UHostId = &id - host, err := describeUHostByID(id, *req.ProjectId, *req.Region, *req.Zone) - if err != nil { - base.Cxt.Println(err) - return - } - inst := host.(*uhost.UHostInstanceSet) - stopReq := base.BizClient.NewStopUHostInstanceRequest() - stopReq.ProjectId = req.ProjectId - stopReq.Region = req.Region - stopReq.Zone = req.Zone - stopReq.UHostId = &id - confirmText := "Resize uhost must be done after the uhost is stopped. Do you want to stop this uhost?" - if req.CPU != nil || req.Memory != nil || *req.NetCapValue != 0 { - if inst.State == status.HOST_RUNNING { - ret := promptStopUhostIns(stopReq, *yes, *async, confirmText, out) - if ret { - inst.State = status.HOST_STOPPED - } - } - resp, err := base.BizClient.ResizeUHostInstance(req) - if err != nil { - base.HandleError(err) - } else { - text := fmt.Sprintf("uhost [%v] cpu, memory resize", resp.UhostId) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewPoller(describeUHostByID, out) - poller.Poll(resp.UhostId, *req.ProjectId, *req.Region, *req.Zone, text, []string{status.HOST_RUNNING, status.HOST_STOPPED, status.HOST_FAIL}) - } - } - } - - if dataDiskSize != 0 || bootDiskSize != 0 { - _req := base.BizClient.NewResizeAttachedDiskRequest() - var bootDisk uhost.UHostDiskSet - var dataDisks = map[string]uhost.UHostDiskSet{} - for _, disk := range inst.DiskSet { - if disk.IsBoot == "True" { - bootDisk = disk - } else if disk.IsBoot == "False" { - dataDisks[disk.DiskId] = disk - } - } - if bootDiskSize != 0 { - if bootDiskSize <= bootDisk.Size { - base.LogError(fmt.Sprintf("Error, disk does not support shrinkage. current system-disk-size %dg", bootDisk.Size)) - continue - } else { - _req.DiskSpace = &bootDiskSize - _req.DiskId = &bootDisk.DiskId - } - err := resizeAttachedDisk(out, _req, inst, *yes, *async, confirmText) - if err != nil { - base.HandleError(err) - } - } - - if dataDiskSize != 0 { - var dataDisk uhost.UHostDiskSet - if len(dataDisks) > 1 { - if dataDiskID == "" { - base.LogError(fmt.Sprintf("Error, the uhost %s have %d data disks. data-disk-id should be assigned", id, len(dataDisks))) - continue - } - var ok bool - dataDisk, ok = dataDisks[dataDiskID] - if !ok { - base.LogError(fmt.Sprintf("Error, the disk %s does not exist", dataDiskID)) - continue - } - } else if len(dataDisks) == 1 { - for _, disk := range dataDisks { - dataDisk = disk - } - } else if len(dataDisks) == 0 { - base.LogError(fmt.Sprintf("Error, the uhost %s have no data disk. data-disk-id should be assigned", id)) - continue - } - if dataDiskSize <= dataDisk.Size { - base.LogError(fmt.Sprintf("Error, disk does not support shrinkage. current data-disk-size %dg", dataDisk.Size)) - continue - } - _req.DiskSpace = &dataDiskSize - _req.DiskId = &dataDisk.DiskId - err := resizeAttachedDisk(out, _req, inst, *yes, *async, confirmText) - if err != nil { - base.HandleError(err) - } - } - } - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "Required. ResourceIDs(or UhostIDs) of the uhost instances") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - req.CPU = cmd.Flags().Int("cpu", 0, "Optional. The number of virtual CPU cores. Series1 {1, 2, 4, 8, 12, 16, 24, 32}. Series2 {1,2,4,8,16}") - req.Memory = cmd.Flags().Int("memory-gb", 0, "Optional. memory size. Unit: GB. Range: [1, 128], multiple of 2") - cmd.Flags().IntVar(&bootDiskSize, "system-disk-size-gb", 0, "Optional. System disk size, unit GB. Range[20,100]. Step 10. System disk does not support shrinkage") - cmd.Flags().IntVar(&dataDiskSize, "data-disk-size-gb", 0, "Optional. Data disk size,unit GB. Step 10. disk does not support shrinkage") - cmd.Flags().StringVar(&dataDiskID, "data-disk-id", "", "Optional. If the uhost specified has two or more data disks, this parameter should be assigned") - req.NetCapValue = cmd.Flags().Int("net-cap", 0, "Optional. NIC scale. 1,upgrade; 2,downgrade; 0,unchanged") - yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") - async = cmd.Flags().BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") - cmd.Flags().SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_RUNNING, status.HOST_STOPPED, status.HOST_FAIL}, *req.ProjectId, *req.Region, *req.Zone) - }) - cmd.MarkFlagRequired("uhost-id") - return cmd -} - -func describeUHostByID(uhostID, projectID, region, zone string) (interface{}, error) { - req := base.BizClient.NewDescribeUHostInstanceRequest() - req.UHostIds = []string{uhostID} - req.ProjectId = &projectID - req.Region = ®ion - req.Zone = &zone - - resp, err := base.BizClient.DescribeUHostInstance(req) - if err != nil { - return nil, err - } - if len(resp.UHostSet) < 1 { - return nil, fmt.Errorf("uhost [%s] does not exist", uhostID) - } - - return &resp.UHostSet[0], nil -} - -func sdescribeUHostByID(uhostID string) (interface{}, error) { - req := base.BizClient.NewDescribeUHostInstanceRequest() - req.UHostIds = []string{uhostID} - - resp, err := base.BizClient.DescribeUHostInstance(req) - if err != nil { - return nil, err - } - if len(resp.UHostSet) < 1 { - return nil, nil - } - - return &resp.UHostSet[0], nil -} - -func getUhostList(states []string, project, region, zone string) []string { - req := base.BizClient.NewDescribeUHostInstanceRequest() - req.ProjectId = sdk.String(project) - req.Region = sdk.String(region) - req.Zone = sdk.String(zone) - req.Limit = sdk.Int(50) - resp, err := base.BizClient.DescribeUHostInstance(req) - if err != nil { - //todo runtime log - return nil - } - list := []string{} - for _, host := range resp.UHostSet { - if states != nil { - for _, s := range states { - if host.State == s { - list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) - } - } - } else { - list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) - } - } - return list -} - -//NewCmdUHostClone ucloud uhost clone -func NewCmdUHostClone(out io.Writer) *cobra.Command { - var uhostID *string - var async *bool - req := base.BizClient.NewCreateUHostInstanceRequest() - cmd := &cobra.Command{ - Use: "clone", - Short: "Create an uhost with the same configuration as another uhost, excluding bound eip and udisk", - Long: "Create an uhost with the same configuration as another uhost, excluding bound eip and udisk", - Run: func(com *cobra.Command, args []string) { - *uhostID = base.PickResourceID(*uhostID) - queryReq := base.BizClient.NewDescribeUHostInstanceRequest() - queryReq.ProjectId = req.ProjectId - queryReq.Region = req.Region - queryReq.Zone = req.Zone - queryReq.UHostIds = []string{*uhostID} - queryResp, err := base.BizClient.DescribeUHostInstance(queryReq) - if err != nil { - base.HandleError(err) - return - } - if len(queryResp.UHostSet) < 1 { - base.Cxt.PrintErr(fmt.Errorf("uhost[%s] not exist", *uhostID)) - return - } - queryFirewallReq := base.BizClient.NewDescribeFirewallRequest() - queryFirewallReq.ProjectId = req.ProjectId - queryFirewallReq.Region = req.Region - queryFirewallReq.ResourceId = uhostID - queryFirewallReq.ResourceType = sdk.String("uhost") - - firewallResp, err := base.BizClient.DescribeFirewall(queryFirewallReq) - if err != nil { - base.HandleError(err) - return - } - - if len(firewallResp.DataSet) == 1 { - req.SecurityGroupId = &firewallResp.DataSet[0].FWId - } - - uhostIns := queryResp.UHostSet[0] - - req.ImageId = &uhostIns.BasicImageId - req.CPU = &uhostIns.CPU - req.Memory = &uhostIns.Memory - for _, ip := range uhostIns.IPSet { - if ip.Type == "Private" { - req.VPCId = &ip.VPCId - req.SubnetId = &ip.SubnetId - } - } - req.ChargeType = &uhostIns.ChargeType - req.UHostType = &uhostIns.UHostType - req.NetCapability = &uhostIns.NetCapability - - for _, disk := range uhostIns.DiskSet { - item := uhost.UHostDisk{ - Size: sdk.Int(disk.Size), - Type: sdk.String(disk.DiskType), - IsBoot: sdk.String(disk.IsBoot), - } - if disk.BackupType != "" { - item.BackupType = sdk.String(disk.BackupType) - } - req.Disks = append(req.Disks, item) - } - req.Tag = &uhostIns.Tag - req.LoginMode = sdk.String("Password") - resp, err := base.BizClient.CreateUHostInstance(req) - if err != nil { - base.HandleError(err) - return - } - if len(resp.UHostIds) == 1 { - text := fmt.Sprintf("cloned uhost:[%s] is initializing", resp.UHostIds[0]) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewPoller(describeUHostByID, out) - poller.Poll(resp.UHostIds[0], *req.ProjectId, *req.Region, *req.Zone, text, []string{status.HOST_RUNNING, status.HOST_FAIL}) - } - } else { - base.HandleError(fmt.Errorf("expect uhost count 1, accept %d", len(resp.UHostIds))) - return - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - uhostID = flags.String("uhost-id", "", "Required. Resource ID of the uhost to clone from") - req.Password = flags.String("password", "", "Required. Password of the uhost user(root/ubuntu)") - req.Name = flags.String("name", "", "Optional. Name of the uhost to clone") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - async = flags.Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") - flags.SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_RUNNING, status.HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) - }) - cmd.MarkFlagRequired("uhost-id") - cmd.MarkFlagRequired("password") - return cmd -} - -//NewCmdUhostCreateImage ucloud uhost create-image -func NewCmdUhostCreateImage(out io.Writer) *cobra.Command { - var async *bool - req := base.BizClient.NewCreateCustomImageRequest() - cmd := &cobra.Command{ - Use: "create-image", - Short: "Create image from an uhost instance", - Long: "Create image from an uhost instance", - Run: func(cmd *cobra.Command, args []string) { - req.UHostId = sdk.String(base.PickResourceID(*req.UHostId)) - resp, err := base.BizClient.CreateCustomImage(req) - if err != nil { - base.HandleError(err) - return - } - text := fmt.Sprintf("iamge[%s] is making", resp.ImageId) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewPoller(describeImageByID, out) - poller.Poll(resp.ImageId, *req.ProjectId, *req.Region, *req.Zone, text, []string{status.IMAGE_AVAILABLE, status.IMAGE_UNAVAILABLE}) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.UHostId = flags.String("uhost-id", "", "Resource ID of uhost to create image from") - req.ImageName = flags.String("image-name", "", "Required. Name of the image to create") - req.ImageDescription = flags.String("image-desc", "", "Optional. Description of the image to create") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - async = flags.BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") - - flags.SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_RUNNING, status.HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) - }) - - cmd.MarkFlagRequired("uhost-id") - cmd.MarkFlagRequired("image-name") - return cmd -} - -//NewCmdUhostResetPassword ucloud uhost reset-password -func NewCmdUhostResetPassword(out io.Writer) *cobra.Command { - var yes *bool - var uhostIDs *[]string - req := base.BizClient.NewResetUHostInstancePasswordRequest() - cmd := &cobra.Command{ - Use: "reset-password", - Short: "Reset the administrator password for the UHost instances.", - Long: "Reset the administrator password for the UHost instances.", - Run: func(cmd *cobra.Command, args []string) { - for _, id := range *uhostIDs { - id = base.PickResourceID(id) - req.UHostId = &id - err := checkAndCloseUhost(*yes, false, id, *req.ProjectId, *req.Region, *req.Zone, out) - if err != nil { - base.Cxt.Println(err) - continue - } - host, err := describeUHostByID(id, *req.ProjectId, *req.Region, *req.Zone) - inst, ok := host.(*uhost.UHostInstanceSet) - if !ok { - return - } - if inst.BootDiskState == "Initializing" { - fmt.Fprintf(out, "uhost[%s] boot disk in initializing, wait 10 minutes\n", id) - return - } - resp, err := base.BizClient.ResetUHostInstancePassword(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "uhost[%s] reset password\n", resp.UhostId) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - uhostIDs = flags.StringSlice("uhost-id", nil, "Required. Resource IDs of the uhosts to reset the administrator's password") - req.Password = flags.String("password", "", "Required. New Password") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") - flags.SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_RUNNING, status.HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) - }) - cmd.MarkFlagRequired("uhost-id") - cmd.MarkFlagRequired("password") - return cmd -} - -func checkAndCloseUhost(yes, async bool, uhostID, project, region, zone string, out io.Writer) error { - host, err := describeUHostByID(uhostID, project, region, zone) - if err != nil { - return err - } - inst, ok := host.(*uhost.UHostInstanceSet) - if ok { - if inst.State == "Running" { - if !yes { - confirmText := fmt.Sprintf("uhost[%s] will be stopped, can we do this?", uhostID) - agreeClose, err := ux.Prompt(confirmText) - if err != nil { - return err - } - if !agreeClose { - return fmt.Errorf("skip, you do not agree to stop uhost") - } - } - _req := base.BizClient.NewStopUHostInstanceRequest() - _req.ProjectId = &project - _req.Region = ®ion - _req.Zone = &zone - _req.UHostId = &uhostID - stopUhostIns(_req, async, out) - } - } else { - return fmt.Errorf("Something wrong, uhost[%s] may not exist", uhostID) - } - return nil -} - -//NewCmdUhostReinstallOS ucloud uhost reinstall-os -func NewCmdUhostReinstallOS(out io.Writer) *cobra.Command { - var isReserveDataDisk, yes, async *bool - req := base.BizClient.NewReinstallUHostInstanceRequest() - cmd := &cobra.Command{ - Use: "reinstall-os", - Short: "Reinstall the operating system of the UHost instance", - Long: "Reinstall the operating system of the UHost instance. we will detach all udisk disks if the uhost attached some, and then stop the uhost if it's running", - Run: func(cmd *cobra.Command, args []string) { - if *isReserveDataDisk { - req.ReserveDisk = sdk.String("Yes") - } else { - req.ReserveDisk = sdk.String("No") - } - req.UHostId = sdk.String(base.PickResourceID(*req.UHostId)) - req.Password = sdk.String(base64.StdEncoding.EncodeToString([]byte(sdk.StringValue(req.Password)))) - - any, err := describeUHostByID(*req.UHostId, *req.ProjectId, *req.Region, *req.Zone) - if err != nil { - base.Cxt.Println(err) - return - } - uhostIns, ok := any.(*uhost.UHostInstanceSet) - if ok { - for _, disk := range uhostIns.DiskSet { - if disk.Type == "Udisk" { - sure := false - if !*yes { - text := fmt.Sprintf("udisk[%s/%s] will be detached, can we do this?", disk.DiskId, disk.Name) - sure, err = ux.Prompt(text) - if err != nil { - base.Cxt.PrintErr(err) - return - } - if !sure { - base.Cxt.Printf("you don't agree to detach udisk\n") - return - } - } - if *yes || sure { - err := detachUdisk(false, disk.DiskId, out) - if err != nil { - base.Cxt.Println(err) - return - } - } - } - } - } else { - base.Cxt.Printf("Something wrong, uhost[%s] may not exist\n", *req.UHostId) - return - } - - err = checkAndCloseUhost(*yes, *async, *req.UHostId, *req.ProjectId, *req.Region, *req.Zone, out) - if err != nil { - base.Cxt.Println(err) - return - } - resp, err := base.BizClient.ReinstallUHostInstance(req) - if err != nil { - base.Cxt.Println(err) - return - } - text := fmt.Sprintf("uhost[%s] is reinstalling OS", *req.UHostId) - if *async { - fmt.Fprintln(out, text) - } else { - poller := base.NewPoller(describeUHostByID, out) - poller.Poll(resp.UhostId, *req.ProjectId, *req.Region, *req.Zone, text, []string{status.HOST_RUNNING, status.HOST_FAIL}) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - req.UHostId = flags.String("uhost-id", "", "Required. Resource ID of the uhost to reinstall operating system") - req.Password = flags.String("password", "", "Required. Password of the administrator") - req.ImageId = flags.String("image-id", "", "Optional. Resource ID the image to install. See 'ucloud image list'. Default is original image of the uhost") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Assign project-id") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Assign region") - req.Zone = flags.String("zone", base.ConfigIns.Zone, "Optional. Assign availability zone") - isReserveDataDisk = flags.Bool("keep-data-disk", false, "Keep data disk or not. If you keep data disk, you can't change OS type(Linux->Window,e.g.)") - yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") - async = flags.BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") - flags.SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList([]string{status.HOST_RUNNING, status.HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) - }) - cmd.MarkFlagRequired("uhost-id") - cmd.MarkFlagRequired("password") - return cmd -} - -//NewCmdUhostLeaveIsolationGroup ucloud uhost leave-isolation-group -func NewCmdUhostLeaveIsolationGroup(out io.Writer) *cobra.Command { - var uhostIds []string - req := base.BizClient.NewLeaveIsolationGroupRequest() - cmd := &cobra.Command{ - Use: "leave-isolation-group", - Short: "Detach uhost from its isolation group", - Run: func(c *cobra.Command, args []string) { - for _, idname := range uhostIds { - id := base.PickResourceID(idname) - any, err := describeUHostByID(id, *req.ProjectId, *req.Region, *req.Zone) - if err != nil { - base.LogError(fmt.Sprintf("fetch uhost %s failed: %v", idname, err)) - continue - } - ins, ok := any.(*uhost.UHostInstanceSet) - if !ok { - base.LogError(fmt.Sprintf("uhost %s may not exist", idname)) - continue - } - if ins.IsolationGroup == "" { - base.LogPrint(fmt.Sprintf("uhost %s doesn't attached any isolation group", idname)) - continue - } - req.GroupId = sdk.String(ins.IsolationGroup) - req.UHostId = &id - _, err = base.BizClient.LeaveIsolationGroup(req) - if err != nil { - base.HandleError(err) - continue - } - base.LogPrint(fmt.Sprintf("uhost %s detached from isolation group %s", idname, ins.IsolationGroup)) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - flags.StringSliceVar(&uhostIds, "uhost-id", nil, "Required. Resource ID of uhosts to be detech from its isolation group") - bindRegion(req, flags) - bindProjectID(req, flags) - bindZone(req, flags) - cmd.MarkFlagRequired("uhost-id") - flags.SetFlagValuesFunc("uhost-id", func() []string { - return getUhostList(nil, *req.ProjectId, *req.Region, *req.Zone) - }) - return cmd -} - -//NewCmdIsolation ucloud uhost isolation-gorup -func NewCmdIsolation(out io.Writer) *cobra.Command { - cmd := &cobra.Command{ - Use: "isolation-group", - Short: "List and manipulate isolation group of uhost", - Long: "List and manipulate isolation group of uhost", - } - cmd.AddCommand(NewCmdIsolationList(out)) - cmd.AddCommand(NewCmdIsolationCreate(out)) - cmd.AddCommand(NewCmdIsolationDelete(out)) - return cmd -} - -//NewCmdIsolationCreate ucloud uhost isolation-group create -func NewCmdIsolationCreate(out io.Writer) *cobra.Command { - req := base.BizClient.NewCreateIsolationGroupRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create isolation group instance", - Long: "Create isolation group instance", - Run: func(c *cobra.Command, args []string) { - re := regexp.MustCompile(cli.REGEXP_NAME) - if !re.Match([]byte(*req.GroupName)) { - base.LogError(fmt.Sprintf("group-name %s is invalid! Length 1~63, only English,Chinese,number and '-_.' are allowed", *req.GroupName)) - return - } - resp, err := base.BizClient.CreateIsolationGroup(req) - if err != nil { - base.HandleError(err) - return - } - base.LogPrint(fmt.Sprintf("isolation group %s created", resp.GroupId)) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.GroupName = flags.String("group-name", "", "Required. Name of isolation group. Length 1~63, only English,Chinese,number and '-_.' are allowed") - bindRegion(req, flags) - bindProjectID(req, flags) - req.Remark = flags.String("remark", "", "Optional. Remark ok isolation group") - - cmd.MarkFlagRequired("group-name") - return cmd -} - -//NewCmdIsolationDelete ucloud uhost -func NewCmdIsolationDelete(out io.Writer) *cobra.Command { - var ids []string - req := base.BizClient.NewDeleteIsolationGroupRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete isolation group instances", - Run: func(c *cobra.Command, args []string) { - for _, idname := range ids { - id := base.PickResourceID(idname) - req.GroupId = &id - _, err := base.BizClient.DeleteIsolationGroup(req) - if err != nil { - base.HandleError(err) - continue - } - base.LogPrint(fmt.Sprintf("isolation group %s deleted", idname)) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - flags.StringSliceVar(&ids, "group-id", nil, "Required. Resource ID of isolation groups to be deleted") - bindRegion(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("group-id") - flags.SetFlagValuesFunc("group-id", func() []string { - return getIsolationGroupList(*req.ProjectId, *req.Region) - }) - - return cmd -} - -type isolationGroupRow struct { - ResourceID string - Name string - Remark string - UHostCount string -} - -//NewCmdIsolationList ucloud uhost isolation-group list -func NewCmdIsolationList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeIsolationGroupRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List isolation group of uhost", - Run: func(c *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeIsolationGroup(req) - if err != nil { - base.HandleError(err) - return - } - var list []isolationGroupRow - for _, group := range resp.IsolationGroupSet { - row := isolationGroupRow{ - ResourceID: group.GroupId, - Name: group.GroupName, - Remark: group.Remark, - } - var zones []string - for _, item := range group.SpreadInfoSet { - zones = append(zones, fmt.Sprintf("%s:%d", item.Zone, item.UHostCount)) - } - row.UHostCount = strings.Join(zones, " ") - list = append(list, row) - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.GroupId = flags.String("group-id", "", "Optional. Resource ID of isolation group to describe") - bindRegion(req, flags) - bindProjectID(req, flags) - bindLimit(req, flags) - bindOffset(req, flags) - - flags.SetFlagValuesFunc("group-id", func() []string { - return getIsolationGroupList(*req.ProjectId, *req.Region) - }) - - return cmd -} - -func getIsolationGroupList(project, region string) []string { - req := base.BizClient.NewDescribeIsolationGroupRequest() - req.ProjectId = sdk.String(project) - req.Region = sdk.String(region) - req.Limit = sdk.Int(50) - resp, err := base.BizClient.DescribeIsolationGroup(req) - if err != nil { - fmt.Println(err) - return nil - } - list := []string{} - for _, group := range resp.IsolationGroupSet { - list = append(list, group.GroupId+"/"+strings.Replace(group.GroupName, " ", "-", -1)) - } - return list -} diff --git a/cmd/uhost_create_image_test.go b/cmd/uhost_create_image_test.go new file mode 100644 index 0000000000..b94f88ed2e --- /dev/null +++ b/cmd/uhost_create_image_test.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ucloud/ucloud-cli/cmd/internal/platform" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + productuhost "github.com/ucloud/ucloud-cli/products/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" +) + +func saveBaseGlobalsForCreateImage(t *testing.T) { + t.Helper() + oldClientConfig, oldAuthCredential, oldConfigIns := platform.ClientConfig, platform.AuthCredential, platform.ConfigIns + t.Cleanup(func() { + platform.ClientConfig = oldClientConfig + platform.AuthCredential = oldAuthCredential + platform.ConfigIns = oldConfigIns + }) +} + +func TestUhostCreateImageJSONEmitsStructuredResult(t *testing.T) { + saveBaseGlobalsForCreateImage(t) + + const imageID = "uimage-json-contract" + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse request form: %v", err) + } + if got := r.Form.Get("Action"); got != "CreateCustomImage" { + t.Fatalf("Action = %q, want CreateCustomImage", got) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"RetCode":0,"Action":"CreateCustomImageResponse","ImageId":%q}`, imageID) + })) + defer api.Close() + + cfg := sdk.NewConfig() + cfg.BaseUrl = api.URL + cfg.Region = "cn-bj2" + cfg.Zone = "cn-bj2-03" + cfg.ProjectId = "org-test" + platform.ClientConfig = &cfg + platform.AuthCredential = &platform.CredentialConfig{PublicKey: "public", PrivateKey: "private"} + platform.ConfigIns = &platform.AggConfig{ProjectID: "org-test", Region: "cn-bj2", Zone: "cn-bj2-03"} + + var stdout, stderr bytes.Buffer + ctx := cli.NewContext(cli.Deps{ + Out: &stdout, + Err: &stderr, + Format: cli.OutputJSON, + DefaultsProvider: func() command.Defaults { + return command.Defaults{ProjectID: platform.ConfigIns.ProjectID, Region: platform.ConfigIns.Region, Zone: platform.ConfigIns.Zone} + }, + ClientConfig: func() *sdk.Config { return platform.ClientConfig }, + BuildCredential: platform.BuildCredential, + AttachHandlers: platform.AttachHandlers, + }) + root := topLevelCmd(t, productuhost.New().NewCommand(ctx), "uhost") + root.SetArgs([]string{ + "create-image", + "--uhost-id", "uhost-for-image", + "--image-name", "contract-image", + "--async", + }) + + if err := root.Execute(); err != nil { + t.Fatalf("create-image command failed: %v", err) + } + if !strings.Contains(stderr.String(), "iamge["+imageID+"] is making") { + t.Fatalf("stderr progress = %q, want progress for %s", stderr.String(), imageID) + } + + var rows []cli.OpResultRow + if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil { + t.Fatalf("stdout must be JSON result rows, got %q: %v", stdout.String(), err) + } + want := []cli.OpResultRow{{ResourceID: imageID, Action: "create", Status: "Making"}} + if len(rows) != 1 || rows[0] != want[0] { + t.Fatalf("result rows = %#v, want %#v", rows, want) + } +} diff --git a/cmd/uhost_test.go b/cmd/uhost_test.go index a6657a6be8..10ad51accd 100644 --- a/cmd/uhost_test.go +++ b/cmd/uhost_test.go @@ -1,230 +1,141 @@ +//go:build live +// +build live + package cmd import ( "bytes" - "encoding/json" "fmt" "regexp" "strings" "testing" "time" - "github.com/ucloud/ucloud-cli/ux" - "github.com/ucloud/ucloud-cli/base" -) - -type listUhostTest struct { - expectedUhosts []string - expectedOut string -} - -func (test listUhostTest) run(t *testing.T) { - buf := new(bytes.Buffer) - cmd := NewCmdUHostList(buf) - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error executing command:%v", err) - } -} - -type listImageTest struct { - flags []string -} + svcuhost "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" -func (test *listImageTest) run(t *testing.T) string { - global.JSON = true - buf := new(bytes.Buffer) - cmd := NewCmdUImageList(buf) - cmd.Flags().Parse(test.flags) - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error executing command: %v, flags: %v", err, test.flags) - } + "github.com/ucloud/ucloud-cli/cmd/internal/platform" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/uhost" +) - var images []ImageRow - err := json.Unmarshal(buf.Bytes(), &images) +// uhost_test.go drives the live UHost flow through the migrated products/uhost +// command tree (uhost moved out of cmd in Part 6). It hits the real API, +// creates paid resources, and needs valid credentials, so it is gated behind +// the `live` build tag. Run it explicitly with: +// `go test -tags live ./cmd -run '^TestUhost$' -count=1`. +// The image-id lookup the old test did via +// the cmd-local NewCmdUImageList/ImageRow shim is now a direct DescribeImage SDK +// call (image is served by the uhost SDK). create/delete narration now flows +// through ctx.NewProgress → ctx.ProgressWriter (the ctx Out buffer in table +// mode) instead of the old global progress document, so the test captures the +// ctx Out buffer. + +// fetchLiveImageID returns the first Available Base image id via DescribeImage. +func fetchLiveImageID(t *testing.T) string { + client := newServiceClient(svcuhost.NewClient) + req := client.NewDescribeImageRequest() + req.ImageType = sdk.String("Base") + resp, err := client.DescribeImage(req) if err != nil { - t.Fatalf("unexpected error of fetching image list: %v", err) - } - if len(images) == 0 { - t.Fatalf("image list is empty") - } - // for _, image := range images { - // // image.ImageName - // } - return images[0].ImageID -} - -type createUHostTest struct { - flags []string - uhostIDs []string - expectedOutRegexp *regexp.Regexp -} - -func (test *createUHostTest) run(t *testing.T) { - cmd := NewCmdUHostCreate() - cmd.Flags().Parse(test.flags) - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error executing command: %v, flags: %v", err, test.flags) + t.Fatalf("unexpected error fetching image list: %v", err) } - lines := ux.Doc.Content() - content := strings.Join(lines, "\n") - list := test.expectedOutRegexp.FindStringSubmatch(content) - if list == nil { - t.Errorf("unexpect output:%s", content) - } else { - if len(list) == 2 { - test.uhostIDs = append(test.uhostIDs, list[1]) + for _, image := range resp.ImageSet { + if image.State == "Available" { + return image.ImageId } } -} - -type deleteUHostTest struct { - flags []string - uhostIDs []string - expectedOutRegexp *regexp.Regexp -} - -func (test *deleteUHostTest) run(t *testing.T) { - buf := new(bytes.Buffer) - cmd := NewCmdUHostDelete(buf) - cmd.Flags().Parse(test.flags) - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error executing command: %v, flags: %v", err, test.flags) - } - list := test.expectedOutRegexp.FindStringSubmatch(buf.String()) - if list == nil { - t.Errorf("unexpect output:%s", buf.String()) - } -} - -type stopUHostTest struct { - flags []string - uhostIDs []string - expectedOutRegexp *regexp.Regexp -} - -func (test *stopUHostTest) run(t *testing.T) { - buf := new(bytes.Buffer) - cmd := NewCmdUHostStop(buf) - cmd.Flags().Parse(test.flags) - - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error executing command: %v, flags: %v", err, test.flags) - } - list := test.expectedOutRegexp.FindStringSubmatch(buf.String()) - if list == nil { - t.Errorf("unexpect output:%s", buf.String()) - } -} - -type startUHostTest struct { - flags []string - uhostIDs []string - expectedOutRegexp *regexp.Regexp -} - -func (test *startUHostTest) run(t *testing.T) { - buf := new(bytes.Buffer) - cmd := NewCmdUHostStart(buf) - cmd.Flags().Parse(test.flags) - - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error executing command: %v, flags: %v", err, test.flags) - } - list := test.expectedOutRegexp.FindStringSubmatch(buf.String()) - if list == nil { - t.Errorf("unexpect output:%s", buf.String()) - } -} - -type restartUHostTest struct { - flags []string - uhostIDs []string - expectedOutRegexp *regexp.Regexp -} - -func (test *restartUHostTest) run(t *testing.T) { - buf := new(bytes.Buffer) - cmd := NewCmdUHostReboot(buf) - cmd.Flags().Parse(test.flags) - - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error executing command: %v, flags: %v", err, test.flags) - } - list := test.expectedOutRegexp.FindStringSubmatch(buf.String()) - if list == nil { - t.Errorf("unexpect output:%s", buf.String()) - } -} - -type poweroffUHostTest struct { - flags []string - uhostIDs []string - expectedOutRegexp *regexp.Regexp -} - -func (test *poweroffUHostTest) run(t *testing.T) { - buf := new(bytes.Buffer) - cmd := NewCmdUHostPoweroff(buf) - cmd.Flags().Parse(test.flags) - - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error executing command: %v, flags: %v", err, test.flags) - } - list := test.expectedOutRegexp.FindStringSubmatch(buf.String()) - if list == nil { - t.Errorf("unexpect output:%s", buf.String()) - } + t.Fatalf("image list is empty") + return "" } func TestUhost(t *testing.T) { - base.InitConfig() - listImageT := listImageTest{ - flags: []string{"--json"}, - } - imageID := listImageT.run(t) - - createT := createUHostTest{expectedOutRegexp: regexp.MustCompile(`uhost\[([\w-]+)\] is initializing\.\.\.done`), - flags: []string{ - "--cpu=1", - "--memory-gb=1", - "--image-id=" + imageID, - "--password=testlxj@123", + platform.InitConfig() + var out bytes.Buffer + // Buffer-backed ctx (table mode): create/delete narration via ctx.NewProgress + // routes to ProgressWriter == Out; the cmd-package completion providers + real + // config preserve the live behaviour. + ctx := cli.NewContext(cli.Deps{ + In: strings.NewReader(""), + Out: &out, + Err: &out, + Format: cli.OutputTable, + DefaultsProvider: func() command.Defaults { + return command.Defaults{Region: platform.ConfigIns.Region, Zone: platform.ConfigIns.Zone, ProjectID: platform.ConfigIns.ProjectID} }, + RegionList: getRegionList, + ZoneList: getZoneList, + ProjectList: getProjectList, + AllRegions: getAllRegions, + ClientConfig: func() *sdk.Config { return platform.ClientConfig }, + BuildCredential: platform.BuildCredential, + AttachHandlers: platform.AttachHandlers, + }) + root := topLevelCmd(t, uhost.New().NewCommand(ctx), "uhost") + + imageID := fetchLiveImageID(t) + + runE := func(name string, flags []string) (string, error) { + out.Reset() + subCmd(t, root, name) + root.SetArgs(append([]string{name}, flags...)) + if err := root.Execute(); err != nil { + return out.String(), fmt.Errorf("unexpected error executing %s: %w, flags: %v", name, err, flags) + } + return out.String(), nil } - createT.run(t) + run := func(name string, flags []string) string { + content, err := runE(name, flags) + if err != nil { + t.Fatalf("%v, output: %s", err, content) + } + return content + } + + createOut := run("create", []string{ + "--zone=cn-bj2-03", + "--cpu=1", + "--memory-gb=1", + "--image-id=" + imageID, + "--password=testlxj@123", + "--hot-plug=false", + "--data-disk-type=NONE", + }) + createRe := regexp.MustCompile(`uhost\[([\w-]+)\] is initializing\.\.\.done`) + m := createRe.FindStringSubmatch(createOut) + if m == nil { + t.Errorf("unexpect create output:%s", createOut) + return + } + uhostID := m[1] + idFlag := fmt.Sprintf("--uhost-id=%s", uhostID) + deleted := false + defer func() { + if deleted { + return + } + content, err := runE("delete", []string{"--yes", "--destroy", idFlag}) + if err != nil { + t.Logf("cleanup delete failed for %s: %v, output: %s", uhostID, err, content) + return + } + t.Logf("cleanup delete succeeded for %s: %s", uhostID, content) + }() - restartT := restartUHostTest{ - flags: []string{fmt.Sprintf("--uhost-id=%s", strings.Join(createT.uhostIDs, ","))}, - expectedOutRegexp: regexp.MustCompile(`uhost\[([\w-]+)\] is restarting\.\.\.done`), + assertRun := func(name string, flags []string, re *regexp.Regexp) { + content := run(name, flags) + if re.FindStringSubmatch(content) == nil { + t.Errorf("unexpect %s output:%s", name, content) + } } - restartT.run(t) - poweroffT := poweroffUHostTest{ - flags: []string{"--yes", fmt.Sprintf("--uhost-id=%s", strings.Join(createT.uhostIDs, ","))}, - expectedOutRegexp: regexp.MustCompile(`uhost\[([\w-]+)\] is power off`), - } - poweroffT.run(t) + assertRun("restart", []string{idFlag}, regexp.MustCompile(`uhost\[([\w-]+)\] is restarting\.\.\.done`)) + assertRun("poweroff", []string{"--yes", idFlag}, regexp.MustCompile(`uhost\[([\w-]+)\] is power off`)) time.Sleep(time.Second * 5) - startT := startUHostTest{ - flags: []string{fmt.Sprintf("--uhost-id=%s", strings.Join(createT.uhostIDs, ","))}, - expectedOutRegexp: regexp.MustCompile(`uhost\[([\w-]+)\] is starting\.\.\.done`), - } - startT.run(t) - - stopT := stopUHostTest{ - flags: []string{fmt.Sprintf("--uhost-id=%s", strings.Join(createT.uhostIDs, ","))}, - expectedOutRegexp: regexp.MustCompile(`uhost\[([\w-]+)\] is shutting down\.\.\.done`), - } - - stopT.run(t) - - deleteT := deleteUHostTest{ - uhostIDs: createT.uhostIDs, - expectedOutRegexp: regexp.MustCompile(`uhost\[([\w-]+)\] deleted`), - flags: []string{"--yes"}, - } - deleteT.flags = append(deleteT.flags, fmt.Sprintf("--uhost-id=%s", strings.Join(deleteT.uhostIDs, ","))) - deleteT.run(t) - + assertRun("start", []string{idFlag}, regexp.MustCompile(`uhost\[([\w-]+)\] is starting\.\.\.done`)) + assertRun("stop", []string{idFlag}, regexp.MustCompile(`uhost\[([\w-]+)\] is shutting down\.\.\.done`)) + run("delete", []string{"--yes", "--destroy", idFlag}) + deleted = true } diff --git a/cmd/ulb.go b/cmd/ulb.go deleted file mode 100644 index a4409054d3..0000000000 --- a/cmd/ulb.go +++ /dev/null @@ -1,1698 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - "io/ioutil" - "strings" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-sdk-go/services/ulb" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/model/status" -) - -//NewCmdULB ucloud ulb -func NewCmdULB() *cobra.Command { - cmd := &cobra.Command{ - Use: "ulb", - Short: "List and manipulate ULB instances", - Long: "List and manipulate ULB instances", - } - out := base.Cxt.GetWriter() - - cmd.AddCommand(NewCmdULBList(out)) - cmd.AddCommand(NewCmdULBCreate(out)) - cmd.AddCommand(NewCmdULBUpdate(out)) - cmd.AddCommand(NewCmdULBDelete(out)) - cmd.AddCommand(NewCmdULBVserver()) - cmd.AddCommand(NewCmdULBSSL()) - - return cmd -} - -//ULBRow 表格行 -type ULBRow struct { - Name string - ResourceID string - Group string - Network string - VserverCount int - VPC string - CreationTime string -} - -//NewCmdULBList ucloud ulb list -func NewCmdULBList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeULBRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List ULB instances", - Long: "List ULB instances", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.VPCId = sdk.String(base.PickResourceID(*req.VPCId)) - resp, err := base.BizClient.DescribeULB(req) - if err != nil { - base.HandleError(err) - return - } - list := []ULBRow{} - for _, ulb := range resp.DataSet { - row := ULBRow{} - row.ResourceID = ulb.ULBId - row.Name = ulb.Name - row.Group = ulb.BusinessId - row.VserverCount = len(ulb.VServerSet) - row.VPC = ulb.VPCId - row.CreationTime = base.FormatDate(ulb.CreateTime) - if ulb.ULBType == "OuterMode" { - ips := []string{} - for _, ip := range ulb.IPSet { - ips = append(ips, fmt.Sprintf("%s(%s)", ip.EIP, ip.EIPId)) - } - row.Network = strings.Join(ips, ",") - } else { - row.Network = ulb.PrivateIP - } - list = append(list, row) - } - - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - - req.ULBId = flags.String("ulb-id", "", "Optional. Resource ID of ULB instance to list") - req.VPCId = flags.String("vpc-id", "", "Optional. Resource ID of VPC which the ULB instances to list belong to") - req.SubnetId = flags.String("subnet-id", "", "Optional. Resource ID of subnet which the ULB instances to list belong to") - req.BusinessId = flags.String("group", "", "Optional. Business group of ULB instances to list") - req.Offset = flags.Int("offset", 0, "Optional. Offset") - req.Limit = flags.Int("limit", 50, "Optional. Limit") - - flags.SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - - return cmd -} - -//NewCmdULBCreate ucloud ulb create -func NewCmdULBCreate(out io.Writer) *cobra.Command { - var bindEipID *string - mode := "outer" - req := base.BizClient.NewCreateULBRequest() - eipReq := base.BizClient.NewAllocateEIPRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create ULB instance", - Long: "Create ULB instance", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - if mode == "outer" { - if *bindEipID == "" && *eipReq.Bandwidth == 0 { - fmt.Fprintln(out, "Outer mode ULB need a eip to bind, please assign eip by flag 'bind-eip' or create eip by 'create-eip-bandwidth-mb'") - return - } - if *eipReq.OperatorName == "" { - *eipReq.OperatorName = getEIPLine(*req.Region) - } - req.OuterMode = sdk.String("Yes") - } else if mode == "inner" { - req.InnerMode = sdk.String("Yes") - } else { - fmt.Fprintln(out, "Error, flag mode should be 'outer' or 'inner'") - return - } - req.VPCId = sdk.String(base.PickResourceID(*req.VPCId)) - req.SubnetId = sdk.String(base.PickResourceID(*req.SubnetId)) - resp, err := base.BizClient.CreateULB(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "ulb[%s] created\n", resp.ULBId) - if mode == "inner" { - return - } - bindEipID = sdk.String(base.PickResourceID(*bindEipID)) - if *bindEipID != "" { - bindEIP(sdk.String(resp.ULBId), sdk.String("ulb"), bindEipID, req.ProjectId, req.Region) - return - } - if *eipReq.OperatorName != "" && *eipReq.Bandwidth != 0 { - eipReq.ChargeType = req.ChargeType - eipReq.Tag = req.Tag - eipReq.Region = req.Region - eipReq.ProjectId = req.ProjectId - eipResp, err := base.BizClient.AllocateEIP(eipReq) - - if err != nil { - base.HandleError(err) - return - } - - for _, eip := range eipResp.EIPSet { - base.Cxt.Printf("allocate EIP[%s] ", eip.EIPId) - for _, ip := range eip.EIPAddr { - base.Cxt.Printf("IP:%s Line:%s \n", ip.IP, ip.OperatorName) - } - bindEIP(sdk.String(resp.ULBId), sdk.String("ulb"), sdk.String(eip.EIPId), req.ProjectId, req.Region) - } - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.ULBName = flags.String("name", "", "Required. Name of ULB instance to create") - flags.StringVar(&mode, "mode", "outer", "Required. Network mode of ULB instance, outer or inner.") - bindRegion(req, flags) - bindProjectID(req, flags) - req.VPCId = flags.String("vpc-id", "", "Optional. Resource ID of VPC which the ULB to create belong to. See 'ucloud vpc list'") - req.SubnetId = flags.String("subnet-id", "", "Optional. Resource ID of subnet. This flag will be discarded when you are creating an outter mode ULB. See 'ucloud subnet list'") - req.ChargeType = flags.String("charge-type", "Month", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") - req.Tag = flags.String("group", "Default", "Optional. Business group") - req.Remark = flags.String("remark", "", "Optional. Remark of instance to create.") - bindEipID = flags.String("bind-eip", "", "Optional. Resource ID or IP Address of eip that will be bound to the new created outer mode ulb") - eipReq.Bandwidth = cmd.Flags().Int("create-eip-bandwidth-mb", 0, "Optional. Required if you want to create new EIP. Bandwidth(Unit:Mbps).The range of value related to network charge mode. By traffic [1, 300]; by bandwidth [1,800] (Unit: Mbps); it could be 0 if the eip belong to the shared bandwidth") - eipReq.OperatorName = flags.String("create-eip-line", "", "Optional. Line of created eip to bind with the new created outer mode ulb") - eipReq.PayMode = cmd.Flags().String("create-eip-traffic-mode", "Bandwidth", "Optional. 'Traffic','Bandwidth' or 'ShareBandwidth'") - eipReq.Name = flags.String("create-eip-name", "", "Optional. Name of created eip to bind with the new created outer mode ulb") - eipReq.Remark = cmd.Flags().String("create-eip-remark", "", "Optional. Remark of your EIP.") - - flags.SetFlagValues("mode", "outer", "inner") - flags.SetFlagValues("charge-type", "Month", "Year", "Dynamic") - flags.SetFlagValues("create-eip-line", "BGP", "International") - flags.SetFlagValues("create-eip-traffic-mode", "Bandwidth", "Traffic", "ShareBandwidth") - flags.SetFlagValuesFunc("bind-eip", func() []string { - return getAllEip(*req.ProjectId, *req.Region, []string{status.EIP_FREE}, nil) - }) - flags.SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("subnet-id", func() []string { - return getAllSubnetIDNames(*req.VPCId, *req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("mode") - cmd.MarkFlagRequired("name") - - return cmd -} - -//NewCmdULBDelete ucloud ulb delete -func NewCmdULBDelete(out io.Writer) *cobra.Command { - idNames := []string{} - req := base.BizClient.NewDeleteULBRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete ULB instances by resource ID", - Long: "Delete ULB instances by resource ID", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - for _, idname := range idNames { - req.ULBId = sdk.String(base.PickResourceID(idname)) - _, err := base.BizClient.DeleteULB(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "ulb[%s] deleted\n", idname) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "ulb-id", nil, "Required. Resource ID of the ULB instances to delete") - bindRegion(req, flags) - bindProjectID(req, flags) - - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("ulb-id") - - return cmd -} - -//NewCmdULBUpdate ucloud ulb update -func NewCmdULBUpdate(out io.Writer) *cobra.Command { - var name, group, remark string - idNames := []string{} - req := base.BizClient.NewUpdateULBAttributeRequest() - cmd := &cobra.Command{ - Use: "update", - Short: "Update ULB instance", - Long: "Update ULB instance", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - for _, idname := range idNames { - req.ULBId = sdk.String(base.PickResourceID(idname)) - if name == "" && group == "" && remark == "" { - fmt.Fprintln(out, "Error, name, remark and group can't be all empty") - return - } - if name != "" { - req.Name = &name - } - if group != "" { - req.Tag = &group - } - if remark != "" { - req.Remark = &remark - } - _, err := base.BizClient.UpdateULBAttribute(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "ulb[%s] updated\n", *req.ULBId) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - flags.StringSliceVar(&idNames, "ulb-id", nil, "Required. Resource ID of ULB instances to update") - flags.StringVar(&name, "name", "", "Optional, Name of ULB instance") - flags.StringVar(&remark, "remark", "", "Optional, Remark of ULB instance") - flags.StringVar(&group, "group", "", "Optional, Business group of ULB instance") - // bindGroup(&group, flags) - - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("ulb-id") - - return cmd -} - -func getAllULB(project, region string) ([]ulb.ULBSet, error) { - list := []ulb.ULBSet{} - req := base.BizClient.NewDescribeULBRequest() - req.ProjectId = &project - req.Region = ®ion - - for offset, limit := 0, 50; ; offset += limit { - req.Offset = sdk.Int(offset) - req.Limit = sdk.Int(limit) - resp, err := base.BizClient.DescribeULB(req) - - if err != nil { - return nil, err - } - list = append(list, resp.DataSet...) - - if resp.TotalCount < offset+limit { - break - } - } - return list, nil -} - -func getAllULBIDNames(project, region string) []string { - list := []string{} - ulbList, err := getAllULB(project, region) - if err != nil { - return nil - } - for _, ulb := range ulbList { - list = append(list, fmt.Sprintf("%s/%s", ulb.ULBId, ulb.Name)) - } - return list -} - -//NewCmdULBVserver ucloud ulb-vserver -func NewCmdULBVserver() *cobra.Command { - cmd := &cobra.Command{ - Use: "vserver", - Short: "List and manipulate ULB Vserver instances", - Long: "List and manipulate ULB Vserver instances", - } - out := base.Cxt.GetWriter() - - cmd.AddCommand(NewCmdULBVServerList(out)) - cmd.AddCommand(NewCmdULBVServerCreate(out)) - cmd.AddCommand(NewCmdULBVServerUpdate(out)) - cmd.AddCommand(NewCmdULBVServerDelete(out)) - cmd.AddCommand(NewCmdULBVServerNode()) - cmd.AddCommand(NewCmdULBVServerPolicy()) - - return cmd -} - -//ULBVServerRow 表格行 -type ULBVServerRow struct { - VServerName string - ResourceID string - ListenType string - Protocol string - Port int - LBMethod string - SessionMaintainMode string - SessionMaintainKey string - ClientTimeout string - HealthCheckMode string - HealthCheckDomain string - HealthCheckPath string -} - -//NewCmdULBVServerList ucloud ulb-vserver list -func NewCmdULBVServerList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeVServerRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List ULB Vserver instances", - Long: "List ULB Vserver instances", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - resp, err := base.BizClient.DescribeVServer(req) - if err != nil { - base.HandleError(err) - return - } - list := []ULBVServerRow{} - for _, vs := range resp.DataSet { - row := ULBVServerRow{} - row.VServerName = vs.VServerName - row.ResourceID = vs.VServerId - row.ListenType = vs.ListenType - row.Protocol = vs.Protocol - row.Port = vs.FrontendPort - row.LBMethod = vs.Method - row.ClientTimeout = fmt.Sprintf("%ds", vs.ClientTimeout) - row.SessionMaintainMode = vs.PersistenceType - row.SessionMaintainKey = vs.PersistenceInfo - row.HealthCheckMode = vs.MonitorType - row.HealthCheckDomain = vs.Domain - row.HealthCheckPath = vs.Path - list = append(list, row) - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB") - req.VServerId = flags.String("vserver-id", "", "Optional. Resource ID of vserver to list") - - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("ulb-id") - - return cmd -} - -//NewCmdULBVServerCreate ucloud ulb-vserver create -func NewCmdULBVServerCreate(out io.Writer) *cobra.Command { - sslID := "" - req := base.BizClient.NewCreateVServerRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create ULB VServer instance", - Long: "Create ULB VServer instance", - Run: func(c *cobra.Command, args []string) { - if *req.ListenType == "RequestProxy" && (*req.ClientTimeout <= 0 || *req.ClientTimeout > 86400) { - fmt.Println("Error, client-timeout-seconds in the range of (0,86400]") - return - } - if *req.ListenType == "PacketsTransmit" && (*req.ClientTimeout <= 0 || *req.ClientTimeout > 86400) { - fmt.Println("Error, client-timeout-seconds in the range of [60,900]") - return - } - if *req.Protocol == "HTTPS" && sslID == "" { - fmt.Println("Error, SSL Certificate is needed when you choose HTTPS") - return - } - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - resp, err := base.BizClient.CreateVServer(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "ulb-vserver[%s] created\n", resp.VServerId) - if *req.Protocol == "HTTPS" && sslID != "" { - bindReq := base.BizClient.NewBindSSLRequest() - bindReq.Region = req.Region - bindReq.ProjectId = req.ProjectId - bindReq.SSLId = sdk.String(base.PickResourceID(sslID)) - bindReq.VServerId = sdk.String(resp.VServerId) - bindReq.ULBId = req.ULBId - _, err := base.BizClient.BindSSL(bindReq) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "ssl certificate[%s] bind with vserver[%s] of ulb[%s]\n", sslID, *bindReq.VServerId, *bindReq.ULBId) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB instance which the VServer to create belongs to") - bindRegion(req, flags) - bindProjectID(req, flags) - req.VServerName = flags.String("name", "", "Optional. Name of VServer to create") - req.ListenType = flags.String("listen-type", "RequestProxy", "Optional. Listen type, 'RequestProxy' or 'PacketsTransmit'") - req.Protocol = flags.String("protocol", "HTTP", "Optional. Protocol of VServer instance, 'HTTP','HTTPS','TCP' for listen type 'RequestProxy' and 'TCP','UDP' for listen type 'PacketsTransmit'") - req.FrontendPort = flags.Int("port", 80, "Optional. Port of VServer instance") - flags.StringVar(&sslID, "ssl-id", "", "Optional. Required if you choose HTTPS, Resource ID of SSL Certificate") - req.Method = flags.String("lb-method", "Roundrobin", "Optional. LB methods, accept values:Roundrobin,Source,ConsistentHash,SourcePort,ConsistentHashPort,WeightRoundrobin and Leastconn. \nConsistentHash,SourcePort and ConsistentHashPort are effective for listen type PacketsTransmit only;\nLeastconn is effective for listen type RequestProxy only;\nRoundrobin,Source and WeightRoundrobin are effective for both listen types") - req.PersistenceType = flags.String("session-maintain-mode", "None", "Optional. The method of maintaining user's session. Accept values: 'None','ServerInsert' and 'UserDefined'. 'None' meaning don't maintain user's session'; 'ServerInsert' meaning auto create session key; 'UserDefined' meaning specify session key which accpeted by flag seesion-maintain-key by yourself") - req.PersistenceInfo = flags.String("session-maintain-key", "", "Optional. Specify a key for maintaining session") - req.ClientTimeout = flags.Int("client-timeout-seconds", 60, "Optional.Unit seconds. For 'RequestProxy', it's lifetime for idle connections, range (0,86400]. For 'PacketsTransmit', it's the duration of the connection is maintained, range [60,900]") - req.MonitorType = flags.String("health-check-mode", "Port", "Optional. Method of checking real server's status of health. Accept values:'Port','Path'") - req.Domain = flags.String("health-check-domain", "", "Optional. Skip this flag if health-check-mode is assigned Port") - req.Path = flags.String("health-check-path", "", "Optional. Skip this flags if health-check-mode is assigned Port") - - flags.SetFlagValues("listen-type", "RequestProxy", "PacketsTransmit") - flags.SetFlagValues("protocol", "HTTP", "HTTPS", "TCP", "UDP") - flags.SetFlagValuesFunc("lb-method", func() []string { - if *req.ListenType == "RequestProxy" { - return []string{"Roundrobin", "Source", "WeightRoundrobin", "Leastconn"} - } else if *req.ListenType == "PacketsTransmit" { - return []string{"Roundrobin", "Source", "WeightRoundrobin", "ConsistentHash", "SourcePort", "ConsistentHashPort"} - } - return []string{"Roundrobin", "Source", "WeightRoundrobin", "ConsistentHash", "SourcePort", "ConsistentHashPort", "Leastconn"} - }) - flags.SetFlagValues("session-maintain-mode", "None", "ServerInsert", "UserDefined") - flags.SetFlagValues("health-check-mode", "Port", "Path") - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("ssl-id", func() []string { - return getAllSSLCertIDNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("ulb-id") - - return cmd -} - -//NewCmdULBVServerUpdate ucloud ulb-vserver update -func NewCmdULBVServerUpdate(out io.Writer) *cobra.Command { - req := base.BizClient.NewUpdateVServerAttributeRequest() - vserverIDs := []string{} - cmd := &cobra.Command{ - Use: "update", - Short: "Update attributes of VServer instances", - Long: "Update attributes of VServer instances", - Run: func(c *cobra.Command, args []string) { - if *req.VServerName == "" { - req.VServerName = nil - } - if *req.Method == "" { - req.Method = nil - } - if *req.PersistenceType == "" { - req.PersistenceType = nil - } - if *req.PersistenceInfo == "" { - req.PersistenceInfo = nil - } - if *req.ClientTimeout == -1 { - req.ClientTimeout = nil - } - if *req.MonitorType == "" { - req.MonitorType = nil - } - if *req.Domain == "" { - req.Domain = nil - } - if *req.Path == "" { - req.Path = nil - } - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - for _, idname := range vserverIDs { - req.VServerId = sdk.String(base.PickResourceID(idname)) - _, err := base.BizClient.UpdateVServerAttribute(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "ulb-vserver[%s] updated\n", *req.VServerId) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB instance which the VServer to create belongs to") - flags.StringSliceVar(&vserverIDs, "vserver-id", nil, "Required. Resource ID of Vserver to update") - bindRegion(req, flags) - bindProjectID(req, flags) - req.VServerName = flags.String("name", "", "Optional. Name of VServer") - req.Method = flags.String("lb-method", "", "Optional. LB methods, accept values:Roundrobin,Source,ConsistentHash,SourcePort,ConsistentHashPort,WeightRoundrobin and Leastconn. \nConsistentHash,SourcePort and ConsistentHashPort are effective for listen type PacketsTransmit only;\nLeastconn is effective for listen type RequestProxy only;\nRoundrobin,Source and WeightRoundrobin are effective for both listen types") - req.PersistenceType = flags.String("session-maintain-mode", "", "Optional. The method of maintaining user's session. Accept values: 'None','ServerInsert' and 'UserDefined'. 'None' meaning don't maintain user's session'; 'ServerInsert' meaning auto create session key; 'UserDefined' meaning specify session key which accpeted by flag seesion-maintain-key by yourself") - req.PersistenceInfo = flags.String("session-maintain-key", "", "Optional. Specify a key for maintaining session") - req.ClientTimeout = flags.Int("client-timeout-seconds", -1, "Optional.Unit seconds. For 'RequestProxy', it's lifetime for idle connections, range (0,86400]. For 'PacketsTransmit', it's the duration of the connection is maintained, range [60,900]") - req.MonitorType = flags.String("health-check-mode", "", "Optional. Method of checking real server's status of health. Accept values:'Port','Path'") - req.Domain = flags.String("health-check-domain", "", "Optional. Skip this flag if health-check-mode is assigned Port") - req.Path = flags.String("health-check-path", "", "Optional. Skip this flags if health-check-mode is assigned Port") - - flags.SetFlagValues("lb-method", "Roundrobin", "Source", "WeightRoundrobin", "ConsistentHash", "SourcePort", "ConsistentHashPort", "Leastconn") - flags.SetFlagValues("session-maintain-mode", "None", "ServerInsert", "UserDefined") - flags.SetFlagValues("health-check-mode", "Port", "Path") - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("vserver-id", func() []string { - ulbID := base.PickResourceID(*req.ULBId) - return getAllULBVServerIDNames(ulbID, *req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("ulb-id") - cmd.MarkFlagRequired("vserver-id") - - return cmd -} - -//NewCmdULBVServerDelete ucloud ulb-vserver delete -func NewCmdULBVServerDelete(out io.Writer) *cobra.Command { - vserverIDs := []string{} - req := base.BizClient.NewDeleteVServerRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete ULB VServer instances", - Long: "Delete ULB VServer instances", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - for _, idname := range vserverIDs { - vsid := base.PickResourceID(idname) - req.VServerId = sdk.String(vsid) - _, err := base.BizClient.DeleteVServer(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "ulb-vserver[%s] deleted\n", idname) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB instance which the VServer to create belongs to") - flags.StringSliceVar(&vserverIDs, "vserver-id", nil, "Required. Resource ID of Vserver to update") - bindRegion(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("ulb-id") - cmd.MarkFlagRequired("vserver-id") - - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("vserver-id", func() []string { - ulbID := base.PickResourceID(*req.ULBId) - return getAllULBVServerIDNames(ulbID, *req.ProjectId, *req.Region) - }) - - return cmd -} - -//NewCmdULBVServerNode ucloud ulb vserver node -func NewCmdULBVServerNode() *cobra.Command { - out := base.Cxt.GetWriter() - cmd := &cobra.Command{ - Use: "backend", - Short: "List and manipulate VServer backend nodes", - Long: "List and manipulate VServer backend nodes", - } - cmd.AddCommand(NewCmdULBVServerListNode(out)) - cmd.AddCommand(NewCmdULBVServerAddNode(out)) - cmd.AddCommand(NewCmdULBVServerUpdateNode(out)) - cmd.AddCommand(NewCmdULBVServerDeleteNode(out)) - return cmd -} - -//ULBVServerNode 表格行 -type ULBVServerNode struct { - Name string - ResourceID string - BackendID string - PrivateIP string - Port int - HealthCheck string - NodeMode string - Weight int -} - -//NewCmdULBVServerListNode ucloud ulb-vserver list-node -func NewCmdULBVServerListNode(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeVServerRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List ULB VServer backend nodes", - Long: "List ULB VServer backend nodes", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - req.VServerId = sdk.String(base.PickResourceID(*req.VServerId)) - resp, err := base.BizClient.DescribeVServer(req) - if err != nil { - base.HandleError(err) - return - } - if len(resp.DataSet) != 1 { - fmt.Fprintf(out, "ulb[%s] or vserver[%s] may not exist\n", *req.ULBId, *req.VServerId) - return - } - vs := resp.DataSet[0] - list := []ULBVServerNode{} - for _, node := range vs.BackendSet { - row := ULBVServerNode{} - row.Name = node.ResourceName - row.ResourceID = node.ResourceId - row.BackendID = node.BackendId - row.PrivateIP = node.PrivateIP - row.Weight = node.Weight - row.Port = node.Port - if node.Status == 0 { - row.HealthCheck = "Normal" - } else if node.Status == 1 { - row.HealthCheck = "Failed" - } - if node.Enabled == 1 { - row.NodeMode = "enable" - } else if node.Enabled == 0 { - row.NodeMode = "disable" - } - list = append(list, row) - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB which the backend nodes belong to") - req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer which the backend nodes belong to") - bindRegion(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("ulb-id") - cmd.MarkFlagRequired("vserver-id") - - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("vserver-id", func() []string { - ulbID := base.PickResourceID(*req.ULBId) - return getAllULBVServerIDNames(ulbID, *req.ProjectId, *req.Region) - }) - - return cmd -} - -//NewCmdULBVServerAddNode ucloud ulb-vserver add-node -func NewCmdULBVServerAddNode(out io.Writer) *cobra.Command { - var enable *string - var weight *int - var ids []string - req := base.BizClient.NewAllocateBackendRequest() - cmd := &cobra.Command{ - Use: "add", - Short: "Add backend nodes for ULB Vserver instance", - Long: "Add backend nodes for ULB Vserver instance", - Run: func(c *cobra.Command, args []string) { - if *enable == "enable" { - req.Enabled = sdk.Int(1) - } else if *enable == "disable" { - req.Enabled = sdk.Int(0) - } else { - fmt.Fprintln(out, "Error, backend-mode must be enable or disable") - return - } - if *weight < 0 || *weight > 100 { - fmt.Fprintln(out, "Error, weight must be between 0 and 100") - return - } - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - req.VServerId = sdk.String(base.PickResourceID(*req.VServerId)) - for _, id := range ids { - req.ResourceId = sdk.String(id) - resp, err := base.BizClient.AllocateBackend(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "backend node[%s] added, backend-id:%s\n", *req.ResourceId, resp.BackendId) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB which the backend nodes belong to") - req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer which the backend nodes belong to") - flags.StringSliceVar(&ids, "resource-id", nil, "Required. Resource ID of the backend nodes to add") - bindRegion(req, flags) - bindProjectID(req, flags) - req.ResourceType = flags.String("resource-type", "UHost", "Optional. Resource type of the backend node to add. Accept values: UHost,UPM,UDHost,UDocker") - req.Port = flags.Int("port", 80, "Optional. The port of your real server on the backend node listening on") - enable = flags.String("backend-mode", "enable", "Optional. Enable backend node or not. Accept values: enable, disable") - weight = flags.Int("weight", 1, "Optional. effective for lb-method WeightRoundrobin. Rnage [0,100]") - - flags.SetFlagValues("resource-type", "Uhost", "UPM", "UDHost", "UDocker") - flags.SetFlagValues("backend-mode", "enable", "disable") - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("vserver-id", func() []string { - ulbID := base.PickResourceID(*req.ULBId) - return getAllULBVServerIDNames(ulbID, *req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("ulb-id") - cmd.MarkFlagRequired("vserver-id") - cmd.MarkFlagRequired("resource-id") - return cmd -} - -//NewCmdULBVServerUpdateNode ucloud ulb-vserver update-node -func NewCmdULBVServerUpdateNode(out io.Writer) *cobra.Command { - var mode *string - var weight *int - backendIDs := []string{} - req := base.BizClient.NewUpdateBackendAttributeRequest() - cmd := &cobra.Command{ - Use: "update", - Short: "Update attributes of ULB backend nodes", - Long: "Update attributes of ULB backend nodes", - Run: func(c *cobra.Command, args []string) { - if *mode == "enable" { - req.Enabled = sdk.Int(1) - } else if *mode == "disable" { - req.Enabled = sdk.Int(0) - } else if *mode == "" { - req.Enabled = nil - } else { - fmt.Fprintln(out, "Error, backend-mode must be enable or disable") - return - } - if *weight != -1 && (*weight < 0 || *weight > 100) { - fmt.Fprintln(out, "Error, weight must be between 0 and 100") - return - } - if *weight != -1 { - req.Weight = weight - } - - if *req.Port == 0 { - req.Port = nil - } - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - for _, bid := range backendIDs { - req.BackendId = sdk.String(base.PickResourceID(bid)) - _, err := base.BizClient.UpdateBackendAttribute(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "backend node[%s] updated\n", bid) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB which the backend nodes belong to") - flags.StringSliceVar(&backendIDs, "backend-id", nil, "Required. BackendID of backend nodes to update") - req.Port = flags.Int("port", 0, "Optional. Port of your real server listening on backend nodes to update. Rnage [1,65535]") - mode = flags.String("backend-mode", "", "Optional. Enable backend node or not. Accept values: enable, disable") - weight = flags.Int("weight", -1, "Optional. effective for lb-method WeightRoundrobin. Rnage [0,100], -1 meaning no update") - - bindRegion(req, flags) - bindProjectID(req, flags) - - flags.SetFlagValues("backend-mode", "enable", "disable") - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("backend-id", func() []string { - return getAllULBVServerNodeIDNames(*req.ULBId, "", *req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("ulb-id") - cmd.MarkFlagRequired("backend-id") - - return cmd -} - -//NewCmdULBVServerDeleteNode ucloud ulb-vserver delete-node -func NewCmdULBVServerDeleteNode(out io.Writer) *cobra.Command { - backendIDs := []string{} - req := base.BizClient.NewReleaseBackendRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete ULB VServer backend nodes", - Long: "Delete ULB VServer backend nodes", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - for _, idname := range backendIDs { - req.BackendId = sdk.String(base.PickResourceID(idname)) - _, err := base.BizClient.ReleaseBackend(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "backend node[%s] deleted\n", idname) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB which the backend nodes belong to") - flags.StringSliceVar(&backendIDs, "backend-id", nil, "Required. BackendID of backend nodes to update") - bindRegion(req, flags) - bindProjectID(req, flags) - - cmd.MarkFlagRequired("ulb-id") - cmd.MarkFlagRequired("backend-id") - - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("backend-id", func() []string { - return getAllULBVServerNodeIDNames(*req.ULBId, "", *req.ProjectId, *req.Region) - }) - return cmd -} - -//NewCmdULBVServerPolicy ucloud ulb vserver policy -func NewCmdULBVServerPolicy() *cobra.Command { - out := base.Cxt.GetWriter() - cmd := &cobra.Command{ - Use: "policy", - Short: "List and manipulate forward policy for VServer", - Long: "List and manipulate forward policy for VServer", - } - cmd.AddCommand(NewCmdULBVServerCreatePolicy(out)) - cmd.AddCommand(NewCmdULBVServerListPolicy(out)) - cmd.AddCommand(NewCmdULBVServerUpdatePolicy(out)) - cmd.AddCommand(NewCmdULBVServerDeletePolicy(out)) - return cmd -} - -//NewCmdULBVServerCreatePolicy ucloud ulb-vserver create-policy -func NewCmdULBVServerCreatePolicy(out io.Writer) *cobra.Command { - backendIDs := []string{} - req := base.BizClient.NewCreatePolicyRequest() - cmd := &cobra.Command{ - Use: "add", - Short: "Add content forward policy for VServer", - Long: "Add content forward policy for VServer", - Run: func(c *cobra.Command, args []string) { - if *req.Type != "Domain" && *req.Type != "Path" { - fmt.Fprintln(out, "Error, forward method must be Domain or Path") - return - } - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - req.VServerId = sdk.String(base.PickResourceID(*req.VServerId)) - for _, idname := range backendIDs { - req.BackendId = append(req.BackendId, base.PickResourceID(idname)) - } - resp, err := base.BizClient.CreatePolicy(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "policy[%s] created\n", resp.PolicyId) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB") - req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer") - flags.StringSliceVar(&backendIDs, "backend-id", nil, "Required. BackendID of the VServer's backend nodes") - req.Type = flags.String("forward-method", "", "Required. Forward method, accept values:Domain and Path; Both forwarding methods can be described by using regular expressions or wildcards") - req.Match = flags.String("expression", "", "Required. Expression of domain or path, such as \"www.[123].demo.com\" or \"/path/img/*.jpg\"") - bindRegion(req, flags) - bindProjectID(req, flags) - - flags.SetFlagValues("forward-method", "Domain", "Path") - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("vserver-id", func() []string { - return getAllULBVServerIDNames(*req.ULBId, *req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("backend-id", func() []string { - return getAllULBVServerNodeIDNames(*req.ULBId, *req.VServerId, *req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("ulb-id") - cmd.MarkFlagRequired("vserver-id") - cmd.MarkFlagRequired("backend-id") - cmd.MarkFlagRequired("forward-method") - cmd.MarkFlagRequired("expression") - - return cmd -} - -//ULBVServerPolicy 表格行 -type ULBVServerPolicy struct { - ForwardMethod string - Expression string - PolicyID string - PolicyType string - Backends string -} - -//NewCmdULBVServerListPolicy ucloud ulb-vserver list-policy -func NewCmdULBVServerListPolicy(out io.Writer) *cobra.Command { - var ulbID, vserverID *string - region := base.ConfigIns.Region - project := base.ConfigIns.ProjectID - cmd := &cobra.Command{ - Use: "list", - Short: "List content forward policies of the VServer instance", - Long: "List content forward policies of the VServer instance", - Run: func(c *cobra.Command, args []string) { - ulbID = sdk.String(base.PickResourceID(*ulbID)) - vserverID = sdk.String(base.PickResourceID(*vserverID)) - vsList, err := getAllULBVServer(*ulbID, *vserverID, project, region) - if err != nil { - base.HandleError(err) - return - } - if len(vsList) == 1 { - vs := vsList[0] - list := []ULBVServerPolicy{} - for _, p := range vs.PolicySet { - row := ULBVServerPolicy{} - row.ForwardMethod = p.Type - row.Expression = p.Match - row.PolicyID = p.PolicyId - row.PolicyType = p.PolicyType - nodes := []string{} - for _, b := range p.BackendSet { - nodes = append(nodes, fmt.Sprintf("%s|%s:%d|%s", b.BackendId, b.PrivateIP, b.Port, b.ResourceName)) - } - row.Backends = strings.Join(nodes, ",") - list = append(list, row) - } - base.PrintList(list, out) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - bindRegionS(®ion, flags) - bindProjectIDS(&project, flags) - - ulbID = flags.String("ulb-id", "", "Required. Resource ID of ULB") - vserverID = flags.String("vserver-id", "", "Required. Resource ID of VServer") - - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(project, region) - }) - flags.SetFlagValuesFunc("vserver-id", func() []string { - ulb := base.PickResourceID(*ulbID) - return getAllULBVServerIDNames(ulb, project, region) - }) - cmd.MarkFlagRequired("ulb-id") - cmd.MarkFlagRequired("vserver-id") - return cmd -} - -//NewCmdULBVServerUpdatePolicy ucloud ulb-vserver update-policy -func NewCmdULBVServerUpdatePolicy(out io.Writer) *cobra.Command { - policyIDs := []string{} - backendIDs := []string{} - addBackendIDs := []string{} - removeBackendIDs := []string{} - req := base.BizClient.NewUpdatePolicyRequest() - cmd := &cobra.Command{ - Use: "update", - Short: "Update content forward policies of ULB VServer", - Long: "Update content forward policies ULB VServer", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - req.VServerId = sdk.String(base.PickResourceID(*req.VServerId)) - - vsList, err := getAllULBVServer(*req.ULBId, *req.VServerId, *req.ProjectId, *req.Region) - if err != nil { - base.HandleError(err) - return - } - vs := vsList[0] - - for _, policyID := range policyIDs { - var policy *ulb.ULBPolicySet - for _, p := range vs.PolicySet { - if p.PolicyId == policyID { - policy = &p - break - } - } - if policy == nil { - fmt.Fprintf(out, "policy[%s] not found\n", *req.PolicyId) - continue - } - req.PolicyId = sdk.String(policyID) - if *req.Type == "" { - req.Type = sdk.String(policy.Type) - } else if *req.Type != "Domain" && *req.Type != "Path" { - fmt.Fprintf(out, "Error, forward-method must be Domain or Path") - continue - } - if *req.Match == "" { - req.Match = sdk.String(policy.Match) - } - backendIDMap := map[string]bool{} - if backendIDs == nil { - for _, b := range policy.BackendSet { - backendIDMap[b.BackendId] = true - } - } else { - for _, bid := range backendIDs { - backendIDMap[base.PickResourceID(bid)] = true - } - } - for _, bid := range addBackendIDs { - backendIDMap[base.PickResourceID(bid)] = true - } - for _, bid := range removeBackendIDs { - backendIDMap[base.PickResourceID(bid)] = false - } - for bid, ok := range backendIDMap { - if ok { - req.BackendId = append(req.BackendId, bid) - } - } - resp, err := base.BizClient.UpdatePolicy(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "policy[%s] updated\n", resp.PolicyId) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB") - req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer") - flags.StringSliceVar(&policyIDs, "policy-id", nil, "Required. PolicyID of policies to update") - flags.StringSliceVar(&backendIDs, "backend-id", nil, "Optional. BackendID of backend nodes. If assign this flag, it will rewrite all backend nodes of the policy") - flags.StringSliceVar(&addBackendIDs, "add-backend-id", nil, "Optional. BackendID of backend nodes. Add backend nodes to the policy") - flags.StringSliceVar(&removeBackendIDs, "remove-backend-id", nil, "Optional. BackendID of backend nodes. Remove those backend nodes from the policy") - req.Type = flags.String("forward-method", "", "Optional. Forward method of policy, accept values:Domain and Path") - req.Match = flags.String("expression", "", "Optional. Expression of domain or path, such as \"www.[123].demo.com\" or \"/path/img/*.jpg\"") - - cmd.MarkFlagRequired("ulb-id") - cmd.MarkFlagRequired("vserver-id") - cmd.MarkFlagRequired("policy-id") - - flags.SetFlagValues("forward-method", "Domain", "Path") - flags.SetFlagValuesFunc("ulb-id", func() []string { - project := base.PickResourceID(*req.ProjectId) - return getAllULBIDNames(project, *req.Region) - }) - flags.SetFlagValuesFunc("vserver-id", func() []string { - return getAllULBVServerIDNames(*req.ULBId, *req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("backend-id", func() []string { - return getAllULBVServerNodeIDNames(*req.ULBId, *req.VServerId, *req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("add-backend-id", func() []string { - return getAllULBVServerNodeIDNames(*req.ULBId, *req.VServerId, *req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("remove-backend-id", func() []string { - return getAllULBVServerNodeIDNames(*req.ULBId, *req.VServerId, *req.ProjectId, *req.Region) - }) - - return cmd -} - -//NewCmdULBVServerDeletePolicy ucloud ulb-vserver delete-policy -func NewCmdULBVServerDeletePolicy(out io.Writer) *cobra.Command { - policyIDs := []string{} - req := base.BizClient.NewDeletePolicyRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete content forward policies of ULB VServer", - Long: "Delete content forward policies of ULB VServer", - Run: func(c *cobra.Command, args []string) { - for _, p := range policyIDs { - req.PolicyId = sdk.String(p) - _, err := base.BizClient.DeletePolicy(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "policy[%s] deleted\n", p) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - - flags.StringSliceVar(&policyIDs, "policy-id", nil, "Required. PolicyID of policies to delete") - req.VServerId = flags.String("vserver-id", "", "Optional. Resource ID of VServer") - - cmd.MarkFlagRequired("policy-id") - - return cmd -} - -//NewCmdULBSSL ucloud ulb-ssl-certificate -func NewCmdULBSSL() *cobra.Command { - cmd := &cobra.Command{ - Use: "ssl", - Short: "List and manipulate SSL Certificates for ULB", - Long: "List and manipulate SSL Certificates for ULB", - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdSSLList(out)) - cmd.AddCommand(NewCmdSSLDescribe(out)) - cmd.AddCommand(NewCmdSSLAdd(out)) - cmd.AddCommand(NewCmdSSLDelete(out)) - cmd.AddCommand(NewCmdSSLBind(out)) - cmd.AddCommand(NewCmdSSLUnbind(out)) - return cmd -} - -//SSLCertificate 表格行 -type SSLCertificate struct { - Name string - ResourceID string - MD5 string - BindResource string - UploadTime string -} - -//NewCmdSSLList ucloud ulb-ssl-certificate list -func NewCmdSSLList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeSSLRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List SSL Certificates", - Long: "List SSL Certificates", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - resp, err := base.BizClient.DescribeSSL(req) - if err != nil { - base.HandleError(err) - return - } - rows := []SSLCertificate{} - for _, ssl := range resp.DataSet { - row := SSLCertificate{} - row.Name = ssl.SSLName - row.ResourceID = ssl.SSLId - row.MD5 = ssl.HashValue - row.UploadTime = base.FormatDateTime(ssl.CreateTime) - targets := []string{} - for _, t := range ssl.BindedTargetSet { - item := fmt.Sprintf("%s/%s(%s/%s)", t.VServerId, t.VServerName, t.ULBId, t.ULBName) - targets = append(targets, item) - } - row.BindResource = strings.Join(targets, ",") - rows = append(rows, row) - } - base.PrintList(rows, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - req.SSLId = flags.String("ssl-id", "", "Optional. ResouceID of ssl certificate to list") - bindLimit(req, flags) - bindOffset(req, flags) - - return cmd -} - -//NewCmdSSLDescribe ucloud ulb-ssl-certificate describe -func NewCmdSSLDescribe(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeSSLRequest() - cmd := &cobra.Command{ - Use: "describe", - Short: "Display all data associated with SSL Certificate", - Long: "Display all data associated with SSL Certificate", - Run: func(c *cobra.Command, args []string) { - req.SSLId = sdk.String(base.PickResourceID(*req.SSLId)) - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - resp, err := base.BizClient.DescribeSSL(req) - if err != nil { - base.HandleError(err) - return - } - if len(resp.DataSet) <= 0 { - fmt.Fprintf(out, "ssl certificate[%s] is not exists\n", *req.SSLId) - return - } - - sslcf := resp.DataSet[0] - targets := []string{} - for _, t := range sslcf.BindedTargetSet { - item := fmt.Sprintf("%s/%s-%s/%s", t.ULBId, t.ULBName, t.VServerId, t.VServerName) - targets = append(targets, item) - } - rows := []base.DescribeTableRow{ - base.DescribeTableRow{ - Attribute: "ResourceID", - Content: sslcf.SSLId, - }, - base.DescribeTableRow{ - Attribute: "Name", - Content: sslcf.SSLName, - }, - base.DescribeTableRow{ - Attribute: "Type", - Content: sslcf.SSLType, - }, - base.DescribeTableRow{ - Attribute: "UploadTime", - Content: base.FormatDateTime(sslcf.CreateTime), - }, - base.DescribeTableRow{ - Attribute: "BindResource", - Content: strings.Join(targets, ","), - }, - base.DescribeTableRow{ - Attribute: "MD5", - Content: sslcf.HashValue, - }, - base.DescribeTableRow{ - Attribute: "Content", - Content: sslcf.SSLContent, - }, - } - base.PrintDescribe(rows, global.JSON) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.SSLId = flags.String("ssl-id", "", "Required. ResouceID of ssl certificate to describe") - bindRegion(req, flags) - bindProjectID(req, flags) - flags.SetFlagValuesFunc("ssl-id", func() []string { - return getAllSSLCertIDNames(*req.ProjectId, *req.Region) - }) - cmd.MarkFlagRequired("ssl-id") - return cmd -} - -//NewCmdSSLAdd ucloud ulb-ssl-certificate add -func NewCmdSSLAdd(out io.Writer) *cobra.Command { - var allPath, sitePath, keyPath, caPath *string - req := base.BizClient.NewCreateSSLRequest() - cmd := &cobra.Command{ - Use: "add", - Short: "Add SSL Certificate", - Long: "Add SSL Certificate", - Run: func(c *cobra.Command, args []string) { - if *allPath == "" && (*sitePath == "" || *keyPath == "") { - fmt.Fprintln(out, "if all-in-one-file is omitted, site-certificate-file and private-key-file can't be empty") - return - } - if *allPath != "" { - content, err := readFile(*allPath) - if err != nil { - base.HandleError(err) - return - } - req.SSLContent = &content - } - if *sitePath != "" { - content, err := readFile(*sitePath) - if err != nil { - base.HandleError(err) - return - } - req.UserCert = &content - } - if *keyPath != "" { - content, err := readFile(*keyPath) - if err != nil { - base.HandleError(err) - return - } - req.PrivateKey = &content - } - if *caPath != "" { - content, err := readFile(*caPath) - if err != nil { - base.HandleError(err) - return - } - req.CaCert = &content - } - - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - resp, err := base.BizClient.CreateSSL(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "ssl certificate[%s] added\n", resp.SSLId) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - req.SSLName = flags.String("name", "", "Required. Name of ssl certificate to add") - req.SSLType = flags.String("format", "Pem", "Optional. Format of ssl certificate") - allPath = flags.String("all-in-one-file", "", "Optional. Path of file which contain the complete content of the SSL certificate, including the content of site certificate, the private key which encrypted the site certificate, and the CA certificate. ") - sitePath = flags.String("site-certificate-file", "", "Optional. Path of user's certificate file, *.crt. Required if all-in-one-file is omitted") - keyPath = flags.String("private-key-file", "", "Optional. Path of private key file, *.key. Required if all-in-one-file is omitted") - caPath = flags.String("ca-certificate-file", "", "Optional. Path of CA certificate file, *.crt") - cmd.MarkFlagRequired("name") - flags.SetFlagValuesFunc("all-in-one-file", func() []string { - return base.GetFileList("") - }) - flags.SetFlagValuesFunc("private-key-file", func() []string { - return base.GetFileList(".key") - }) - flags.SetFlagValuesFunc("ca-certificate-file", func() []string { - return base.GetFileList(".crt") - }) - flags.SetFlagValuesFunc("site-certificate-file", func() []string { - return base.GetFileList(".crt") - }) - return cmd -} - -//NewCmdSSLDelete ucloud ulb-ssl-certificate delete -func NewCmdSSLDelete(out io.Writer) *cobra.Command { - var idNames []string - req := base.BizClient.NewDeleteSSLRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete SSL Certificates by resource id(ssl id)", - Long: "Delete SSL Certificates by resource id(ssl id)", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - for _, idname := range idNames { - req.SSLId = sdk.String(base.PickResourceID(idname)) - _, err := base.BizClient.DeleteSSL(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "ssl certificate[%s] deleted\n", idname) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - flags.StringSliceVar(&idNames, "ssl-id", nil, "Required. Resource ID of SSL Certificates to delete") - flags.SetFlagValuesFunc("ssl-id", func() []string { - return getAllSSLCertIDNames(*req.ProjectId, *req.Region) - }) - return cmd -} - -//NewCmdSSLBind ucloud ulb-ssl-certificate bind -func NewCmdSSLBind(out io.Writer) *cobra.Command { - req := base.BizClient.NewBindSSLRequest() - cmd := &cobra.Command{ - Use: "bind", - Short: "Bind SSL Certificate with VServer", - Long: "Bind SSL Certificate with VServer", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - req.VServerId = sdk.String(base.PickResourceID(*req.VServerId)) - req.SSLId = sdk.String(base.PickResourceID(*req.SSLId)) - _, err := base.BizClient.BindSSL(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "ssl certificate[%s] bind with vserver[%s] of ulb[%s]\n", *req.SSLId, *req.VServerId, *req.ULBId) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - req.SSLId = flags.String("ssl-id", "", "Required. Resource ID of SSL Certificate to bind") - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB") - req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer") - flags.SetFlagValuesFunc("ssl-id", func() []string { - return getAllSSLCertIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("ulb-id", func() []string { - return getAllULBIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("vserver-id", func() []string { - return getAllULBVServerIDNames(*req.ULBId, *req.ProjectId, *req.Region) - }) - cmd.MarkFlagRequired("ssl-id") - cmd.MarkFlagRequired("ulb-id") - cmd.MarkFlagRequired("vserver-id") - return cmd -} - -//NewCmdSSLUnbind ucloud ulb-ssl-certificate unbind -func NewCmdSSLUnbind(out io.Writer) *cobra.Command { - req := base.BizClient.NewUnbindSSLRequest() - cmd := &cobra.Command{ - Use: "unbind", - Short: "Unbind SSL Certificate with VServer", - Long: "Unbind SSL Certificate with VServer", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.ULBId = sdk.String(base.PickResourceID(*req.ULBId)) - req.VServerId = sdk.String(base.PickResourceID(*req.VServerId)) - req.SSLId = sdk.String(base.PickResourceID(*req.SSLId)) - _, err := base.BizClient.UnbindSSL(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "ssl certificate[%s] unbind with vserver[%s] of ulb[%s]\n", *req.SSLId, *req.VServerId, *req.ULBId) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - bindRegion(req, flags) - bindProjectID(req, flags) - req.SSLId = flags.String("ssl-id", "", "Required. Resource ID of SSL Certificate to unbind") - req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB") - req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer") - flags.SetFlagValuesFunc("ssl-id", func() []string { - return getAllSSLCertIDNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("ulb-id", func() []string { - if *req.SSLId == "" { - return getAllULBIDNames(*req.ProjectId, *req.Region) - } - cert, err := getSSLCertByID(*req.SSLId, *req.ProjectId, *req.Region) - if err != nil { - return nil - } - ulbs := []string{} - for _, b := range cert.BindedTargetSet { - ulbs = append(ulbs, fmt.Sprintf("%s/%s", b.ULBId, b.ULBName)) - } - return ulbs - }) - flags.SetFlagValuesFunc("vserver-id", func() []string { - if *req.SSLId == "" { - return getAllULBVServerIDNames(*req.ULBId, *req.ProjectId, *req.Region) - } - cert, err := getSSLCertByID(*req.SSLId, *req.ProjectId, *req.Region) - if err != nil { - return nil - } - vservers := []string{} - for _, b := range cert.BindedTargetSet { - vservers = append(vservers, fmt.Sprintf("%s/%s", b.VServerId, b.VServerName)) - } - return vservers - }) - cmd.MarkFlagRequired("ssl-id") - cmd.MarkFlagRequired("ulb-id") - cmd.MarkFlagRequired("vserver-id") - return cmd -} - -func readFile(file string) (string, error) { - byts, err := ioutil.ReadFile(file) - if err != nil { - return "", err - } - return string(byts), nil -} - -func getAllULBVServerNodes(ulbID, vserverID, project, region string) ([]ulb.ULBBackendSet, error) { - vsList, err := getAllULBVServer(ulbID, vserverID, project, region) - if err != nil { - return nil, err - } - nodeList := []ulb.ULBBackendSet{} - for _, vs := range vsList { - nodeList = append(nodeList, vs.BackendSet...) - } - return nodeList, nil -} - -func getAllULBVServerNodeIDNames(ulbID, vserverID, project, region string) []string { - nodeList, err := getAllULBVServerNodes(ulbID, vserverID, project, region) - if err != nil { - return nil - } - idNames := []string{} - for _, node := range nodeList { - idNames = append(idNames, fmt.Sprintf("%s/%s", node.BackendId, node.ResourceName)) - } - return idNames -} - -func getAllSSLCertIDNames(project, region string) []string { - sslcs, err := getAllSSLCerts(project, region) - if err != nil { - return nil - } - idNames := []string{} - for _, ssl := range sslcs { - idNames = append(idNames, fmt.Sprintf("%s/%s", ssl.SSLId, ssl.SSLName)) - } - return idNames -} - -func getAllSSLCerts(project, region string) ([]ulb.ULBSSLSet, error) { - req := base.BizClient.NewDescribeSSLRequest() - req.ProjectId = sdk.String(base.PickResourceID(project)) - req.Region = sdk.String(region) - list := []ulb.ULBSSLSet{} - for offset, limit := 0, 50; ; offset += limit { - req.Offset = sdk.Int(offset) - req.Limit = sdk.Int(limit) - resp, err := base.BizClient.DescribeSSL(req) - if err != nil { - return nil, err - } - list = append(list, resp.DataSet...) - if resp.TotalCount <= offset+limit { - break - } - } - return list, nil -} - -func getSSLCertByID(sslID, project, region string) (*ulb.ULBSSLSet, error) { - if sslID == "" { - return nil, fmt.Errorf("ssl certificate resource id can't be empty") - } - req := base.BizClient.NewDescribeSSLRequest() - req.ProjectId = sdk.String(base.PickResourceID(project)) - req.Region = sdk.String(region) - req.SSLId = sdk.String(base.PickResourceID(sslID)) - resp, err := base.BizClient.DescribeSSL(req) - if err != nil { - return nil, err - } - if len(resp.DataSet) <= 0 { - return nil, fmt.Errorf("ssl certificate[%s] is not exists", sslID) - } - return &resp.DataSet[0], nil -} - -func getAllULBVServer(ulbID, vserverID, project, region string) ([]ulb.ULBVServerSet, error) { - req := base.BizClient.NewDescribeVServerRequest() - req.ULBId = sdk.String(base.PickResourceID(ulbID)) - req.ProjectId = sdk.String(base.PickResourceID(project)) - req.Region = ®ion - if vserverID != "" { - req.VServerId = sdk.String(base.PickResourceID(vserverID)) - } - resp, err := base.BizClient.DescribeVServer(req) - if err != nil { - return nil, err - } - if vserverID != "" { - if len(resp.DataSet) < 1 { - return nil, fmt.Errorf("VServer[%s] may not exist", vserverID) - } else if len(resp.DataSet) > 1 { - return nil, fmt.Errorf("Internal Error, too many vserver:%#v", resp.DataSet) - } - } - return resp.DataSet, nil -} - -func getAllULBVServerIDNames(ulbID, project, region string) []string { - vservers, err := getAllULBVServer(ulbID, "", project, region) - if err != nil { - return nil - } - idNames := []string{} - for _, vs := range vservers { - idNames = append(idNames, fmt.Sprintf("%s/%s", vs.VServerId, vs.VServerName)) - } - return idNames -} diff --git a/cmd/umem.go b/cmd/umem.go deleted file mode 100644 index dcf35f89fa..0000000000 --- a/cmd/umem.go +++ /dev/null @@ -1,631 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - "strings" - - "github.com/spf13/cobra" - - pumem "github.com/ucloud/ucloud-sdk-go/private/services/umem" - "github.com/ucloud/ucloud-sdk-go/services/umem" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - "github.com/ucloud/ucloud-sdk-go/ucloud/request" - - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/model/status" - "github.com/ucloud/ucloud-cli/ux" -) - -//NewCmdRedis ucloud redis -func NewCmdRedis() *cobra.Command { - cmd := &cobra.Command{ - Use: "redis", - Short: "List and manipulate redis instances", - Long: "List and manipulate redis instances", - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdRedisList(out)) - cmd.AddCommand(NewCmdRedisCreate(out)) - cmd.AddCommand(NewCmdRedisDelete(out)) - cmd.AddCommand(NewCmdRedisRestart(out)) - return cmd -} - -//NewCmdMemcache ucloud memcache -func NewCmdMemcache() *cobra.Command { - cmd := &cobra.Command{ - Use: "memcache", - Short: "List and manipulate memcache instances", - Long: "List and manipulate memcache instances", - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdMemcacheList(out)) - cmd.AddCommand(NewCmdMemcacheCreate(out)) - cmd.AddCommand(NewCmdMemcacheDelete(out)) - cmd.AddCommand(NewCmdMemcacheRestart(out)) - return cmd -} - -//UMemRedisRow 表格行 -type UMemRedisRow struct { - ResourceID string - Name string - Role string - Type string - Address string - Size string - UsedSize string - State string - Group string - Zone string - CreateTime string -} - -var redisTypeMap = map[string]string{ - "single": "master-replica", - "distributed": "distributed", -} - -//NewCmdRedisList ucloud redis list -func NewCmdRedisList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeUMemRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List redis instances", - Long: "List redis instances", - Run: func(c *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeUMem(req) - if err != nil { - base.HandleError(err) - return - } - list := []UMemRedisRow{} - for _, ins := range resp.DataSet { - row := UMemRedisRow{ - ResourceID: ins.ResourceId, - Name: ins.Name, - Role: ins.Role, - Type: redisTypeMap[ins.ResourceType], - Group: ins.Tag, - Size: fmt.Sprintf("%dGB", ins.Size), - UsedSize: fmt.Sprintf("%dMB", ins.UsedSize), - State: ins.State, - Zone: ins.Zone, - CreateTime: base.FormatDate(ins.CreateTime), - } - addrs := []string{} - for _, addr := range ins.Address { - addrs = append(addrs, fmt.Sprintf("%s:%d", addr.IP, addr.Port)) - } - row.Address = strings.Join(addrs, "|") - list = append(list, row) - for _, slave := range ins.DataSet { - srow := UMemRedisRow{ - ResourceID: slave.GroupId, - Name: slave.Name, - Role: fmt.Sprintf("\u2b91 %s", slave.Role), - Type: redisTypeMap[slave.ResourceType], - Group: slave.Tag, - Size: fmt.Sprintf("%dGB", slave.Size), - UsedSize: fmt.Sprintf("%dMB", slave.UsedSize), - State: slave.State, - Zone: slave.Zone, - Address: fmt.Sprintf("%s:%d", slave.VirtualIP, slave.Port), - CreateTime: base.FormatDate(slave.CreateTime), - } - list = append(list, srow) - } - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.ResourceId = flags.String("umem-id", "", "Optional. Resource ID of the redis to list") - bindRegion(req, flags) - bindZoneEmpty(req, flags) - bindProjectID(req, flags) - bindOffset(req, flags) - bindLimit(req, flags) - req.Protocol = sdk.String("redis") - - flags.SetFlagValuesFunc("umem-id", func() []string { - return getRedisIDList(*req.ProjectId, *req.Region) - }) - - return cmd -} - -//NewCmdRedisCreate ucloud redis create -func NewCmdRedisCreate(out io.Writer) *cobra.Command { - req := base.BizClient.NewCreateURedisGroupRequest() - req.HighAvailability = sdk.String("enable") - var redisType, password string - cmd := &cobra.Command{ - Use: "create", - Short: "Create redis instance", - Long: "Create redis instance", - Run: func(c *cobra.Command, args []string) { - if l := len(*req.Name); l < 6 || l > 63 { - fmt.Fprintln(out, "length of name should be between 6 and 63") - return - } - if redisType == "master-replica" { - resp, err := base.BizClient.CreateURedisGroup(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Printf("redis[%s] created\n", resp.GroupId) - } else if redisType == "distributed" { - dreq := base.BizClient.NewCreateUMemSpaceRequest() - dreq.Region = req.Region - dreq.Zone = req.Zone - dreq.ProjectId = req.ProjectId - dreq.Name = req.Name - dreq.Size = req.Size - if *req.Size == 1 { - dreq.Size = sdk.Int(16) - } - dreq.ChargeType = req.ChargeType - dreq.Quantity = req.Quantity - dreq.Tag = req.Tag - dreq.Password = req.Password - resp, err := base.BizClient.CreateUMemSpace(dreq) - if err != nil { - base.HandleError(err) - return - } - fmt.Printf("redis[%s] created\n", resp.SpaceId) - } else { - fmt.Printf("unknow redis type[%s], it's should be 'master-replica' or 'distributed'\n", redisType) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.Name = flags.String("name", "", "Required. Name of the redis to create. Range of the password length is [6,63] and the password can only contain letters and numbers") - flags.StringVar(&redisType, "type", "", "Required. Type of the redis. Accept values:'master-replica','distributed'") - req.Size = flags.Int("size-gb", 1, "Optional. Memory size. Default value 1GB(for master-replica redis type) or 16GB(for distributed redis type). Unit GB") - req.Version = flags.String("version", "3.2", "Optional. Version of redis") - req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID. This field is required under VPC2.0. See 'ucloud vpc list'") - req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID. This field is required under VPC2.0. See 'ucloud subnet list'") - flags.StringVar(&password, "password", "", "Optional. Password of redis to create") - - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - bindGroup(req, flags) - bindChargeType(req, flags) - bindQuantity(req, flags) - - flags.SetFlagValues("version", "3.0", "3.2", "4.0") - flags.SetFlagValues("type", "master-replica", "distributed") - flags.SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("subnet-id", func() []string { - return getAllSubnetIDNames(*req.VPCId, *req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("type") - - return cmd -} - -//NewCmdRedisDelete ucloud redis delete -func NewCmdRedisDelete(out io.Writer) *cobra.Command { - var idNames []string - req := base.BizClient.NewDeleteURedisGroupRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete redis instances", - Long: "Delete redis instances", - Example: "ucloud redis delete --umem-id uredis-rl5xuxx/testcli1,uredis-xsdfa/testcli2", - Run: func(c *cobra.Command, args []string) { - for _, idname := range idNames { - id := base.PickResourceID(idname) - if strings.HasPrefix(id, "uredis") { - req.GroupId = &id - _, err := base.BizClient.DeleteURedisGroup(req) - if err != nil { - base.HandleError(err) - continue - } - } else if strings.HasPrefix(id, "umem") { - _req := base.BizClient.NewDeleteUMemSpaceRequest() - _req.Region = req.Region - _req.Zone = req.Zone - _req.ProjectId = req.ProjectId - _req.SpaceId = &id - _, err := base.BizClient.DeleteUMemSpace(_req) - if err != nil { - base.HandleError(err) - continue - } - } - fmt.Fprintf(out, "redis[%s] deleted\n", idname) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis intances to delete") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - - cmd.MarkFlagRequired("umem-id") - - flags.SetFlagValuesFunc("umem-id", func() []string { - return getRedisIDList(*req.ProjectId, *req.Region) - }) - - return cmd -} - -//NewCmdRedisRestart ucloud redis restart -func NewCmdRedisRestart(out io.Writer) *cobra.Command { - idNames := make([]string, 0) - req := base.BizClient.NewRestartURedisGroupRequest() - cmd := &cobra.Command{ - Use: "restart", - Short: "Restart redis instances of master-replica type", - Long: "Restart redis instances of master-replica type", - Run: func(c *cobra.Command, args []string) { - reqs := make([]request.Common, len(idNames)) - for idx, idname := range idNames { - id := base.PickResourceID(idname) - _req := *req - _req.GroupId = &id - reqs[idx] = &_req - } - coAction := newConcurrentAction(reqs, 10, restartRedis) - coAction.Do() - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to restart") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - - cmd.MarkFlagRequired("umem-id") - flags.SetFlagValuesFunc("umem-id", func() []string { - return getRedisIDList(*req.ProjectId, *req.Region) - }) - - return cmd -} - -func restartRedis(creq request.Common) (bool, []string) { - req := creq.(*pumem.RestartURedisGroupRequest) - block := ux.NewBlock() - ux.Doc.Append(block) - logs := make([]string, 0) - logs = append(logs, fmt.Sprintf("api:RestartURedisGroup, request:%v", base.ToQueryMap(req))) - _, err := base.BizClient.RestartURedisGroup(req) - if err != nil { - block.Append(base.ParseError(err)) - logs = append(logs, fmt.Sprintf("restart redis[%s] failed: %s", *req.GroupId, base.ParseError(err))) - return false, logs - } - poller := base.NewSpoller(describeRedisByID, base.Cxt.GetWriter()) - text := fmt.Sprintf("redis[%s] is restarting", *req.GroupId) - ret := poller.Sspoll(*req.GroupId, text, []string{status.UMEM_RUNNING, status.UMEM_FAIL}, block) - if ret.Err != nil { - block.Append(base.ParseError(err)) - logs = append(logs, ret.Err.Error()) - } - if ret.Timeout { - logs = append(logs, "poll redis[%s] timeout", *req.GroupId) - } - return ret.Done, logs -} - -func getRedisIDList(project, region string) []string { - req := base.BizClient.NewDescribeURedisGroupRequest() - req.ProjectId = &project - req.Region = ®ion - list := []string{} - - for limit, offset := 50, 0; ; offset += limit { - req.Limit = sdk.Int(limit) - req.Offset = sdk.Int(offset) - resp, err := base.BizClient.DescribeURedisGroup(req) - if err != nil { - return nil - } - for _, ins := range resp.DataSet { - list = append(list, fmt.Sprintf("%s/%s", ins.GroupId, ins.Name)) - } - if offset+limit >= resp.TotalCount { - break - } - } - return list -} - -//UMemMemcacheRow 表格行 -type UMemMemcacheRow struct { - ResourceID string - Name string - Address string - Size string - UsedSize string - State string - Group string - CreateTime string -} - -//NewCmdMemcacheList ucloud memcache list -func NewCmdMemcacheList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeUMemcacheGroupRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List memcache instances", - Long: "List memcache instances", - Run: func(c *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeUMemcacheGroup(req) - if err != nil { - base.HandleError(err) - return - } - list := []UMemMemcacheRow{} - for _, ins := range resp.DataSet { - row := UMemMemcacheRow{ - ResourceID: ins.GroupId, - Name: ins.Name, - Group: ins.Tag, - Size: fmt.Sprintf("%dGB", ins.Size), - UsedSize: fmt.Sprintf("%dMB", ins.UsedSize), - State: ins.State, - CreateTime: base.FormatDate(ins.CreateTime), - Address: fmt.Sprintf("%s:%d", ins.VirtualIP, ins.Port), - } - list = append(list, row) - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.GroupId = flags.String("umem-id", "", "Optional. Resource ID of the redis to list") - bindRegion(req, flags) - bindZoneEmpty(req, flags) - bindProjectID(req, flags) - bindOffset(req, flags) - bindLimit(req, flags) - - return cmd -} - -//NewCmdMemcacheCreate ucloud memcache create -func NewCmdMemcacheCreate(out io.Writer) *cobra.Command { - req := base.BizClient.NewCreateUMemcacheGroupRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create memcache instance", - Long: "Create memcache instance", - Run: func(c *cobra.Command, args []string) { - if *req.Size > 32 || *req.Size < 1 { - fmt.Fprintln(out, "size-gb should be between 1 and 32") - return - } - resp, err := base.BizClient.CreateUMemcacheGroup(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "memcache[%s] created\n", resp.GroupId) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.Name = flags.String("name", "", "Required. Name of memcache instance to create") - req.Size = flags.Int("size-gb", 1, "Optional. Memory size of memcache instance. Unit GB. Accpet values:1,2,4,8,16,32") - req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID. See 'ucloud vpc list'") - req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID. See 'ucloud subnet list'") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZone(req, flags) - bindChargeType(req, flags) - bindQuantity(req, flags) - bindGroup(req, flags) - - flags.SetFlagValues("size-gb", "1", "2", "4", "8", "16", "32") - flags.SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - flags.SetFlagValuesFunc("subnet-id", func() []string { - return getAllSubnetIDNames(*req.VPCId, *req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("name") - - return cmd -} - -//NewCmdMemcacheDelete ucloud memcache delete -func NewCmdMemcacheDelete(out io.Writer) *cobra.Command { - var idNames []string - req := base.BizClient.NewDeleteUMemcacheGroupRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete memcache instances", - Long: "Delete memcache instances", - Example: "ucloud memcache delete --umem-id umemcache-rl5xuxx/testcli1,umemcache-xsdfa/testcli2", - Run: func(c *cobra.Command, args []string) { - for _, idname := range idNames { - id := base.PickResourceID(idname) - req.GroupId = &id - _, err := base.BizClient.DeleteUMemcacheGroup(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "memcache[%s] deleted\n", idname) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of memcache intances to delete") - bindProjectID(req, flags) - bindRegion(req, flags) - bindZoneEmpty(req, flags) - - cmd.MarkFlagRequired("umem-id") - - flags.SetFlagValuesFunc("umem-id", func() []string { - return getMemcacheIDList(*req.ProjectId, *req.Region) - }) - - return cmd -} - -//NewCmdMemcacheRestart ucloud memcache restart -func NewCmdMemcacheRestart(out io.Writer) *cobra.Command { - idNames := make([]string, 0) - req := base.BizClient.NewRestartUMemcacheGroupRequest() - cmd := &cobra.Command{ - Use: "restart", - Short: "Restart memcache instances", - Long: "Restart memcache instances", - Run: func(c *cobra.Command, args []string) { - reqs := make([]request.Common, len(idNames)) - for idx, idname := range idNames { - id := base.PickResourceID(idname) - _req := *req - _req.GroupId = &id - reqs[idx] = &_req - } - coAction := newConcurrentAction(reqs, 10, restartMemcache) - coAction.Do() - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of memcache to restart") - bindRegion(req, flags) - bindZone(req, flags) - bindProjectID(req, flags) - - flags.SetFlagValuesFunc("umem-id", func() []string { - return getMemcacheIDList(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("umem-id") - return cmd -} - -func restartMemcache(creq request.Common) (bool, []string) { - req := creq.(*umem.RestartUMemcacheGroupRequest) - block := ux.NewBlock() - ux.Doc.Append(block) - logs := make([]string, 0) - logs = append(logs, fmt.Sprintf("api:RestartUMemcacheGroup, request:%v", base.ToQueryMap(req))) - _, err := base.BizClient.RestartUMemcacheGroup(req) - if err != nil { - block.Append(base.ParseError(err)) - logs = append(logs, fmt.Sprintf("restart memcache[%s] failed: %s", *req.GroupId, base.ParseError(err))) - return false, logs - } - poller := base.NewSpoller(describeMemcacheByID, base.Cxt.GetWriter()) - text := fmt.Sprintf("memcache[%s] is restarting", *req.GroupId) - ret := poller.Sspoll(*req.GroupId, text, []string{status.UMEM_RUNNING, status.UMEM_FAIL}, block) - if ret.Err != nil { - block.Append(base.ParseError(err)) - logs = append(logs, ret.Err.Error()) - } - if ret.Timeout { - logs = append(logs, "poll memcache[%s] timeout", *req.GroupId) - } - return ret.Done, logs -} - -func describeMemcacheByID(memcacheID string) (interface{}, error) { - req := base.BizClient.NewDescribeUMemRequest() - req.Protocol = sdk.String("memcache") - req.ResourceId = &memcacheID - - resp, err := base.BizClient.DescribeUMem(req) - if err != nil { - return nil, err - } - if len(resp.DataSet) < 1 { - return nil, fmt.Errorf(fmt.Sprintf("resource [%s] may not exist", memcacheID)) - } - return &resp.DataSet[0], nil -} -func describeRedisByID(redisID string) (interface{}, error) { - req := base.BizClient.NewDescribeUMemRequest() - req.Protocol = sdk.String("redis") - req.ResourceId = &redisID - - resp, err := base.BizClient.DescribeUMem(req) - if err != nil { - return nil, err - } - if len(resp.DataSet) < 1 { - return nil, fmt.Errorf(fmt.Sprintf("resource [%s] may not exist", redisID)) - } - return &resp.DataSet[0], nil -} - -func getMemcacheIDList(project, region string) []string { - req := base.BizClient.NewDescribeUMemcacheGroupRequest() - req.ProjectId = &project - req.Region = ®ion - list := []string{} - - for limit, offset := 50, 0; ; offset += limit { - req.Limit = sdk.Int(limit) - req.Offset = sdk.Int(offset) - resp, err := base.BizClient.DescribeUMemcacheGroup(req) - if err != nil { - fmt.Println(err) - return nil - } - for _, ins := range resp.DataSet { - list = append(list, fmt.Sprintf("%s/%s", ins.GroupId, ins.Name)) - } - if offset+limit >= resp.TotalCount { - break - } - } - return list -} diff --git a/cmd/unet.go b/cmd/unet.go deleted file mode 100644 index 7a338486de..0000000000 --- a/cmd/unet.go +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-sdk-go/services/udpn" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" -) - -//NewCmdUDPN ucloud udpn -func NewCmdUDPN(out io.Writer) *cobra.Command { - cmd := &cobra.Command{ - Use: "udpn", - Short: "List and manipulate udpn instances", - Long: "List and manipulate udpn instances", - } - - cmd.AddCommand(NewCmdUDPNCreate(out)) - cmd.AddCommand(NewCmdUDPNList(out)) - cmd.AddCommand(NewCmdUdpnDelete(out)) - cmd.AddCommand(NewCmdUdpnModifyBW(out)) - - return cmd -} - -//NewCmdUDPNCreate ucloud udpn create -func NewCmdUDPNCreate(out io.Writer) *cobra.Command { - req := base.BizClient.NewAllocateUDPNRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create UDPN tunnel", - Long: "Create UDPN tunnel", - Run: func(c *cobra.Command, args []string) { - if *req.Bandwidth < 2 || *req.Bandwidth > 1000 { - fmt.Fprintln(out, "Error, bandwidth must be between 2Mb and 1000Mb") - return - } - if *req.Peer1 == *req.Peer2 { - fmt.Fprintln(out, "Error, flags peer1 and peer2 can't be equal") - return - } - resp, err := base.BizClient.AllocateUDPN(req) - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "udpn[%s] created\n", resp.UDPNId) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.Peer1 = flags.String("peer1", base.ConfigIns.Region, "Required. One end of the tunnel to create") - req.Peer2 = flags.String("peer2", "", "Required. The other end of the tunnel create") - req.Bandwidth = flags.Int("bandwidth-mb", 0, "Required. Bandwidth of the tunnel to create. Unit:Mb. Rnange [2,1000]") - req.ChargeType = flags.String("charge-type", "", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") - req.Quantity = cmd.Flags().Int("quantity", 1, "Optional. The duration of the instance. N years/months.") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - - flags.SetFlagValues("charge-type", "Month", "Year", "Dynamic") - flags.SetFlagValuesFunc("project-id", getProjectList) - flags.SetFlagValuesFunc("peer1", getRegionList) - //peer1和peer2不相等 - flags.SetFlagValuesFunc("peer2", func() []string { - regions := getRegionList() - list := []string{} - for _, r := range regions { - if r != *req.Peer1 { - list = append(list, r) - } - } - return list - }) - - cmd.MarkFlagRequired("peer1") - cmd.MarkFlagRequired("peer2") - cmd.MarkFlagRequired("bandwidth-mb") - - return cmd -} - -//UDPNRow 表格行 -type UDPNRow struct { - ResourceID string - Peers string - Bandwidth string - ChargeType string - CreationTime string -} - -//NewCmdUDPNList ucloud udpn list -func NewCmdUDPNList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeUDPNRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List udpn instances", - Long: "List udpn instances", - Run: func(c *cobra.Command, args []string) { - req.UDPNId = sdk.String(base.PickResourceID(*req.UDPNId)) - resp, err := base.BizClient.DescribeUDPN(req) - if err != nil { - base.HandleError(err) - return - } - list := []UDPNRow{} - for _, udpn := range resp.DataSet { - row := UDPNRow{} - row.ResourceID = udpn.UDPNId - row.Peers = fmt.Sprintf("%s <--> %s", udpn.Peer1, udpn.Peer2) - row.Bandwidth = fmt.Sprintf("%dMb", udpn.Bandwidth) - row.ChargeType = udpn.ChargeType - row.CreationTime = base.FormatDate(udpn.CreateTime) - list = append(list, row) - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - req.UDPNId = flags.String("udpn-id", "", "Optional. Resource ID of udpn instances to list") - req.Offset = flags.Int("offset", 0, "Optional. Offset") - req.Limit = flags.Int("limit", 50, "Optional. Limit") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - - flags.SetFlagValuesFunc("region", getRegionList) - flags.SetFlagValuesFunc("project-id", getRegionList) - flags.SetFlagValuesFunc("udpn-id", func() []string { - return getAllUDPNIdNames(*req.ProjectId, *req.Region) - }) - - return cmd -} - -//NewCmdUdpnDelete ucloud udpn delete -func NewCmdUdpnDelete(out io.Writer) *cobra.Command { - idNames := []string{} - req := base.BizClient.NewReleaseUDPNRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "delete udpn instances", - Long: "delete udpn instances", - Run: func(c *cobra.Command, args []string) { - for _, idname := range idNames { - req.UDPNId = sdk.String(base.PickResourceID(idname)) - _, err := base.BizClient.ReleaseUDPN(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "udpn[%s] deleted\n", idname) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "udpn-id", nil, "Required. Resource ID of udpn instances to delete") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - - flags.SetFlagValuesFunc("project-id", getRegionList) - flags.SetFlagValuesFunc("udpn-id", func() []string { - return getAllUDPNIdNames(*req.ProjectId, base.ConfigIns.Region) - }) - - cmd.MarkFlagRequired("udpn-id") - - return cmd -} - -//NewCmdUdpnModifyBW ucloud udpn modify-bw -func NewCmdUdpnModifyBW(out io.Writer) *cobra.Command { - idNames := []string{} - req := base.BizClient.NewModifyUDPNBandwidthRequest() - cmd := &cobra.Command{ - Use: "modify-bw", - Short: "Modify bandwidth of UDPN tunnel", - Long: "Modify bandwidth of UDPN tunnel", - Run: func(c *cobra.Command, args []string) { - if *req.Bandwidth < 2 || *req.Bandwidth > 1000 { - fmt.Fprintln(out, "Error, bandwidth must be between 2Mb and 1000Mb") - return - } - for _, idname := range idNames { - req.UDPNId = sdk.String(base.PickResourceID(idname)) - _, err := base.BizClient.ModifyUDPNBandwidth(req) - if err != nil { - base.HandleError(err) - return - } - fmt.Fprintf(out, "udpn[%s]'s bandwidth modified\n", idname) - } - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "udpn-id", nil, "Required. Resource ID of UDPN to modify bandwidth") - req.Bandwidth = flags.Int("bandwidth-mb", 0, "Required. Bandwidth of UDPN tunnel. Unit:Mb. Range [2,1000]") - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - - flags.SetFlagValuesFunc("udpn-id", func() []string { - return getAllUDPNIdNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("udpn-id") - cmd.MarkFlagRequired("bandwidth-mb") - - return cmd -} - -func getAllUDPNIns(project, region string) ([]udpn.UDPNData, error) { - req := base.BizClient.NewDescribeUDPNRequest() - req.ProjectId = sdk.String(project) - req.Region = sdk.String(region) - list := make([]udpn.UDPNData, 0) - for offset, limit := 0, 50; ; offset += limit { - req.Offset = sdk.Int(offset) - req.Limit = sdk.Int(limit) - resp, err := base.BizClient.DescribeUDPN(req) - if err != nil { - return nil, err - } - for _, u := range resp.DataSet { - list = append(list, u) - } - if offset+limit > resp.TotalCount { - break - } - } - return list, nil -} - -func getAllUDPNIdNames(project, region string) []string { - udpnInsList, err := getAllUDPNIns(project, region) - if err != nil { - return nil - } - idNameList := []string{} - for _, udpn := range udpnInsList { - idNameList = append(idNameList, fmt.Sprintf("%s/%s:%s", udpn.UDPNId, udpn.Peer1, udpn.Peer2)) - } - return idNameList -} diff --git a/cmd/uphost.go b/cmd/uphost.go deleted file mode 100644 index e9b4b02a68..0000000000 --- a/cmd/uphost.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "fmt" - "io" - - "github.com/spf13/cobra" - "github.com/ucloud/ucloud-cli/base" -) - -//NewCmdUPHost ucloud uphost -func NewCmdUPHost() *cobra.Command { - cmd := &cobra.Command{ - Use: "uphost", - Short: "List UPHost instances", - Long: `List UPHost instances`, - Args: cobra.NoArgs, - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdUPHostList(out)) - - return cmd -} - -type uphostRow struct { - ResourceID string - Name string - PrivateIP string - PublicIP string - Config string - Image string - HostType string - Status string - Group string -} - -//NewCmdUPHostList ucloud uphost list -func NewCmdUPHostList(out io.Writer) *cobra.Command { - ids := []string{} - req := base.BizClient.NewDescribePHostRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List UPHost instances", - Long: "List UPHost instances", - Run: func(c *cobra.Command, args []string) { - resp, err := base.BizClient.DescribePHost(req) - if err != nil { - base.HandleError(err) - return - } - list := make([]uphostRow, 0) - for _, ins := range resp.PHostSet { - row := uphostRow{ - ResourceID: ins.PHostId, - Name: ins.Name, - Config: fmt.Sprintf("core:%d memory:%dG", ins.CPUSet.CoreCount, ins.Memory/1024), - Group: ins.Tag, - HostType: ins.PHostType, - Status: ins.PMStatus, - Image: ins.ImageName, - } - for _, ip := range ins.IPSet { - if ip.OperatorName == "Private" { - row.PrivateIP = ip.IPAddr - } else { - row.PublicIP = ip.IPAddr + " " + ip.OperatorName - } - } - for _, disk := range ins.DiskSet { - if disk.Name == "data" { - row.Config += fmt.Sprintf(" data-disk:%dG %s", disk.Space, disk.Type) - } - } - list = append(list, row) - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - bindRegion(req, flags) - bindZoneEmpty(req, flags) - bindProjectID(req, flags) - bindOffset(req, flags) - bindLimit(req, flags) - flags.StringSliceVar(&ids, "uphost-id", nil, "Optional. Resource ID of uphost instances. List those specified uphost instances") - - return cmd -} diff --git a/cmd/usage_template_test.go b/cmd/usage_template_test.go new file mode 100644 index 0000000000..dbdd6a61a6 --- /dev/null +++ b/cmd/usage_template_test.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// TestUsageTemplateRenders is a regression test for the usageTmpl helper funcs. +// +// usageTmpl (cmd/root.go) uses two template functions that the forked +// cobra/pflag provided but upstream does NOT: +// - add: the command-list separator, `{{... (add $index 1)}}` +// - flagNames: the flag-name list, which replaced the fork-only +// `.Flags.FlagNames` +// +// Both are registered via cobra.AddTemplateFunc in init(). If either +// registration is removed, rendering usageTmpl breaks: an unregistered `add` +// makes the template fail to parse and panics; an unregistered `flagNames` +// leaves a template-error string in the rendered output. This test renders the +// usage template and fails on either symptom. +func TestUsageTemplateRenders(t *testing.T) { + root := NewCmdRoot() + + // Two dummy subcommands with Run funcs so HasAvailableSubCommands is true: + // the `add`/command-list block then renders, and the flags block calls + // flagNames. + root.AddCommand(&cobra.Command{ + Use: "dummyalpha", + Run: func(c *cobra.Command, args []string) {}, + }) + root.AddCommand(&cobra.Command{ + Use: "dummybeta", + Run: func(c *cobra.Command, args []string) {}, + }) + + // Renders usageTmpl. An unregistered `add` panics here (failing the test + // naturally); an unregistered `flagNames` leaves a template-error string in + // the output, caught by the assertions below. + usage := root.UsageString() + + // The command list rendered (proves the `add`/command-list block ran). + for _, name := range []string{"dummyalpha", "dummybeta"} { + if !strings.Contains(usage, name) { + t.Errorf("usage output missing dummy command %q; command list did not render.\nusage:\n%s", name, usage) + } + } + + // No template error leaked into the output (proves flagNames/add resolved). + for _, bad := range []string{"template:", "not defined", "can't evaluate"} { + if strings.Contains(usage, bad) { + t.Errorf("usage output contains template error %q (a helper func is likely unregistered).\nusage:\n%s", bad, usage) + } + } +} diff --git a/cmd/util.go b/cmd/util.go index 3a0347bdfa..2cf64d59d4 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -3,75 +3,82 @@ package cmd import ( "fmt" "reflect" - "strings" "sync" "time" + "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/ucloud/ucloud-sdk-go/ucloud/request" - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/ux" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/pkg/ui" ) -func bindRegion(req request.Common, flags *pflag.FlagSet) { +func bindRegion(req request.Common, cmd *cobra.Command) { var region string - flags.StringVar(®ion, "region", base.ConfigIns.Region, "Optional. Override default region for this command invocation, see 'ucloud region'") - flags.SetFlagValuesFunc("region", getRegionList) + def := runtimeDefaults() + cmd.Flags().StringVar(®ion, "region", def.Region, "Optional. Override default region for this command invocation, see 'ucloud region'") + command.SetCompletion(cmd, "region", getRegionList) req.SetRegionRef(®ion) } -func bindRegionS(region *string, flags *pflag.FlagSet) { - *region = base.ConfigIns.Region - flags.StringVar(region, "region", base.ConfigIns.Region, "Optional. Override default region for this command invocation, see 'ucloud region'") - flags.SetFlagValuesFunc("region", getRegionList) +func bindRegionS(region *string, cmd *cobra.Command) { + def := runtimeDefaults() + *region = def.Region + cmd.Flags().StringVar(region, "region", def.Region, "Optional. Override default region for this command invocation, see 'ucloud region'") + command.SetCompletion(cmd, "region", getRegionList) } -func bindZone(req request.Common, flags *pflag.FlagSet) { +func bindZone(req request.Common, cmd *cobra.Command) { var zone string - flags.StringVar(&zone, "zone", base.ConfigIns.Zone, "Optional. Override default availability zone for this command invocation, see 'ucloud region'") - flags.SetFlagValuesFunc("zone", func() []string { + def := runtimeDefaults() + cmd.Flags().StringVar(&zone, "zone", def.Zone, "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + command.SetCompletion(cmd, "zone", func() []string { return getZoneList(req.GetRegion()) }) req.SetZoneRef(&zone) } -func bindZoneEmpty(req request.Common, flags *pflag.FlagSet) { +func bindZoneEmpty(req request.Common, cmd *cobra.Command) { var zone string - flags.StringVar(&zone, "zone", "", "Optional. Override default availability zone for this command invocation, see 'ucloud region'") - flags.SetFlagValuesFunc("zone", func() []string { + cmd.Flags().StringVar(&zone, "zone", "", "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + command.SetCompletion(cmd, "zone", func() []string { return getZoneList(req.GetRegion()) }) req.SetZoneRef(&zone) } -func bindZoneEmptyS(zone, region *string, flags *pflag.FlagSet) { - flags.StringVar(zone, "zone", "", "Optional. Override default availability zone for this command invocation, see 'ucloud region'") - flags.SetFlagValuesFunc("zone", func() []string { +func bindZoneEmptyS(zone, region *string, cmd *cobra.Command) { + cmd.Flags().StringVar(zone, "zone", "", "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + command.SetCompletion(cmd, "zone", func() []string { return getZoneList(*region) }) } -func bindZoneS(zone, region *string, flags *pflag.FlagSet) { - *zone = base.ConfigIns.Zone - flags.StringVar(zone, "zone", base.ConfigIns.Zone, "Optional. Override default availability zone for this command invocation, see 'ucloud region'") - flags.SetFlagValuesFunc("zone", func() []string { +func bindZoneS(zone, region *string, cmd *cobra.Command) { + def := runtimeDefaults() + *zone = def.Zone + cmd.Flags().StringVar(zone, "zone", def.Zone, "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + command.SetCompletion(cmd, "zone", func() []string { return getZoneList(*region) }) } -func bindProjectID(req request.Common, flags *pflag.FlagSet) { +func bindProjectID(req request.Common, cmd *cobra.Command) { var project string - flags.StringVar(&project, "project-id", base.ConfigIns.ProjectID, "Optional. Override default project-id for this command invocation, see 'ucloud project list'") - flags.SetFlagValuesFunc("project-id", getProjectList) + def := runtimeDefaults() + cmd.Flags().StringVar(&project, "project-id", def.ProjectID, "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + command.SetCompletion(cmd, "project-id", getProjectList) req.SetProjectIdRef(&project) } -func bindProjectIDS(project *string, flags *pflag.FlagSet) { - *project = base.ConfigIns.ProjectID - flags.StringVar(project, "project-id", base.ConfigIns.ProjectID, "Optional. Override default project-id for this command invocation, see 'ucloud project list'") - flags.SetFlagValuesFunc("project-id", getProjectList) +func bindProjectIDS(project *string, cmd *cobra.Command) { + def := runtimeDefaults() + *project = def.ProjectID + cmd.Flags().StringVar(project, "project-id", def.ProjectID, "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + command.SetCompletion(cmd, "project-id", getProjectList) } func bindGroup(req interface{}, flags *pflag.FlagSet) { @@ -95,12 +102,12 @@ func bindOffset(req interface{}, flags *pflag.FlagSet) { f.Set(reflect.ValueOf(offset)) } -func bindChargeType(req interface{}, flags *pflag.FlagSet) { - chargeType := flags.String("charge-type", "Month", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly; 'Dynamic', pay hourly; 'Trial', free trial(need permission)") +func bindChargeType(req interface{}, cmd *cobra.Command) { + chargeType := cmd.Flags().String("charge-type", "Month", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly; 'Dynamic', pay hourly; 'Trial', free trial(need permission)") v := reflect.ValueOf(req).Elem() f := v.FieldByName("ChargeType") f.Set(reflect.ValueOf(chargeType)) - flags.SetFlagValues("charge-type", "Month", "Dynamic", "Year") + command.SetFlagValues(cmd, "charge-type", "Month", "Dynamic", "Year") } func bindQuantity(req interface{}, flags *pflag.FlagSet) { @@ -110,15 +117,6 @@ func bindQuantity(req interface{}, flags *pflag.FlagSet) { f.Set(reflect.ValueOf(quanitiy)) } -func getEIPLine(region string) (line string) { - if strings.HasPrefix(region, "cn") { - line = "BGP" - } else { - line = "International" - } - return -} - type concurrentAction struct { reqs []request.Common actionFunc func(request.Common) (bool, []string) @@ -145,7 +143,7 @@ func (c *concurrentAction) actionFuncWrapper(req request.Common) { success, logs := c.actionFunc(req) c.result <- success logs = append([]string{"========================================"}, logs...) - base.LogInfo(logs...) + platform.LogInfo(logs...) <-c.tokens time.Sleep(time.Second / 5) c.wg.Done() @@ -154,10 +152,12 @@ func (c *concurrentAction) actionFuncWrapper(req request.Common) { func (c *concurrentAction) Do() { count := len(c.reqs) success, fail := 0, 0 - refresh := ux.NewRefresh() + progressOut := platform.Cxt.GetWriter() + refresh := ui.NewRefresh(progressOut) //同时执行任务数量大于5时,不再单独显示每一个任务的进行情况,而是聚合显示 if count > 5 { - ux.Doc.Disable() + doc := ui.NewDocument(progressOut) + doc.Disable() refresh.Do(fmt.Sprintf("total:%d, doing:%d, success:%d, fail:%d", count, len(c.tokens), success, fail)) } go func() { @@ -172,7 +172,7 @@ func (c *concurrentAction) Do() { case <-time.Tick(time.Second / 30): if count == (success+fail) && fail > 0 { - fmt.Printf("Check logs in %s\n", base.GetLogFilePath()) + fmt.Printf("Check logs in %s\n", platform.GetLogFilePath()) return } if count > 5 { diff --git a/cmd/vpc.go b/cmd/vpc.go deleted file mode 100644 index 4d79c6d03d..0000000000 --- a/cmd/vpc.go +++ /dev/null @@ -1,573 +0,0 @@ -package cmd - -import ( - "fmt" - "io" - "net" - "strconv" - "strings" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-sdk-go/services/vpc" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - - "github.com/ucloud/ucloud-cli/base" -) - -//NewCmdVpc ucloud vpc -func NewCmdVpc() *cobra.Command { - cmd := &cobra.Command{ - Use: "vpc", - Short: "List and manipulate VPC instances", - Long: "List and manipulate VPC instances", - Args: cobra.NoArgs, - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdVpcCreate()) - cmd.AddCommand(NewCmdVPCList(out)) - cmd.AddCommand(NewCmdVpcDelete()) - cmd.AddCommand(NewCmdVpcCreatePeer()) - cmd.AddCommand(NewCmdVpcListPeer(out)) - cmd.AddCommand(NewCmdVpcDeletePeer()) - return cmd -} - -//VPCRow 表格行 -type VPCRow struct { - VPCName string - ResourceID string - Group string - NetworkSegment string - SubnetCount int - CreationTime string -} - -//NewCmdVPCList ucloud vpc list -func NewCmdVPCList(out io.Writer) *cobra.Command { - vpcIDs := []string{} - req := base.BizClient.NewDescribeVPCRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List vpc", - Long: "List vpc", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - for _, id := range vpcIDs { - req.VPCIds = append(req.VPCIds, base.PickResourceID(id)) - } - resp, err := base.BizClient.DescribeVPC(req) - if err != nil { - base.HandleError(err) - return - } - list := []VPCRow{} - for _, vpc := range resp.DataSet { - row := VPCRow{} - row.VPCName = vpc.Name - row.ResourceID = vpc.VPCId - row.Group = vpc.Tag - row.NetworkSegment = strings.Join(vpc.Network, ",") - row.SubnetCount = vpc.SubnetCount - row.CreationTime = base.FormatDate(vpc.CreateTime) - list = append(list, row) - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - req.Tag = flags.String("group", "", "Optional. Group") - flags.StringSliceVar(&vpcIDs, "vpc-id", []string{}, "Optional. Multiple values separated by commas") - - flags.SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - - return cmd -} - -//NewCmdVpcCreate ucloud vpc create -func NewCmdVpcCreate() *cobra.Command { - var segments *[]string - req := base.BizClient.NewCreateVPCRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create vpc network", - Long: "Create vpc network", - Example: "ucloud vpc create --name xxx --segment 192.168.0.0/16", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - req.Network = *segments - resp, err := base.BizClient.CreateVPC(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("vpc[%s] created\n", resp.VPCId) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.Name = cmd.Flags().String("name", "", "Required. Name of the vpc network.") - segments = cmd.Flags().StringSlice("segment", nil, "Required. The segment for private network.") - req.Tag = cmd.Flags().String("group", "", "Optional. Business group.") - req.Remark = cmd.Flags().String("remark", "", "Optional. The description of the vpc.") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Assign the region of the VPC") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Assign the project-id") - - flags.SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("segment") - - return cmd -} - -//NewCmdVpcDelete ucloud vpc delete -func NewCmdVpcDelete() *cobra.Command { - idNames := []string{} - req := base.BizClient.NewDeleteVPCRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete vpc network", - Long: "Delete vpc network", - Example: "ucloud vpc delete --vpc-id uvnet-xxx", - Run: func(cmd *cobra.Command, args []string) { - for _, idname := range idNames { - req.VPCId = sdk.String(base.PickResourceID(idname)) - _, err := base.BizClient.DeleteVPC(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("vpc[%s] deleted\n", idname) - } - }, - } - - cmd.Flags().SortFlags = false - - cmd.Flags().StringSliceVar(&idNames, "vpc-id", nil, "Required. Resource ID of the vpc network to delete") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. Region of the vpc") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. Project id of the vpc") - - cmd.Flags().SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("vpc-id") - - return cmd -} - -//NewCmdVpcCreatePeer ucloud vpc peer -func NewCmdVpcCreatePeer() *cobra.Command { - req := base.BizClient.NewCreateVPCIntercomRequest() - cmd := &cobra.Command{ - Use: "create-intercome", - Short: "Create intercome with other vpc", - Long: "Create intercome with other vpc", - Example: "ucloud vpc create-intercome --vpc-id xx --dst-vpc-id xx --dst-region xx", - Run: func(cmd *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - req.DstProjectId = sdk.String(base.PickResourceID(*req.DstProjectId)) - req.VPCId = sdk.String(base.PickResourceID(*req.VPCId)) - req.DstVPCId = sdk.String(base.PickResourceID(*req.DstVPCId)) - _, err := base.BizClient.CreateVPCIntercom(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("intercome [%s<-->%s] establish", *req.VPCId, *req.DstVPCId) - }, - } - - cmd.Flags().SortFlags = false - - req.VPCId = cmd.Flags().String("vpc-id", "", "Required. The source vpc you want to establish the intercome") - req.DstVPCId = cmd.Flags().String("dst-vpc-id", "", "Required. The target vpc you want to establish the intercome") - req.DstRegion = cmd.Flags().String("dst-region", base.ConfigIns.Region, "Required. If the intercome established across different regions") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optioanl. The region of source vpc which will establish the intercome") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. The project id of the source vpc") - req.DstProjectId = cmd.Flags().String("dst-project-id", base.ConfigIns.ProjectID, "Optional. The project id of the source vpc") - - cmd.MarkFlagRequired("vpc-id") - cmd.MarkFlagRequired("dst-vpc-id") - - cmd.Flags().SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - cmd.Flags().SetFlagValuesFunc("dst-vpc-id", func() []string { - return getAllVPCIdNames(*req.DstProjectId, *req.DstRegion) - }) - cmd.Flags().SetFlagValuesFunc("region", getRegionList) - cmd.Flags().SetFlagValuesFunc("dst-region", getRegionList) - cmd.Flags().SetFlagValuesFunc("project-id", getProjectList) - cmd.Flags().SetFlagValuesFunc("dst-project-id", getProjectList) - - return cmd -} - -//VPCIntercomRow 表格行 -type VPCIntercomRow struct { - VPCName string - ResourceID string - Segments string - ProjectID string - DstRegion string - Group string -} - -//NewCmdVpcListPeer ucloud vpc list-intercome -func NewCmdVpcListPeer(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeVPCIntercomRequest() - cmd := &cobra.Command{ - Use: "list-intercome", - Short: "list intercome ", - Long: "list intercome", - Example: "ucloud vpc list-intercome --vpc-id xx", - Run: func(cmd *cobra.Command, args []string) { - req.VPCId = sdk.String(base.PickResourceID(*req.VPCId)) - resp, err := base.BizClient.DescribeVPCIntercom(req) - if err != nil { - base.HandleError(err) - return - } - list := make([]VPCIntercomRow, 0) - for _, VPCIntercom := range resp.DataSet { - row := VPCIntercomRow{} - row.ProjectID = VPCIntercom.ProjectId - row.Segments = strings.Join(VPCIntercom.Network, ",") - row.DstRegion = VPCIntercom.DstRegion - row.VPCName = VPCIntercom.Name - row.ResourceID = VPCIntercom.VPCId - row.Group = VPCIntercom.Tag - list = append(list, row) - } - base.PrintList(list, out) - }, - } - req.VPCId = cmd.Flags().String("vpc-id", "", "Required. The vpc id which you wnat to describe the information") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. The project id of source vpc") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional, The region of source vpc") - - cmd.Flags().SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - cmd.Flags().SetFlagValuesFunc("region", getRegionList) - cmd.Flags().SetFlagValuesFunc("project-id", getProjectList) - - cmd.MarkFlagRequired("vpc-id") - - return cmd -} - -//NewCmdVpcDeletePeer ucloud vpc delete-intercome -func NewCmdVpcDeletePeer() *cobra.Command { - req := base.BizClient.NewDeleteVPCIntercomRequest() - cmd := &cobra.Command{ - Use: "delete-intercome", - Short: "delete the vpc intercome", - Long: "delete the vpc intercome", - Example: "ucloud vpc delete-intercome --vpc-id xxx --dst-vpc-id xxx", - Run: func(cmd *cobra.Command, args []string) { - req.VPCId = sdk.String(base.PickResourceID(*req.VPCId)) - req.DstVPCId = sdk.String(base.PickResourceID(*req.DstVPCId)) - _, err := base.BizClient.DeleteVPCIntercom(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("intercome [%s<-->%s] deleted\n", *req.VPCId, *req.DstVPCId) - }, - } - - cmd.Flags().SortFlags = false - - req.VPCId = cmd.Flags().String("vpc-id", "", "Required. Resource ID of source VPC to disconnect with destination VPC") - req.DstVPCId = cmd.Flags().String("dst-vpc-id", "", "Required. Resource ID of destination VPC to disconnect with source VPC") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. The project id of source vpc") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. The region of source vpc to disconnect") - req.DstRegion = cmd.Flags().String("dst-region", "", "Optional. The region of dest vpc to disconnect") - - cmd.MarkFlagRequired("vpc-id") - cmd.MarkFlagRequired("dst-vpc-id") - cmd.MarkFlagRequired("dst-region") - - cmd.Flags().SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - cmd.Flags().SetFlagValuesFunc("dst-region", getRegionList) - - return cmd -} - -func getAllVPCIns(project, region string) ([]vpc.VPCInfo, error) { - req := base.BizClient.NewDescribeVPCRequest() - req.ProjectId = &project - req.Region = ®ion - resp, err := base.BizClient.DescribeVPC(req) - if err != nil { - return nil, err - } - return resp.DataSet, nil -} - -func getAllVPCIdNames(project, region string) []string { - vpcInsList, err := getAllVPCIns(project, region) - list := []string{} - if err != nil { - return nil - } - for _, vpc := range vpcInsList { - list = append(list, fmt.Sprintf("%s/%s", vpc.VPCId, vpc.Name)) - } - return list -} - -//NewCmdSubnet ucloud subnet -func NewCmdSubnet() *cobra.Command { - cmd := &cobra.Command{ - Use: "subnet", - Short: "List, create and delete subnet", - Long: "List, create and delete subnet", - Args: cobra.NoArgs, - } - out := base.Cxt.GetWriter() - cmd.AddCommand(NewCmdSubnetList(out)) - cmd.AddCommand(NewCmdSubnetCreate()) - cmd.AddCommand(NewCmdSubnetDelete(out)) - cmd.AddCommand(NewCmdSubnetListResource(out)) - - return cmd -} - -//SubnetRow 表格行 -type SubnetRow struct { - SubnetName string - ResourceID string - Group string - AffiliatedVPC string - NetworkSegment string - CreationTime string -} - -//NewCmdSubnetList ucloud subnet list -func NewCmdSubnetList(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeSubnetRequest() - cmd := &cobra.Command{ - Use: "list", - Short: "List subnet", - Long: `List subnet`, - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - resp, err := base.BizClient.DescribeSubnet(req) - if err != nil { - base.HandleError(err) - return - } - list := make([]SubnetRow, 0) - for _, sn := range resp.DataSet { - row := SubnetRow{} - row.SubnetName = sn.SubnetName - row.ResourceID = sn.SubnetId - row.Group = sn.Tag - row.AffiliatedVPC = fmt.Sprintf("%s/%s", sn.VPCId, sn.VPCName) - row.NetworkSegment = fmt.Sprintf("%s/%s", sn.Subnet, sn.Netmask) - row.CreationTime = base.FormatDate(sn.CreateTime) - list = append(list, row) - } - base.PrintList(list, out) - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - req.Region = flags.String("region", base.ConfigIns.Region, "Optional. Region, see 'ucloud region'") - req.ProjectId = flags.String("project-id", base.ConfigIns.ProjectID, "Optional. Project-id, see 'ucloud project list'") - flags.StringSliceVar(&req.SubnetIds, "subnet-id", []string{}, "Optional. Multiple values separated by commas") - req.VPCId = flags.String("vpc-id", "", "Optional. Resource ID of VPC") - req.Tag = flags.String("group", "", "Optional. Group") - req.Offset = flags.Int("offset", 0, "Optional. Offset") - req.Limit = flags.Int("limit", 50, "Optional. Limit") - - return cmd -} - -//NewCmdSubnetCreate ucloud subnet create -func NewCmdSubnetCreate() *cobra.Command { - var segment *net.IPNet - req := base.BizClient.NewCreateSubnetRequest() - cmd := &cobra.Command{ - Use: "create", - Short: "Create subnet of vpc network", - Long: "Create subnet of vpc network", - Example: "ucloud subnet create --vpc-id uvnet-vpcxid --name testName --segment 192.168.2.0/24", - Run: func(cmd *cobra.Command, args []string) { - ipMaskStrs := strings.SplitN(segment.String(), "/", 2) - req.Subnet = sdk.String(ipMaskStrs[0]) - mask, err := strconv.Atoi(ipMaskStrs[1]) - if err != nil { - base.HandleError(err) - return - } - req.Netmask = sdk.Int(mask) - req.VPCId = sdk.String(base.PickResourceID(*req.VPCId)) - resp, err := base.BizClient.CreateSubnet(req) - if err != nil { - base.HandleError(err) - return - } - base.Cxt.Printf("subnet[%s] created\n", resp.SubnetId) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - - req.VPCId = cmd.Flags().String("vpc-id", "", "Required. Assign the VPC network of the subnet") - segment = cmd.Flags().IPNet("segment", net.IPNet{}, "Required. Segment of subnet. For example '192.168.0.0/24'") - req.SubnetName = cmd.Flags().String("name", "Subnet", "Optional. Name of subnet to create") - req.Region = cmd.Flags().String("region", base.ConfigIns.Region, "Optional. The region of the subnet") - req.ProjectId = cmd.Flags().String("project-id", base.ConfigIns.ProjectID, "Optional. The project id of the subnet") - req.Tag = cmd.Flags().String("group", "", "Optional. Business group") - req.Remark = cmd.Flags().String("remark", "", "Optional. Remark of subnet to create") - - cmd.Flags().SetFlagValuesFunc("vpc-id", func() []string { - return getAllVPCIdNames(*req.ProjectId, *req.Region) - }) - - cmd.MarkFlagRequired("vpc-id") - cmd.MarkFlagRequired("segment") - - return cmd -} - -//NewCmdSubnetDelete ucloud subnet delete -func NewCmdSubnetDelete(out io.Writer) *cobra.Command { - idNames := []string{} - req := base.BizClient.NewDeleteSubnetRequest() - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete subnet", - Long: "Delete subnet", - Run: func(c *cobra.Command, args []string) { - req.ProjectId = sdk.String(base.PickResourceID(*req.ProjectId)) - for _, id := range idNames { - req.SubnetId = sdk.String(base.PickResourceID(id)) - _, err := base.BizClient.DeleteSubnet(req) - if err != nil { - base.HandleError(err) - continue - } - fmt.Fprintf(out, "subnet[%s] deleted\n", id) - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&idNames, "subnet-id", nil, "Required. Resource ID of subent") - bindRegion(req, flags) - bindProjectID(req, flags) - cmd.MarkFlagRequired("subnet-id") - flags.SetFlagValuesFunc("subnet-id", func() []string { - return getAllSubnetIDNames("", *req.ProjectId, *req.Region) - }) - - return cmd -} - -//SubnetResourceRow 表格行 -type SubnetResourceRow struct { - ResourceName string - ResourceID string - ResourceType string - PrivateIP string -} - -//NewCmdSubnetListResource ucloud subnet list-resource -func NewCmdSubnetListResource(out io.Writer) *cobra.Command { - req := base.BizClient.NewDescribeSubnetResourceRequest() - cmd := &cobra.Command{ - Use: "list-resource", - Short: "List resources belong to subnet", - Long: "List resources belong to subnet", - Run: func(c *cobra.Command, args []string) { - req.SubnetId = sdk.String(base.PickResourceID(*req.SubnetId)) - resp, err := base.BizClient.DescribeSubnetResource(req) - if err != nil { - base.HandleError(err) - return - } - list := []SubnetResourceRow{} - for _, r := range resp.DataSet { - row := SubnetResourceRow{ - ResourceName: r.Name, - ResourceID: r.ResourceId, - ResourceType: r.ResourceType, - PrivateIP: r.IP, - } - list = append(list, row) - } - base.PrintList(list, out) - }, - } - flags := cmd.Flags() - flags.SortFlags = false - req.SubnetId = flags.String("subnet-id", "", "Required. Resource ID of subnet which resources to list belong to") - req.ResourceType = flags.String("resource-type", "", "Optional. Resource type of resources to list. Accept values:'uhost','phost','ulb','uhadoophost','ufortresshost','unatgw','ukafka','umem','docker','udb','udw' and 'vip'") - bindRegion(req, flags) - bindProjectID(req, flags) - bindLimit(req, flags) - bindOffset(req, flags) - cmd.MarkFlagRequired("subnet-id") - flags.SetFlagValuesFunc("subnet-id", func() []string { - return getAllSubnetIDNames("", *req.ProjectId, *req.Region) - }) - flags.SetFlagValues("resource-type", "uhost", "phost", "ulb", "uhadoophost", "ufortresshost", "unatgw", "ukafka", "umem", "docker", "udb", "udw", "vip") - - return cmd -} - -func getAllSubnets(vpcID, project, region string) ([]vpc.VPCSubnetInfoSet, error) { - req := base.BizClient.NewDescribeSubnetRequest() - req.ProjectId = sdk.String(base.PickResourceID(project)) - req.Region = sdk.String(region) - if vpcID != "" { - req.VPCId = sdk.String(base.PickResourceID(vpcID)) - } - subnets := []vpc.VPCSubnetInfoSet{} - for limit, offset := 50, 0; ; offset += limit { - req.Limit = sdk.Int(limit) - req.Offset = sdk.Int(offset) - resp, err := base.BizClient.DescribeSubnet(req) - if err != nil { - base.HandleError(err) - return nil, err - } - subnets = append(subnets, resp.DataSet...) - if limit+offset >= resp.TotalCount { - break - } - } - return subnets, nil -} - -func getAllSubnetIDNames(vpcID, project, region string) []string { - subnets, err := getAllSubnets(vpcID, project, region) - if err != nil { - return nil - } - list := []string{} - for _, s := range subnets { - list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) - } - return list -} diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 298ea9e213..0000000000 --- a/docs/Makefile +++ /dev/null @@ -1,19 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/_static/header.css b/docs/_static/header.css deleted file mode 100644 index 7497b13d48..0000000000 --- a/docs/_static/header.css +++ /dev/null @@ -1,364 +0,0 @@ -.docs-header { - color: #fff; - background-color: #4074e1; - background-image: url(https://static.ucloud.cn/af1a3fb7f6d2824ae06793c8f57e42d6.png); - background-position: center bottom; - background-repeat: repeat-x; -} - -.docs-header .main { - overflow: visible; - box-sizing: border-box; - padding-left: 20px; - padding-right: 20px; -} - -.docs-header .nav-bar { - line-height: 1; - padding-top: 15px; - padding-bottom: 20px; - margin-bottom: 10px; -} -.docs-header a { - text-decoration: inherit; - color: inherit; -} - -.docs-logo { - display: inline-block; - width: 90px; - height: 25px; - background: url(https://static.ucloud.cn/46edd4d75119fb842dc9d8f7d71730bd.png) center center no-repeat; -} - -.page-footer-copyright { - padding: 5px 10px; - text-align: center; - font-size: 14px; - line-height: 2; - color: #fff; - background-color: #212930; -} - -.page-footer-copyright a { - color: #fff; -} - -.header-link { - display: inline-block; - line-height: 25px; - padding-left: 10px; - padding-right: 10px; - margin-left: 5px; - margin-right: 5px; - border-radius: 4px; - color: #fff; - font-size: 13px; - background-color: transparent; - -webkit-transition: all .2s; - transition: all .2s; -} - -.header-link:hover { - color: #fff; - background-color: #658be1; -} - -.header-link [class^="icon-"] { - margin-right: 5px; - font-size: 16px; - vertical-align: text-bottom; -} - -.docs-login-wrapper { - display: inline-block; -} - -.docs-login-wrapper.login .signup,.docs-login-wrapper.login .login { - display: none; -} - -.docs-login-wrapper.logout .login-user-name,.docs-login-wrapper.logout .logout { - display: none; -} - -.docs-login-wrapper .header-link { - border: 1px solid #2e63d0; -} - -.docs-login-wrapper .header-link:hover { - border-color: transparent; -} - -.docs-login-wrapper .login,.docs-login-wrapper .logout { - background-color: #2e63d0; -} - -.docs-login-wrapper .login:hover,.docs-login-wrapper .logout:hover { - background-color: #658be1; -} - -.login-user-name { - display: inline-block; - min-width: 130px; - line-height: 25px; - margin-left: 5px; - margin-right: 5px; - color: #fff; - font-size: 13px; -} - -.login-user-name:hover { - color: #fff; - text-decoration: underline; -} - -.header-search-bar{ - padding-bottom: 35px; - } - - -.header-title { - text-align: center; - color: #fff; -} - -.header-title .title { - color: #fff; - font-size: 34px; - font-weight: normal; - line-height: 1; - padding-bottom: 5px; - margin-bottom: 0; -} - -.header-title .title-en { - display: inline-block; - width: 131px; - height: 0; - padding-top: 13px; - font-size: 13px; - line-height: 1; - color: #fff; - overflow: hidden; - background: url(https://static.ucloud.cn/df6e35e1f24ead71ea2be121ac390659.png) 0 0 no-repeat; -} - -.header-search-wrapper { - position: relative; - width: 1000px; -} - -.search-input-wrapper { - position: relative; - box-sizing: border-box; - width: 100%; - height: 40px; - line-height: 1; - text-align: left; - background-color: #386dda; - border-radius: 4px; - -webkit-transition: background-color .2s ease-out; - transition: background-color .2s ease-out; -} - -.search-input-wrapper.focus { - background-color: #3769d4; -} - -.search-input-wrapper .search-input { - position: absolute; - box-sizing: border-box; - width: 100%; - left: 0; - top: 0; - bottom: 0; - height: 100%; - border: none; - padding-left: 25px; - padding-right: 65px; - color: #fff; - font-size: 15px; - background-color: transparent; -} - -.search-input-wrapper .search-input::-webkit-input-placeholder { - color: #a4c1ff; -} - -.search-input-wrapper .search-input::-moz-placeholder { - color: #a4c1ff; -} - -.search-input-wrapper .search-input:-ms-input-placeholder { - color: #a4c1ff; -} - -.search-input-wrapper .search-input:-webkit-autofill,.search-input-wrapper .search-input:-webkit-autofill:hover,.search-input-wrapper .search-input:-webkit-autofill:focus { - box-shadow: 0 0 0 60px #3263cc inset; - -webkit-text-fill-color: #fff; -} - -.search-input-wrapper .header-search-btn { - position: absolute; - box-sizing: border-box; - width: 65px; - right: 0; - top: 0; - bottom: 0; - opacity: 0; - z-index: 10; -} - -.search-input-wrapper .icon-search { - position: absolute; - font-size: 22px; - color: #fff; - right: 20px; - top: 50%; - margin-top: -11px; -} -.pull-right { - float: right !important; -} -.pull-left { - float: left !important; -} -.header-title { - text-align: center; - color: #fff; -} - -.tr { - text-align: right; -} -.hz-wrapper { - font-size: 0; -} -.main { - margin: 0 auto; - width: 1200px; - height: auto; - clear: both; - overflow: hidden; -} - -.clearfix:before,.clearfix:after { - content: " "; - display: table; -} - -.clearfix:after { - clear: both; -} -@font-face { - font-family: 'icomoon'; - src: url('/_static/icomoon.ttf?uhi4h5'); - font-weight: normal; - font-style: normal; -} - -[class^="icon-"],[class*=" icon-"] { - font-family: 'icomoon' !important; - speak: none; - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - line-height: 1; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.icon-sidebar-menu:before { - content: "\e903"; -} - -.icon-console:before { - content: "\e900"; -} - -.icon-home:before { - content: "\e901"; -} - -.icon-UConnect:before { - content: "\e902"; -} - -.icon-arrow-up:before { - content: "\e90e"; -} - -.icon-ufile:before { - content: "\e912"; -} - -.icon-UNet:before { - content: "\e91a"; -} - -.icon-dashbord-meum:before { - content: "\e943"; -} - -.icon-down:before { - content: "\e94e"; -} - -.icon-minus:before { - content: "\e97f"; -} - -.icon-plus:before { - content: "\e990"; -} - -.icon-qr-code:before { - content: "\e995"; -} - -.icon-search:before { - content: "\e99f"; -} - -.icon-up:before { - content: "\e9d1"; -} - -.icon-urecord:before { - content: "\e9d5"; -} - -.icon-wechat:before { - content: "\e9e2"; -} - -div { - margin: 0; - padding: 0; -} -a { - text-decoration: none; -} - -body{ - position: relative; - font: normal 13px "Hiragino Sans GB","\5FAE\8F6F\96C5\9ED1","Microsoft Yahei",tahoma,arial,"\5B8B\4F53",sans-serif; - -webkit-text-size-adjust: 100%; - min-height: 100%; -} -h1 { - font-size: 22px; - margin: 0 0 8px; -} -h1,h2,h3,h4,h5,h6 { - font-weight: bold; - color: #212930; - background-color: inherit; - padding: 0; - line-height: 2.2; - clear: left; -} -input:focus,button:focus,select:focus,keygen:focus,textarea:focus { - outline: 0; -} \ No newline at end of file diff --git a/docs/_static/icomoon.ttf b/docs/_static/icomoon.ttf deleted file mode 100644 index 2a08f40121..0000000000 Binary files a/docs/_static/icomoon.ttf and /dev/null differ diff --git a/docs/_static/ucloud_cli_demo.gif b/docs/_static/ucloud_cli_demo.gif deleted file mode 100644 index cb0c4537e5..0000000000 Binary files a/docs/_static/ucloud_cli_demo.gif and /dev/null differ diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html deleted file mode 100644 index ea7ca738fd..0000000000 --- a/docs/_templates/layout.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "!layout.html" %} -{% block header %} -
-
- - -
-
- {{ super() }} -{% endblock %} -{# Add some extra stuff before and use existing with 'super()' call. #} -{% block footer %} - -{% endblock %} - diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 1bc9623d51..0000000000 --- a/docs/conf.py +++ /dev/null @@ -1,180 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Configuration file for the Sphinx documentation builder. -# -# This file does only contain a selection of the most common options. For a -# full list see the documentation: -# http://www.sphinx-doc.org/en/master/config - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - - -# -- Project information ----------------------------------------------------- - -project = 'ucloud-cli' -copyright = '2019, ucloud' -author = 'ucloud' - -# The short X.Y version -version = '' -# The full version, including alpha/beta/rc tags -release = '0.1.14' - - -# -- General configuration --------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The master toctree document. -master_doc = 'index' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = [] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = None - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'alabaster' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -html_theme_options = { - 'github_user': 'ucloud', - 'github_repo': 'ucloud-cli', - 'github_type': 'star', - 'show_powered_by':False, -} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# The default sidebars (for documents that don't match any pattern) are -# defined by theme itself. Builtin themes are using these templates by -# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', -# 'searchbox.html']``. -# - -# -- Options for HTMLHelp output --------------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = 'ucloud-clidoc' - - -# -- Options for LaTeX output ------------------------------------------------ - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'ucloud-cli.tex', 'ucloud-cli Documentation', - 'ucloud', 'manual'), -] - - -# -- Options for manual page output ------------------------------------------ - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'ucloud-cli', 'ucloud-cli Documentation', - [author], 1) -] - - -# -- Options for Texinfo output ---------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'ucloud-cli', 'ucloud-cli Documentation', - author, 'ucloud-cli', 'One line description of project.', - 'Miscellaneous'), -] - - -# -- Options for Epub output ------------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = project - -# The unique identifier of the text. This can be a ISBN number -# or the project homepage. -# -# epub_identifier = '' - -# A unique identification for the text. -# -# epub_uid = '' - -# A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] - - -def setup(app): - app.add_stylesheet("header.css") \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 7bb9f32d11..0000000000 --- a/docs/index.rst +++ /dev/null @@ -1,13 +0,0 @@ -.. ucloud-cli documentation master file, created by - sphinx-quickstart on Fri Mar 29 16:22:29 2019. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to ucloud-cli's documentation! -====================================== - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - Command Reference \ No newline at end of file diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 27f573b87a..0000000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% - -:end -popd diff --git a/examples/product_onboarding/cmd.go b/examples/product_onboarding/cmd.go new file mode 100644 index 0000000000..ce9e7d8c57 --- /dev/null +++ b/examples/product_onboarding/cmd.go @@ -0,0 +1,31 @@ +package onboarding + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newCommand assembles the product's command tree: construct the top-level +// command and AddCommand one constructor per verb — this aggregator is the +// ONLY content allowed in cmd.go (§2 file-layout convention: one verb per +// file, named after the subcommand). +func newCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: productName, + Short: "Greenfield example product (onboarding worked example)", + Long: "Greenfield example product demonstrating the ucloud-cli platform " + + "onboarding contract. Not a real product; exists as the onboarding " + + "worked example and the platform-API compile gate.", + } + + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newStart(ctx)) + cmd.AddCommand(newStop(ctx)) + cmd.AddCommand(newRestart(ctx)) + + return cmd +} diff --git a/examples/product_onboarding/completion.go b/examples/product_onboarding/completion.go new file mode 100644 index 0000000000..b59273fa68 --- /dev/null +++ b/examples/product_onboarding/completion.go @@ -0,0 +1,70 @@ +package onboarding + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// listResourceIDs returns the resource-id completion candidates for the +// `---id` flag, in the conventional "resourceID/name" form. A caller +// later runs the picked value through ctx.PickResourceID to strip the "/name" +// suffix back to a bare id. +// +// A real product filters by the current region/zone/project; here we close over +// ctx and read those from the bound request at completion time. The candidates +// double as a worked example of calling cli.NewServiceClient inside a +// completion provider. +// +// states, when non-nil, restricts candidates to those whose State is in the +// set — e.g. start completes only stopped instances, stop only running ones. +func listResourceIDs(ctx *cli.Context, states []string, region, zone, projectID string) []string { + client := cli.NewServiceClient(ctx, udb.NewClient) + + req := client.NewDescribeUDBInstanceRequest() + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + req.ProjectId = sdk.String(projectID) + req.ClassType = sdk.String("sql") + req.Limit = sdk.Int(100) + + resp, err := client.DescribeUDBInstance(req) + if err != nil { + // Completion must never error out the shell; degrade to no candidates. + return nil + } + + candidates := make([]string, 0, len(resp.DataSet)) + for _, ins := range resp.DataSet { + if !stateAllowed(ins.State, states) { + continue + } + candidates = append(candidates, fmt.Sprintf("%s/%s", ins.DBId, ins.Name)) + } + return candidates +} + +// stateAllowed reports whether state passes the optional allow-list. A nil +// allow-list means "any state". +func stateAllowed(state string, states []string) bool { + if states == nil { + return true + } + for _, s := range states { + if s == state { + return true + } + } + return false +} + +// derefStr safely dereferences a *string bound by a flag, returning "" for nil. +func derefStr(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/examples/product_onboarding/create.go b/examples/product_onboarding/create.go new file mode 100644 index 0000000000..4d5717a79d --- /dev/null +++ b/examples/product_onboarding/create.go @@ -0,0 +1,86 @@ +package onboarding + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// versionValues is a static candidate set for the create --version flag, +// registered via command.SetFlagValues. +var versionValues = []string{"mysql-5.7", "mysql-8.0"} + +// newCreate implements `example create`. +// +// Platform APIs exercised: cli.NewServiceClient, ctx.BindCommonParams, +// ctx.PollerTo(...).Spoll (the wait path), ctx.ProgressWriter, ctx.EmitResult, +// ctx.HandleError, command.SetFlagValues, MarkFlagRequired with "Required." +// descriptions, the --async pattern. +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewCreateUDBInstanceRequest() + + var async bool + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an example instance", + Long: "Create an example instance and, unless --async is set, wait for it to become Running.", + Run: func(c *cobra.Command, args []string) { + // Human narration goes to the progress writer: stdout in table + // mode, stderr in machine (json/yaml) modes so stdout stays + // machine-parseable. + w := ctx.ProgressWriter() + resp, err := client.CreateUDBInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("%s[%s] is creating", productName, resp.DBId) + if async { + // --async: narrate and return without polling. + fmt.Fprintln(w, text) + } else { + // Synchronous: poll until the instance reaches a terminal state. + ctx.PollerTo(w, describeByID(ctx)).Spoll(resp.DBId, text, []string{stateRunning, stateFail}) + } + // Machine (json/yaml) modes: emit the structured result row on + // stdout. In table mode EmitResult is a no-op — the narration + // above is the result. + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.DBId, Action: "create", Status: "Creating"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + // Required flags: description starts with "Required." AND MarkFlagRequired. + req.Name = flags.String("name", "", "Required. Instance name, at least 6 characters.") + req.AdminPassword = flags.String("password", "", "Required. Admin password.") + req.DBTypeId = flags.String("version", "", "Required. DB version, e.g. mysql-8.0.") + + // Optional flags: description starts with "Optional.". + req.Port = flags.Int("port", 3306, "Optional. Service port.") + req.DiskSpace = flags.Int("disk-size-gb", 20, "Optional. Disk size in GiB.") + req.MemoryLimit = flags.Int("memory-size-mb", 1000, "Optional. Memory size in MB.") + req.ParamGroupId = flags.Int("param-group-id", 0, "Optional. Parameter group ID.") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for creation to finish.") + + // Aggregate binder also wires --charge-type/--quantity here because the + // create request carries those fields. + ctx.BindCommonParams(cmd, req) + + // Static candidate set for an enum flag. + command.SetFlagValues(cmd, "version", versionValues...) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("version") + + return cmd +} diff --git a/examples/product_onboarding/delete.go b/examples/product_onboarding/delete.go new file mode 100644 index 0000000000..0da339e624 --- /dev/null +++ b/examples/product_onboarding/delete.go @@ -0,0 +1,71 @@ +package onboarding + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete implements `example delete`. +// +// Platform APIs exercised: cli.NewServiceClient, ctx.BindCommonParams, +// ctx.Confirm (the destructive-op guard), the --yes/-y pattern, +// ctx.PickResourceID, ctx.ProgressWriter, ctx.EmitResult, ctx.HandleError, +// command.SetCompletion. +func newDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDeleteUDBInstanceRequest() + + var ids []string + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete example instances", + Long: "Delete one or more example instances by resource ID.", + Run: func(c *cobra.Command, args []string) { + // Destructive: gate on confirmation unless --yes was passed. + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the instance(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + req.DBId = sdk.String(id) + if _, err := client.DeleteUDBInstance(req); err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "%s[%s] deleted\n", productName, id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. Resource ID(s) of instances to delete.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + ctx.BindCommonParams(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return listResourceIDs(ctx, nil, derefStr(req.Region), derefStr(req.Zone), derefStr(req.ProjectId)) + }) + + return cmd +} diff --git a/examples/product_onboarding/describe.go b/examples/product_onboarding/describe.go new file mode 100644 index 0000000000..a895673ef8 --- /dev/null +++ b/examples/product_onboarding/describe.go @@ -0,0 +1,78 @@ +package onboarding + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDescribe implements `example describe`. +// +// Platform APIs exercised: cli.NewServiceClient, the non-aggregate binders +// (ctx.BindRegion / ctx.BindZone / ctx.BindProjectID — shown here once for the +// case where you want per-field control), cli.DescribeRow for detail rows, +// ctx.PrintList, ctx.PickResourceID, ctx.HandleError, command.SetCompletion. +func newDescribe(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBInstanceRequest() + + cmd := &cobra.Command{ + Use: "describe", + Short: "Show details of one example instance", + Long: "Show the full attribute/value detail of a single example instance.", + Run: func(c *cobra.Command, args []string) { + *req.DBId = ctx.PickResourceID(*req.DBId) + resp, err := client.DescribeUDBInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + if len(resp.DataSet) == 0 { + ctx.HandleError(fmt.Errorf("instance %q not found", *req.DBId)) + return + } + ins := resp.DataSet[0] + + // cli.DescribeRow renders a single resource as attribute/content + // rows. In table mode the field names "Attribute" and "Content" + // become the two column headers. + rows := []cli.DescribeRow{ + {Attribute: "ResourceID", Content: ins.DBId}, + {Attribute: "Name", Content: ins.Name}, + {Attribute: "Zone", Content: ins.Zone}, + {Attribute: "Mode", Content: ins.InstanceMode}, + {Attribute: "Version", Content: ins.DBTypeId}, + {Attribute: "Memory(MB)", Content: fmt.Sprintf("%d", ins.MemoryLimit)}, + {Attribute: "Disk(GB)", Content: fmt.Sprintf("%d", ins.DiskSpace)}, + {Attribute: "VirtualIP", Content: ins.VirtualIP}, + {Attribute: "Port", Content: fmt.Sprintf("%d", ins.Port)}, + {Attribute: "Status", Content: ins.State}, + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.DBId = flags.String(resourceIDFlag, "", "Required. Resource ID of the instance to describe.") + + // Non-aggregate binding: bind each common flag explicitly. Equivalent to + // BindCommonParams for region/zone/project, shown here for the case where a + // command needs to bind them individually (e.g. to interleave custom flags). + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return listResourceIDs(ctx, nil, derefStr(req.Region), derefStr(req.Zone), derefStr(req.ProjectId)) + }) + + return cmd +} diff --git a/examples/product_onboarding/list.go b/examples/product_onboarding/list.go new file mode 100644 index 0000000000..265f2720ac --- /dev/null +++ b/examples/product_onboarding/list.go @@ -0,0 +1,72 @@ +package onboarding + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList implements `example list`. +// +// Platform APIs exercised: cli.NewServiceClient, ctx.BindCommonParams (the +// aggregate binder), ctx.PickResourceID, ctx.PrintList, ctx.HandleError, +// command.SetCompletion. +func newList(ctx *cli.Context) *cobra.Command { + // One authed SDK client per command, built from the constructor. The Run + // func only needs this to type-check; the example is never executed. + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBInstanceRequest() + + cmd := &cobra.Command{ + Use: "list", + Short: "List example instances", + Long: "List example instances in the active region/zone/project.", + Run: func(c *cobra.Command, args []string) { + if req.DBId != nil && *req.DBId != "" { + *req.DBId = ctx.PickResourceID(*req.DBId) + } + resp, err := client.DescribeUDBInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]instanceRow, 0, len(resp.DataSet)) + for _, ins := range resp.DataSet { + rows = append(rows, instanceRow{ + ResourceID: ins.DBId, + Name: ins.Name, + Zone: ins.Zone, + Mode: ins.InstanceMode, + Spec: fmt.Sprintf("%s|%dMB|%dGB", ins.DBTypeId, ins.MemoryLimit, ins.DiskSpace), + Status: ins.State, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + // Optional resource-id filter, named after the product. + req.DBId = flags.String(resourceIDFlag, "", "Optional. List only the specified instance.") + req.ClassType = sdk.String("sql") + + // One call binds region/zone/project plus --limit/--offset (present on this + // request) with the per-invocation defaults and the injected completion + // providers. This is the primary, preferred binder. + ctx.BindCommonParams(cmd, req) + + // Dynamic completion for the resource-id flag. + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return listResourceIDs(ctx, nil, derefStr(req.Region), derefStr(req.Zone), derefStr(req.ProjectId)) + }) + + return cmd +} diff --git a/examples/product_onboarding/poll.go b/examples/product_onboarding/poll.go new file mode 100644 index 0000000000..e8b26d21fb --- /dev/null +++ b/examples/product_onboarding/poll.go @@ -0,0 +1,37 @@ +package onboarding + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// Poller plumbing shared by the long-running verbs — not the "describe" verb (that lives in describe.go). + +// describeByID returns the Poller describe func: given a resource id it fetches +// the current resource so the Poller can read its state field. The signature +// (func(string, *request.CommonBase) (interface{}, error)) is exactly what +// ctx.PollerTo expects. +func describeByID(ctx *cli.Context) func(string, *request.CommonBase) (interface{}, error) { + return func(id string, common *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBInstanceRequest() + if common != nil { + req.CommonBase = *common + } + req.DBId = sdk.String(id) + resp, err := client.DescribeUDBInstance(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) == 0 { + return nil, fmt.Errorf("instance %q not found", id) + } + // Return a *struct whose exported State field the Poller reads by name. + return &resp.DataSet[0], nil + } +} diff --git a/examples/product_onboarding/product.go b/examples/product_onboarding/product.go new file mode 100644 index 0000000000..37e692dc24 --- /dev/null +++ b/examples/product_onboarding/product.go @@ -0,0 +1,82 @@ +// Package onboarding is the canonical greenfield worked example for the +// ucloud-cli platform onboarding contract. +// +// It is NOT a real product: it is deliberately placed under examples/ (outside +// products/) so the platform's gen-products/check-product tooling ignores it, +// and it is never registered into the CLI (no product.yaml entry). It exists +// for two reasons: +// +// 1. Documentation. A new product author copies this directory as a starting +// point. It shows the standard 2-level command shape ( ) and +// exercises the core platform contract a product uses: client construction, +// flag binding, output, completion, polling, and machine-mode results. +// +// 2. Compile gate. Because it calls the core pkg/cli + pkg/command APIs +// above, any drift in those signatures breaks `go build ./...`, so CI +// catches platform-API regressions before they reach real products. +// +// The Run funcs build real SDK requests and type-check against the live SDK, +// but the example is never executed; it only needs to compile. +// +// Shape conventions demonstrated here (the onboarding contract): +// - Standard verbs only: list, describe, create, delete, start, stop, restart. +// - A flat 2-level tree: ` ` (no 3-level db/conf/backup groups). +// - Resource id flag named after the product: `--example-id`. +// - Required flags: MarkFlagRequired + a "Required." description prefix. +// - Optional flags: an "Optional." description prefix. +// - Long-running verbs (create/start/stop/restart) offer `--async` and +// otherwise wait via ctx.PollerTo(w, ...).Spoll(...). +// - Destructive verbs (delete) offer `--yes/-y` and gate on ctx.Confirm(...). +// - Write verbs narrate via ctx.ProgressWriter() (stdout in table mode, +// stderr in machine modes) and emit structured cli.OpResultRow rows via +// ctx.EmitResult, so json/yaml stdout stays machine-parseable. +package onboarding + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// productName is the single source of truth for the product's command name and +// its resource-id flag (`---id`). A real product hard-codes this. +const productName = "example" + +// resourceIDFlag is the resource-id flag, named after the product per the +// onboarding contract. +const resourceIDFlag = productName + "-id" // "example-id" + +// Product implements cli.Product. The platform calls New() to obtain it, then +// Metadata() to learn which top-level command names it owns, then NewCommand(ctx) +// to mount its subtrees. +type Product struct{} + +// New returns the product instance. The platform's generated registration code +// calls this constructor; here it is exercised only by the example's own tests +// and by NewCommand below. +func New() cli.Product { return &Product{} } + +// Metadata identifies the product and its owners. Commands is the list of +// top-level command names owned by this product; Version is filled at build +// time for a real product. +func (p *Product) Metadata() cli.Metadata { + return cli.Metadata{ + Name: productName, + Owners: []string{"platform-onboarding@ucloud.cn"}, + Commands: []string{productName}, + Version: "0.0.0", + } +} + +// NewCommand builds the product's cobra subtrees. Single-command products +// return one command; multi-command products return one command per top-level +// CLI entry. Each verb constructor receives ctx so it can build authed SDK +// clients (via cli.NewServiceClient) and bind common flags. NewCommand hands +// this example's tree assembly to newCommand (cmd.go). +func (p *Product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{newCommand(ctx)} +} + +// Compile-time assurance that Product satisfies the platform interface. If +// cli.Product changes shape, this line (and New's return type) fail to build. +var _ cli.Product = (*Product)(nil) diff --git a/examples/product_onboarding/restart.go b/examples/product_onboarding/restart.go new file mode 100644 index 0000000000..30f2711f8a --- /dev/null +++ b/examples/product_onboarding/restart.go @@ -0,0 +1,62 @@ +package onboarding + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestart implements `example restart`. +func newRestart(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewRestartUDBInstanceRequest() + + var ids []string + var async bool + + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart example instances", + Long: "Restart one or more example instances.", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + req.DBId = sdk.String(id) + if _, err := client.RestartUDBInstance(req); err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("%s[%s] is restarting", productName, id) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx)).Spoll(id, text, []string{stateRunning, stateFail}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "restart", Status: "Restarting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. Resource ID(s) of instances to restart.") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the operation to finish.") + ctx.BindCommonParams(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return listResourceIDs(ctx, nil, derefStr(req.Region), derefStr(req.Zone), derefStr(req.ProjectId)) + }) + + return cmd +} diff --git a/examples/product_onboarding/rows.go b/examples/product_onboarding/rows.go new file mode 100644 index 0000000000..1b74714636 --- /dev/null +++ b/examples/product_onboarding/rows.go @@ -0,0 +1,14 @@ +package onboarding + +// instanceRow is the output struct for `example list`. When passed to +// ctx.PrintList in table mode, the exported field NAMES become the column +// headers, in declaration order. Keep the set small and human-meaningful: this +// is the at-a-glance view, not the full resource dump (that is `describe`). +type instanceRow struct { + ResourceID string + Name string + Zone string + Mode string + Spec string + Status string +} diff --git a/examples/product_onboarding/start.go b/examples/product_onboarding/start.go new file mode 100644 index 0000000000..fad82098ef --- /dev/null +++ b/examples/product_onboarding/start.go @@ -0,0 +1,63 @@ +package onboarding + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStart implements `example start`. +func newStart(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewStartUDBInstanceRequest() + + var ids []string + var async bool + + cmd := &cobra.Command{ + Use: "start", + Short: "Start example instances", + Long: "Start one or more stopped example instances.", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + req.DBId = sdk.String(id) + if _, err := client.StartUDBInstance(req); err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("%s[%s] is starting", productName, id) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx)).Spoll(id, text, []string{stateRunning, stateFail}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "start", Status: "Starting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. Resource ID(s) of instances to start.") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the operation to finish.") + ctx.BindCommonParams(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + // Only stopped instances are startable. + return listResourceIDs(ctx, []string{stateShutoff}, derefStr(req.Region), derefStr(req.Zone), derefStr(req.ProjectId)) + }) + + return cmd +} diff --git a/examples/product_onboarding/status.go b/examples/product_onboarding/status.go new file mode 100644 index 0000000000..14b4a6419b --- /dev/null +++ b/examples/product_onboarding/status.go @@ -0,0 +1,10 @@ +package onboarding + +// Terminal states a Poller waits on. A real product imports these from its own +// status table; the example defines them locally so it depends only on the +// platform packages and the SDK, never on another product's internals. +const ( + stateRunning = "Running" + stateShutoff = "Shutoff" + stateFail = "Fail" +) diff --git a/examples/product_onboarding/stop.go b/examples/product_onboarding/stop.go new file mode 100644 index 0000000000..4ccd17a0a5 --- /dev/null +++ b/examples/product_onboarding/stop.go @@ -0,0 +1,65 @@ +package onboarding + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStop implements `example stop`. +func newStop(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewStopUDBInstanceRequest() + + var ids []string + var async bool + + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop example instances", + Long: "Stop one or more running example instances.", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + req.DBId = sdk.String(id) + if _, err := client.StopUDBInstance(req); err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("%s[%s] is stopping", productName, id) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx)).Spoll(id, text, []string{stateShutoff, stateFail}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "stop", Status: "Stopping"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. Resource ID(s) of instances to stop.") + req.ForceToKill = flags.Bool("force", false, "Optional. Force-stop the instance(s).") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the operation to finish.") + ctx.BindCommonParams(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetFlagValues(cmd, "force", "true", "false") + command.SetCompletion(cmd, resourceIDFlag, func() []string { + // Only running instances are stoppable. + return listResourceIDs(ctx, []string{stateRunning}, derefStr(req.Region), derefStr(req.Zone), derefStr(req.ProjectId)) + }) + + return cmd +} diff --git a/go.mod b/go.mod index c6546acb02..e801bcced5 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,27 @@ module github.com/ucloud/ucloud-cli -go 1.12 +go 1.25.0 require ( - github.com/kr/pretty v0.1.0 // indirect + github.com/fatih/color v1.13.0 + github.com/gofrs/flock v0.8.1 + github.com/mattn/go-isatty v0.0.14 github.com/satori/go.uuid v1.2.0 - github.com/sirupsen/logrus v1.3.0 - github.com/spf13/cobra v0.0.3 - github.com/spf13/pflag v1.0.3 - github.com/ucloud/ucloud-sdk-go v0.13.2 - golang.org/x/sys v0.0.0-20190412213103-97732733099d - gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + github.com/sirupsen/logrus v1.8.3 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/ucloud/ucloud-sdk-go v0.22.96 + gopkg.in/yaml.v2 v2.2.8 ) -replace ( - github.com/spf13/cobra v0.0.3 => github.com/lixiaojun629/cobra v0.0.9 - github.com/spf13/pflag v1.0.3 => github.com/lixiaojun629/pflag v1.0.5 +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/mattn/go-colorable v0.1.9 // indirect + github.com/pkg/errors v0.8.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.45.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) diff --git a/go.sum b/go.sum index cb01cf568d..3892e1726a 100644 --- a/go.sum +++ b/go.sum @@ -1,84 +1,68 @@ -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/coreos/etcd v3.3.10+incompatible h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04= -github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= -github.com/coreos/go-semver v0.2.0 h1:3Jm3tLmsgAYcjC+4Up7hJrFBPr+n7rAqYeSw/SZazuY= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= -github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/lixiaojun629/cobra v0.0.9 h1:wk3qMaSJ/wpQMfGiaj+m6tV+Qp5gxtIIlabF3wG7WHo= -github.com/lixiaojun629/cobra v0.0.9/go.mod h1:6VKYqzoixuRlMBmzm3rHPS0sRYVhT3zXEfrt+Qf8eMs= -github.com/lixiaojun629/pflag v1.0.5 h1:plFJ2SBJd2S2Fc7ZwwFZ3682IvxBiUkhRuJS40OvEMs= -github.com/lixiaojun629/pflag v1.0.5/go.mod h1:uchrjsiFxJj1XOBpO4YJCZwpqXHsCHovxY91tyFoUrg= -github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= -github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= -github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME= github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/sirupsen/logrus v1.8.3 h1:DBBfY8eMYazKEJHb3JKpSPfpgd2mBCoNFlQx6C5fftU= +github.com/sirupsen/logrus v1.8.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/ucloud/ucloud-sdk-go v0.13.2 h1:KA7hMx2+E02p8ujw80xMPFfAvApnVUNHRyt3awc0HxQ= -github.com/ucloud/ucloud-sdk-go v0.13.2/go.mod h1:dyLmFHmUfgb4RZKYQP9IArlvQ2pxzFthfhwxRzOEPIw= -github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8 h1:3SVOIvH7Ae1KRYyQWRjXWJEA9sS/c/pjvH++55Gr648= -github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/ucloud/ucloud-sdk-go v0.22.96 h1:efyqknRMEaoik6lH1g3DqO0XBipz4xhurGAXcQcDc2Q= +github.com/ucloud/ucloud-sdk-go v0.22.96/go.mod h1:dyLmFHmUfgb4RZKYQP9IArlvQ2pxzFthfhwxRzOEPIw= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33 h1:I6FyU15t786LL7oL/hn43zqTuEGr4PN7F4XJ1p4E3Y8= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a h1:1n5lsVfiQW3yfsRGu98756EH1YthsFqr/5mxHduZW2A= -golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hack/check-product/main.go b/hack/check-product/main.go new file mode 100644 index 0000000000..a8a7a8477f --- /dev/null +++ b/hack/check-product/main.go @@ -0,0 +1,640 @@ +// hack/check-product enforces product-module boundaries by statically +// analysing the products/ tree. +// +// Run from repo root: +// +// go run ./hack/check-product +// +// Exit 0 means all rules passed. Exit non-zero means one or more violations +// were printed to stdout. +// +// # Design note +// +// This tool uses the standard library (go/parser + go/ast + go/token) rather +// than golang.org/x/tools/go/packages, so it adds NO new module dependency. +// Import-level rules are fully covered by the AST; type resolution is not +// needed for the patterns we flag. +// +// # Rules +// +// 1. No cross-product imports: a file under products/A/... must not import +// github.com/ucloud/ucloud-cli/products/B (for any B != A). +// 2. No platform-internal or legacy imports: product files must not import +// github.com/ucloud/ucloud-cli/cmd, .../base, .../ux, or .../ansi. +// 3. No bare SDK NewClient calls (best-effort AST): flag svc.NewClient(...) +// where svc is not the identifier "cli" (products must use +// cli.NewServiceClient). +// 4. No raw completion API calls: flag selector calls whose method name is +// SetFlagValuesFunc, GetFlagValuesFunc, GetFlagValues, or SetFlagValues +// (when the receiver is not "command"). +// 5. product.yaml consistency: every enabled product whose dir is absent on +// disk emits a WARNING (not a failure). Every directory under products/ +// that has no product.yaml is a VIOLATION. +// 6. Reserved command names: no product.yaml may declare a top-level +// command name that the platform itself registers (see reservedCommands). +// A product declaring e.g. "config" would silently shadow the platform +// command, so it is a VIOLATION. +// 7. Cross-product command uniqueness: no two enabled products may declare the +// same top-level command name (cobra AddCommand silently shadows duplicates). +// 8. Commands consistency: each enabled product's product.go Metadata().Commands +// must match its product.yaml `commands` (order-independent). +// 9. §6.1 import whitelist: product files may import only stdlib, +// ucloud-sdk-go, spf13/cobra|pflag, pkg/cli|command|ui, internal/common, +// and their own product subtree. Anything else (model/*, ux/, new +// third-party deps) is a violation; extending the list is a platform PR. +// 10. §2 file layout: (a) grab-bag basenames (helpers.go, utils.go, util.go, +// common.go, misc.go, and their _test.go variants) are forbidden under +// products/ — name files by verb or concern; (b) each non-test .go file +// may declare at most one top-level function (methods included) returning +// *cobra.Command: one verb per file. +package main + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "gopkg.in/yaml.v2" +) + +const moduleRoot = "github.com/ucloud/ucloud-cli" + +// Product mirrors the products.yaml entry. +type Product struct { + Name string `yaml:"name"` + Dir string `yaml:"-"` // 从 product.yaml 路径推断,不从文件读 + Owners []string `yaml:"owners"` + Commands []string `yaml:"commands"` + Enabled bool `yaml:"enabled"` +} + +// loadProducts scans products/*/product.yaml and returns the products in +// deterministic (path-sorted) order. Dir is derived from each file's directory. +func loadProducts() ([]Product, error) { + matches, err := filepath.Glob("products/*/product.yaml") + if err != nil { + return nil, err + } + sort.Strings(matches) + var products []Product + for _, path := range matches { + raw, readErr := os.ReadFile(path) + if readErr != nil { + return nil, fmt.Errorf("read %s: %w", path, readErr) + } + var p Product + if uErr := yaml.Unmarshal(raw, &p); uErr != nil { + return nil, fmt.Errorf("parse %s: %w", path, uErr) + } + p.Dir = filepath.Dir(path) + products = append(products, p) + } + return products, nil +} + +// rawCompletion methods that must go through command.*; flagging them is +// best-effort — we match the selector method name. +var rawCompletionMethods = map[string]bool{ + "SetFlagValuesFunc": true, + "GetFlagValuesFunc": true, + "GetFlagValues": true, + // SetFlagValues is only flagged when the receiver is NOT "command". + "SetFlagValues": true, +} + +// ---- Rule 9: §6.1 product import whitelist ------------------------------- +// A product file may import ONLY: the Go standard library; the platform +// contract packages pkg/cli, pkg/command, pkg/ui; internal/common +// (domain-agnostic pure tools, open to products); the UCloud SDK (incl. +// private/); the cobra flag stack; and its own product subtree. Everything +// else — other module-internal packages (model/*, ux/, ...) and any NEW +// third-party dependency — is a violation. Extending this list is a platform +// PR by design: it is the gate that keeps go.mod out of product PRs. +// +// allowedModulePackages: exact import paths — subpackages need their own +// entry (platform PR). +var allowedModulePackages = map[string]bool{ + moduleRoot + "/pkg/cli": true, + moduleRoot + "/pkg/command": true, + moduleRoot + "/pkg/ui": true, + moduleRoot + "/internal/common": true, +} + +// allowedThirdParty: prefix match — subpackages allowed. +var allowedThirdParty = []string{ + "github.com/ucloud/ucloud-sdk-go", + "github.com/spf13/cobra", + "github.com/spf13/pflag", +} + +// importAllowed reports whether importPath is inside the §6.1 whitelist for +// files belonging to productName. +func importAllowed(importPath, productName string) bool { + first := importPath + if idx := strings.Index(importPath, "/"); idx >= 0 { + first = importPath[:idx] + } + if !strings.Contains(first, ".") { + return true // standard library + } + if allowedModulePackages[importPath] { + return true + } + self := moduleRoot + "/products/" + productName + if importPath == self || strings.HasPrefix(importPath, self+"/") { + return true + } + for _, p := range allowedThirdParty { + if importPath == p || strings.HasPrefix(importPath, p+"/") { + return true + } + } + return false +} + +// ---- Rule 10a: forbidden grab-bag filenames -------------------------------- +// §2 names product files by verb or concern (list.go, rows.go, completion.go, +// describe.go/poll.go, status.go, ...). Grab-bag basenames defeat that layout, +// so they are forbidden under products/ — including their _test.go variants +// (a grab-bag test file is the same smell). +var grabBagBasenames = map[string]bool{ + "helpers": true, + "utils": true, + "util": true, + "common": true, + "misc": true, +} + +// checkFilename enforces rule 10a on a single path under products/. It is a +// pure path check (no parsing) kept separate from checkFile so it can be +// unit-tested with arbitrary paths. +func checkFilename(path string) []string { + base := filepath.Base(path) + if !strings.HasSuffix(base, ".go") { + return nil + } + stem := strings.TrimSuffix(base, ".go") + stem = strings.TrimSuffix(stem, "_test") + if grabBagBasenames[stem] { + return []string{fmt.Sprintf( + "rule10: grab-bag filename %q is forbidden under products/ (name files by verb or concern: .go, rows.go, completion.go, describe.go/poll.go, status.go)", + path)} + } + return nil +} + +// ---- Rule 10b: one cobra constructor per file ------------------------------- + +// returnsCobraCommand reports whether the function signature's result list +// includes a *cobra.Command. Matching is by AST shape — a StarExpr over a +// SelectorExpr whose Sel is "Command" — regardless of the import alias or +// package qualifier: the only *X.Command pointer type used in this codebase +// is cobra's (verified by grep across cmd/, products/, pkg/, internal/, base/, +// ux/, model/), so selector-name matching is sufficient and alias-proof. +func returnsCobraCommand(ft *ast.FuncType) bool { + if ft == nil || ft.Results == nil { + return false + } + for _, field := range ft.Results.List { + star, ok := field.Type.(*ast.StarExpr) + if !ok { + continue + } + if sel, ok := star.X.(*ast.SelectorExpr); ok && sel.Sel.Name == "Command" { + return true + } + } + return false +} + +// checkFile parses the Go file at path (which lives under +// products//…) and returns one string per violation. +// +// productName is the immediate subdirectory name under products/ +// (e.g. "udb"), used to distinguish intra-product imports from cross-product +// imports. +func checkFile(path, productName string) []string { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return []string{fmt.Sprintf("%s: parse error: %v", path, err)} + } + + var violations []string + + pos := func(node ast.Node) string { + p := fset.Position(node.Pos()) + return fmt.Sprintf("%s:%d", p.Filename, p.Line) + } + + // ---- Rules 1, 2 & 9: import paths -------------------------------------- + productsPrefix := moduleRoot + "/products/" + for _, imp := range f.Imports { + if imp.Path == nil { + continue + } + // Strip surrounding quotes. + importPath := strings.Trim(imp.Path.Value, `"`) + + // Shared territory booleans: rules 1-2 and rule 9's carve-out must + // stay in sync, so classify each import path exactly once. + isCmdImport := importPath == moduleRoot+"/cmd" || + strings.HasPrefix(importPath, moduleRoot+"/cmd/") + legacyPlatformPackage := "" + for _, name := range []string{"base", "ux", "ansi"} { + prefix := moduleRoot + "/" + name + if importPath == prefix || strings.HasPrefix(importPath, prefix+"/") { + legacyPlatformPackage = name + break + } + } + isProductsImport := strings.HasPrefix(importPath, productsPrefix) + + // Rule 2: no platform-internal or legacy imports. + if isCmdImport { + violations = append(violations, + fmt.Sprintf("%s: rule2: product must not import cmd package %q", + pos(imp.Path), importPath)) + } + if legacyPlatformPackage != "" { + violations = append(violations, + fmt.Sprintf("%s: rule2: product must not import legacy %s package %q", + pos(imp.Path), legacyPlatformPackage, importPath)) + } + + // Rule 1: no cross-product imports. + if isProductsImport { + // e.g. "github.com/ucloud/ucloud-cli/products/vpc/something" + // → rest = "vpc/something" + rest := strings.TrimPrefix(importPath, productsPrefix) + // other product = everything before the first "/" + otherProduct := rest + if idx := strings.Index(rest, "/"); idx >= 0 { + otherProduct = rest[:idx] + } + if otherProduct != productName { + violations = append(violations, + fmt.Sprintf("%s: rule1: product %q must not import sibling product %q (import %q)", + pos(imp.Path), productName, otherProduct, importPath)) + } + } + + // Rule 9: §6.1 whitelist. cmd, legacy platform, and products/ prefixes are owned by + // rules 1-2 above (more specific messages) — rule 9 covers the rest. + isRule12Territory := isCmdImport || legacyPlatformPackage != "" || isProductsImport + if !isRule12Territory && !importAllowed(importPath, productName) { + violations = append(violations, + fmt.Sprintf("%s: rule9: import %q is outside the §6.1 product import whitelist (allowed: stdlib, ucloud-sdk-go, spf13/cobra|pflag, pkg/cli|command|ui, internal/common, own product); extending the whitelist is a platform PR", + pos(imp.Path), importPath)) + } + } + + // ---- Rules 3 & 4: AST call-expression walk ---------------------------- + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + recv, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + method := sel.Sel.Name + receiver := recv.Name + + // Rule 3: bare SDK NewClient calls. + // Flag svc.NewClient(...) unless svc == "cli". + if method == "NewClient" && receiver != "cli" { + violations = append(violations, + fmt.Sprintf("%s: rule3: direct %s.NewClient() call; use cli.NewServiceClient(ctx, %s.NewClient) instead", + pos(call), receiver, receiver)) + } + + // Rule 4: raw completion API. + if rawCompletionMethods[method] { + // SetFlagValues is only forbidden when NOT called as command.SetFlagValues. + if method == "SetFlagValues" && receiver == "command" { + return true + } + // All other raw-completion methods are forbidden unconditionally. + violations = append(violations, + fmt.Sprintf("%s: rule4: raw completion call %s.%s(); use command.* wrappers instead", + pos(call), receiver, method)) + } + + return true + }) + + // ---- Rule 10b: at most one cobra constructor per file ------------------ + // §2「one verb per file」: a non-test file may declare at most one + // top-level function whose results include *cobra.Command. Methods count + // too (a method returning *cobra.Command is still a constructor); FuncLit + // closures inside bodies are naturally excluded because only f.Decls is + // scanned. + if !strings.HasSuffix(filepath.Base(path), "_test.go") { + constructors := 0 + for _, decl := range f.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && returnsCobraCommand(fn.Type) { + constructors++ + } + } + if constructors > 1 { + violations = append(violations, + fmt.Sprintf("rule10: %s declares %d cobra-constructor functions; §2 allows at most one per file (one verb per file; move extras to their own .go)", + path, constructors)) + } + } + + return violations +} + +// checkConsistency (rule5): every immediate subdirectory of products/ must have +// a product.yaml (i.e. appear in the scanned products list). +func checkConsistency(products []Product, dirs []string) (violations, warnings []string) { + hasYAML := make(map[string]bool, len(products)) + for _, p := range products { + hasYAML[filepath.Base(p.Dir)] = true + } + for _, d := range dirs { + if !hasYAML[d] { + violations = append(violations, + fmt.Sprintf("products/%s: rule5: directory has no product.yaml", d)) + } + } + return violations, warnings +} + +// reservedCommands is the set of PLATFORM-RESERVED top-level command names. +// A product must not declare any of these in its products.yaml `commands`, +// because doing so would silently shadow the platform's own command. +// +// MUST track the platform commands registered in cmd/root.go: +// addPlatformCommands (the root.AddCommand(NewCmd*()) calls and newSchemaCmd), +// plus the root-level pseudo-commands wired in NewCmdRoot (completion, signup). +// When a platform command is added/renamed/removed in cmd/root.go, update this +// set to match. +var reservedCommands = map[string]bool{ + // addPlatformCommands (cmd/root.go), in registration order: + "init": true, // NewCmdInit + "auth": true, // NewCmdAuth + "gendoc": true, // NewCmdDoc (doc-gen command, Use: "gendoc") + "config": true, // NewCmdConfig + "region": true, // NewCmdRegion + "project": true, // NewCmdProject + // uhost migrated to products/uhost (Part 6) — no longer platform-reserved. + "api": true, // NewCmdAPI + "signature": true, // NewCmdSignature + "__schema": true, // newSchemaCmd (hidden) + // Root-level pseudo-commands wired in NewCmdRoot (cmd/root.go): + "completion": true, // NewCmdCompletion + "signup": true, // NewCmdSignup +} + +// checkReservedCommands verifies that no product declares a top-level command +// name that collides with a platform-reserved name (see reservedCommands). +// Returns one violation string per offending (product, command) pair. +func checkReservedCommands(yamlProducts []Product) []string { + var violations []string + for _, p := range yamlProducts { + for _, c := range p.Commands { + if reservedCommands[c] { + violations = append(violations, + fmt.Sprintf("rule6: product %q declares reserved platform command %q", p.Name, c)) + } + } + } + return violations +} + +// checkCommandCollisions verifies that no two ENABLED products declare the same +// top-level command name (设计 §6.3「一级命令无冲突」, rule7). cobra's AddCommand +// does not panic on duplicate names — the later registration silently shadows the +// earlier — so this static gate is the only thing that catches a collision once +// multiple products live under products/. +func checkCommandCollisions(yamlProducts []Product) []string { + // command name -> products declaring it (enabled only) + owners := make(map[string][]string) + for _, p := range yamlProducts { + if !p.Enabled { + continue + } + for _, c := range p.Commands { + owners[c] = append(owners[c], p.Name) + } + } + + // Deterministic output: iterate command names in sorted order. + cmds := make([]string, 0, len(owners)) + for c := range owners { + cmds = append(cmds, c) + } + sort.Strings(cmds) + + var violations []string + for _, c := range cmds { + if len(owners[c]) > 1 { + violations = append(violations, + fmt.Sprintf("rule7: top-level command %q declared by multiple products %v (command names must be unique across products)", + c, owners[c])) + } + } + return violations +} + +func main() { + var allViolations []string + var allWarnings []string + + // ---- Load products from products/*/product.yaml ----------------------- + products, err := loadProducts() + if err != nil { + fmt.Fprintf(os.Stderr, "check-product: load products: %v\n", err) + os.Exit(2) + } + + // ---- Discover products/ dirs ------------------------------------------ + productsRoot := "products" + var foundDirs []string + + entries, err := os.ReadDir(productsRoot) + if err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "check-product: read products/: %v\n", err) + os.Exit(2) + } + // If products/ doesn't exist, entries is nil/empty → graceful. + for _, e := range entries { + if e.IsDir() { + foundDirs = append(foundDirs, e.Name()) + } + } + + // ---- Rule 5: consistency check ---------------------------------------- + v5, w5 := checkConsistency(products, foundDirs) + allViolations = append(allViolations, v5...) + allWarnings = append(allWarnings, w5...) + + // ---- Rule 6: reserved platform command names -------------------------- + allViolations = append(allViolations, checkReservedCommands(products)...) + + // ---- Rule 7: cross-product command-name uniqueness -------------------- + allViolations = append(allViolations, checkCommandCollisions(products)...) + + // ---- Rule 8: product.go Metadata commands ↔ products.yaml ------------- + allViolations = append(allViolations, checkCommandsConsistency(products)...) + + // ---- Rules 1–4 & 10: walk every .go file under products/ -------------- + if _, statErr := os.Stat(productsRoot); statErr == nil { + walkErr := filepath.Walk(productsRoot, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + + // Determine which product this file belongs to. + // path is like "products/mysql/internal/mysql/cmd.go" + rel := strings.TrimPrefix(path, productsRoot+string(filepath.Separator)) + parts := strings.SplitN(rel, string(filepath.Separator), 2) + productName := parts[0] + + // Rule 10a is a per-path check; no parsing needed. + allViolations = append(allViolations, checkFilename(path)...) + allViolations = append(allViolations, checkFile(path, productName)...) + return nil + }) + if walkErr != nil { + fmt.Fprintf(os.Stderr, "check-product: walk products/: %v\n", walkErr) + os.Exit(2) + } + } + + // ---- Report ------------------------------------------------------------ + for _, w := range allWarnings { + fmt.Println(w) + } + + if len(allViolations) == 0 { + fmt.Println("check-product: all boundary rules passed.") + os.Exit(0) + } + + for _, v := range allViolations { + fmt.Println(v) + } + fmt.Fprintf(os.Stderr, "check-product: %d violation(s) found.\n", len(allViolations)) + os.Exit(1) +} + +// extractMetadataCommands statically extracts the string slice passed as the +// Commands field of the cli.Metadata composite literal returned by the +// Metadata() method in /product.go. Returns (nil, nil) if there is +// no Metadata method or no Commands field (设计 §6.3「命令声明与 products.yaml 一致」). +func extractMetadataCommands(productDir string) ([]string, error) { + path := filepath.Join(productDir, "product.go") + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return nil, err + } + + var cmds []string + found := false + ast.Inspect(f, func(n ast.Node) bool { + fn, ok := n.(*ast.FuncDecl) + if !ok || fn.Recv == nil || fn.Name.Name != "Metadata" || fn.Body == nil { + return true + } + ast.Inspect(fn.Body, func(m ast.Node) bool { + kv, ok := m.(*ast.KeyValueExpr) + if !ok { + return true + } + key, ok := kv.Key.(*ast.Ident) + if !ok || key.Name != "Commands" { + return true + } + lit, ok := kv.Value.(*ast.CompositeLit) + if !ok { + return true + } + for _, elt := range lit.Elts { + if bl, ok := elt.(*ast.BasicLit); ok && bl.Kind == token.STRING { + if s, uerr := strconv.Unquote(bl.Value); uerr == nil { + cmds = append(cmds, s) + } + } + } + found = true + return false + }) + return true + }) + if !found { + return nil, nil + } + return cmds, nil +} + +// sameStringSet reports whether a and b contain the same elements (order-independent). +func sameStringSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + seen := make(map[string]int, len(a)) + for _, s := range a { + seen[s]++ + } + for _, s := range b { + seen[s]-- + } + for _, n := range seen { + if n != 0 { + return false + } + } + return true +} + +// checkCommandsConsistency verifies that each enabled product whose dir exists +// on disk declares the same Commands in product.go's Metadata() as in +// products.yaml (设计 §6.3, rule8). Skips products whose dir is not yet present +// (pre-F state). Order-independent comparison. +func checkCommandsConsistency(yamlProducts []Product) []string { + var violations []string + for _, p := range yamlProducts { + if !p.Enabled { + continue + } + if _, statErr := os.Stat(p.Dir); os.IsNotExist(statErr) { + continue + } + meta, err := extractMetadataCommands(p.Dir) + if err != nil { + violations = append(violations, + fmt.Sprintf("rule8: %s: cannot parse product.go: %v", p.Dir, err)) + continue + } + if meta == nil { + violations = append(violations, + fmt.Sprintf("rule8: product %q: product.go has no Metadata().Commands to verify against products.yaml", p.Name)) + continue + } + if !sameStringSet(p.Commands, meta) { + violations = append(violations, + fmt.Sprintf("rule8: product %q commands mismatch: products.yaml=%v vs product.go Metadata()=%v", + p.Name, p.Commands, meta)) + } + } + return violations +} diff --git a/hack/check-product/main_test.go b/hack/check-product/main_test.go new file mode 100644 index 0000000000..d1f21bcdec --- /dev/null +++ b/hack/check-product/main_test.go @@ -0,0 +1,704 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// writeFile creates a file at dir/name with content, creating parent dirs. +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return path +} + +// -------------------------------------------------------------------------- +// checkFile tests +// -------------------------------------------------------------------------- + +func TestCheckFile_Rule1_CrossProductImport(t *testing.T) { + dir := t.TempDir() + src := `package cmd + +import ( + "github.com/ucloud/ucloud-cli/products/vpc" +) + +var _ = vpc.Foo +` + path := writeFile(t, dir, "udb/cmd.go", src) + got := checkFile(path, "udb") + if len(got) == 0 { + t.Fatal("expected violation for cross-product import, got none") + } + if !strings.Contains(got[0], "rule1") { + t.Errorf("expected rule1 in violation, got: %v", got) + } +} + +func TestCheckFile_Rule1_SameProductImport_Clean(t *testing.T) { + dir := t.TempDir() + // Importing within the same product is allowed. + src := `package cmd + +import ( + _ "github.com/ucloud/ucloud-cli/products/mysql/internal/helper" +) +` + path := writeFile(t, dir, "mysql/cmd.go", src) + got := checkFile(path, "mysql") + for _, v := range got { + if strings.Contains(v, "rule1") { + t.Errorf("unexpected rule1 violation for same-product import: %v", v) + } + } +} + +func TestCheckFile_Rule2_CmdImport(t *testing.T) { + dir := t.TempDir() + src := `package cmd + +import ( + "github.com/ucloud/ucloud-cli/cmd" +) + +var _ = cmd.Root +` + path := writeFile(t, dir, "udb/cmd.go", src) + got := checkFile(path, "udb") + if len(got) == 0 { + t.Fatal("expected violation for cmd import, got none") + } + if !strings.Contains(got[0], "rule2") { + t.Errorf("expected rule2 in violation, got: %v", got) + } +} + +func TestCheckFile_Rule2_BaseImport(t *testing.T) { + dir := t.TempDir() + src := "package cmd\n\n" + + "import (\n" + + "\t\"" + moduleRoot + "/base\"\n" + + ")\n\n" + + "var _ = base.Foo\n" + + path := writeFile(t, dir, "udb/cmd.go", src) + got := checkFile(path, "udb") + if len(got) == 0 { + t.Fatal("expected violation for base import, got none") + } + if !strings.Contains(got[0], "rule2") { + t.Errorf("expected rule2 in violation, got: %v", got) + } +} + +func TestCheckFile_Rule2_LegacyPlatformImports(t *testing.T) { + cases := []struct { + name string + src string + }{ + { + name: "ux", + src: "package cmd\n\nimport \"" + moduleRoot + "/ux\"\n\nvar _ = ux.Doc\n", + }, + { + name: "ansi", + src: "package cmd\n\nimport \"" + moduleRoot + "/ansi\"\n\nvar _ = ansi.CursorLeft\n", + }, + { + name: "cmd/internal", + src: `package cmd + +import "github.com/ucloud/ucloud-cli/cmd/internal/runtime" + +var _ = runtime.Active +`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := writeFile(t, t.TempDir(), "udb/cmd.go", tc.src) + got := checkFile(path, "udb") + if len(got) == 0 { + t.Fatalf("expected violation for %s import, got none", tc.name) + } + if !strings.Contains(got[0], "rule2") { + t.Errorf("expected rule2 in violation, got: %v", got) + } + }) + } +} + +func TestCheckFile_Rule3_BareNewClient(t *testing.T) { + dir := t.TempDir() + src := `package cmd + +import "github.com/ucloud/ucloud-sdk-go/services/udb" + +func setup() { + client := udb.NewClient(nil) + _ = client +} +` + path := writeFile(t, dir, "udb/cmd.go", src) + got := checkFile(path, "udb") + found := false + for _, v := range got { + if strings.Contains(v, "rule3") { + found = true + break + } + } + if !found { + t.Fatalf("expected rule3 violation for udb.NewClient(), got: %v", got) + } +} + +func TestCheckFile_Rule3_CliNewServiceClient_Clean(t *testing.T) { + dir := t.TempDir() + // cli.NewServiceClient is explicitly allowed. + src := `package cmd + +import ( + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-sdk-go/services/udb" +) + +func setup(ctx *cli.Context) { + client := cli.NewServiceClient(ctx, udb.NewClient) + _ = client +} +` + path := writeFile(t, dir, "udb/cmd.go", src) + got := checkFile(path, "udb") + for _, v := range got { + if strings.Contains(v, "rule3") { + t.Errorf("unexpected rule3 violation: %v", v) + } + } +} + +func TestCheckFile_Rule4_SetFlagValuesFunc(t *testing.T) { + dir := t.TempDir() + src := `package cmd + +func setup(f someFlag) { + f.SetFlagValuesFunc(func() []string { return nil }) +} +` + path := writeFile(t, dir, "udb/cmd.go", src) + got := checkFile(path, "udb") + found := false + for _, v := range got { + if strings.Contains(v, "rule4") && strings.Contains(v, "SetFlagValuesFunc") { + found = true + break + } + } + if !found { + t.Fatalf("expected rule4 violation for SetFlagValuesFunc, got: %v", got) + } +} + +func TestCheckFile_Rule4_GetFlagValues(t *testing.T) { + dir := t.TempDir() + src := `package cmd + +func setup(f someFlag) []string { + return f.GetFlagValues() +} +` + path := writeFile(t, dir, "udb/cmd.go", src) + got := checkFile(path, "udb") + found := false + for _, v := range got { + if strings.Contains(v, "rule4") && strings.Contains(v, "GetFlagValues") { + found = true + break + } + } + if !found { + t.Fatalf("expected rule4 violation for GetFlagValues, got: %v", got) + } +} + +func TestCheckFile_Rule4_SetFlagValues_CommandReceiver_Clean(t *testing.T) { + dir := t.TempDir() + // command.SetFlagValues is the allowed wrapper — must not be flagged. + src := `package cmd + +import "github.com/ucloud/ucloud-cli/pkg/command" + +func setup(f someFlag) { + command.SetFlagValues(f, []string{"a", "b"}) +} +` + path := writeFile(t, dir, "udb/cmd.go", src) + got := checkFile(path, "udb") + for _, v := range got { + if strings.Contains(v, "rule4") { + t.Errorf("unexpected rule4 violation for command.SetFlagValues: %v", v) + } + } +} + +func TestCheckFile_Clean(t *testing.T) { + dir := t.TempDir() + src := `package cmd + +import ( + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-sdk-go/services/udb" +) + +func setup(ctx *cli.Context, f someFlag) { + client := cli.NewServiceClient(ctx, udb.NewClient) + command.SetFlagValues(f, []string{"a", "b"}) + _ = client +} +` + path := writeFile(t, dir, "udb/cmd.go", src) + got := checkFile(path, "udb") + if len(got) != 0 { + t.Errorf("expected no violations for clean file, got: %v", got) + } +} + +// -------------------------------------------------------------------------- +// checkConsistency tests +// -------------------------------------------------------------------------- + +func TestCheckConsistency_DirWithoutYAML_Violation(t *testing.T) { + products := []Product{{Name: "mysql", Dir: "products/mysql", Enabled: true}} + dirs := []string{"mysql", "mystery"} // mystery 无 product.yaml + violations, _ := checkConsistency(products, dirs) + found := false + for _, v := range violations { + if strings.Contains(v, "mystery") && strings.Contains(v, "rule5") { + found = true + } + } + if !found { + t.Fatalf("expected rule5 violation for dir without product.yaml, got: %v", violations) + } +} + +func TestCheckConsistency_AllHaveYAML_Clean(t *testing.T) { + products := []Product{{Name: "mysql", Dir: "products/mysql", Enabled: true}} + dirs := []string{"mysql"} + violations, _ := checkConsistency(products, dirs) + if len(violations) != 0 { + t.Errorf("expected no violations, got: %v", violations) + } +} + +func TestLoadProducts(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "products/mysql/product.yaml", "name: mysql\nowners: [Episkey-G]\ncommands: [mysql]\nenabled: true\n") + t.Chdir(dir) + got, err := loadProducts() + if err != nil { + t.Fatalf("loadProducts: %v", err) + } + if len(got) != 1 || got[0].Name != "mysql" || got[0].Dir != "products/mysql" || len(got[0].Commands) != 1 { + t.Fatalf("unexpected: %+v", got) + } +} + +// -------------------------------------------------------------------------- +// checkReservedCommands tests +// -------------------------------------------------------------------------- + +func TestCheckReservedCommands_ReservedName_Violation(t *testing.T) { + // A product declaring the platform-reserved "config" command must violate. + products := []Product{ + {Name: "rogue", Dir: "products/rogue", Commands: []string{"config"}, Enabled: true}, + } + + violations := checkReservedCommands(products) + + found := false + for _, v := range violations { + if strings.Contains(v, "rule6") && + strings.Contains(v, "rogue") && + strings.Contains(v, "config") { + found = true + break + } + } + if !found { + t.Fatalf("expected rule6 violation for reserved command 'config', got: %v", violations) + } +} + +func TestCheckReservedCommands_RealRegistry_Clean(t *testing.T) { + // The real registry declares no reserved name → clean. + products := []Product{ + {Name: "mysql", Dir: "products/mysql", Commands: []string{"mysql"}, Enabled: true}, + } + + violations := checkReservedCommands(products) + if len(violations) != 0 { + t.Errorf("expected no violations for clean registry, got: %v", violations) + } +} + +func TestReservedCommands_ExcludesStaleBandwidthImplementationName(t *testing.T) { + if reservedCommands["bandwidth"] { + t.Fatal("reservedCommands must not include stale implementation name \"bandwidth\"") + } +} + +func TestReservedCommands_ExcludesMigratedUDPNProduct(t *testing.T) { + if reservedCommands["udpn"] { + t.Fatal("reservedCommands must not include udpn after it migrates to products/udpn") + } +} + +func TestReservedCommands_ExcludesMigratedGSSHProductCommand(t *testing.T) { + if reservedCommands["gssh"] { + t.Fatal("reservedCommands must not include gssh after it migrates to products/globalssh") + } +} + +func TestReservedCommands_ExcludesMigratedPathXProductCommand(t *testing.T) { + if reservedCommands["pathx"] { + t.Fatal("reservedCommands must not include pathx after it migrates to products/pathx") + } +} + +func TestReservedCommands_ExcludesMigratedBandwidthProductCommand(t *testing.T) { + if reservedCommands["bw"] { + t.Fatal("reservedCommands must not include bw after it migrates to products/sharedbw") + } +} + +func TestReservedCommands_ExcludesMigratedRedisProductCommand(t *testing.T) { + if reservedCommands["redis"] { + t.Fatal("reservedCommands must not include redis after it migrates to products/redis") + } +} + +func TestReservedCommands_ExcludesMigratedMemcacheProductCommand(t *testing.T) { + if reservedCommands["memcache"] { + t.Fatal("reservedCommands must not include memcache after it migrates to products/memcache") + } +} + +func TestReservedCommands_ExcludesMigratedULBProductCommand(t *testing.T) { + if reservedCommands["ulb"] { + t.Fatal("reservedCommands must not include ulb after it migrates to products/ulb") + } +} + +func TestReservedCommands_ExcludesMigratedVPCProductCommand(t *testing.T) { + if reservedCommands["vpc"] { + t.Fatal("reservedCommands must not include vpc after it migrates to products/vpc") + } +} + +func TestReservedCommands_ExcludesMigratedSubnetProductCommand(t *testing.T) { + if reservedCommands["subnet"] { + t.Fatal("reservedCommands must not include subnet after it migrates to products/subnet") + } +} + +func TestReservedCommands_ExcludesMigratedExtProductCommand(t *testing.T) { + if reservedCommands["ext"] { + t.Fatal("reservedCommands must not include ext after it migrates to products/eip") + } +} + +// -------------------------------------------------------------------------- +// checkCommandCollisions tests (rule7) +// -------------------------------------------------------------------------- + +func TestCheckCommandCollisions_Duplicate_Violation(t *testing.T) { + products := []Product{ + {Name: "uhost", Dir: "products/uhost", Commands: []string{"uhost"}, Enabled: true}, + {Name: "compute", Dir: "products/compute", Commands: []string{"uhost"}, Enabled: true}, + } + violations := checkCommandCollisions(products) + found := false + for _, v := range violations { + if strings.Contains(v, "rule7") && strings.Contains(v, "uhost") { + found = true + break + } + } + if !found { + t.Fatalf("expected rule7 violation for duplicate command 'uhost', got: %v", violations) + } +} + +func TestCheckCommandCollisions_UniqueRegistry_Clean(t *testing.T) { + products := []Product{ + {Name: "mysql", Dir: "products/mysql", Commands: []string{"mysql"}, Enabled: true}, + {Name: "uhost", Dir: "products/uhost", Commands: []string{"uhost"}, Enabled: true}, + } + violations := checkCommandCollisions(products) + if len(violations) != 0 { + t.Errorf("expected no violations for unique commands, got: %v", violations) + } +} + +func TestCheckCommandCollisions_DisabledIgnored_Clean(t *testing.T) { + // 被禁用产品即便重名也不算冲突(它不会被注册进命令树)。 + products := []Product{ + {Name: "uhost", Dir: "products/uhost", Commands: []string{"uhost"}, Enabled: true}, + {Name: "legacy", Dir: "products/legacy", Commands: []string{"uhost"}, Enabled: false}, + } + violations := checkCommandCollisions(products) + if len(violations) != 0 { + t.Errorf("expected no violations when duplicate is disabled, got: %v", violations) + } +} + +// -------------------------------------------------------------------------- +// rule8: commands consistency (product.go Metadata vs products.yaml) +// -------------------------------------------------------------------------- + +func TestSameStringSet(t *testing.T) { + cases := []struct { + a, b []string + want bool + }{ + {[]string{"mysql"}, []string{"mysql"}, true}, + {[]string{"redis", "memcache"}, []string{"memcache", "redis"}, true}, // order-independent + {[]string{"mysql"}, []string{"mysql", "extra"}, false}, + {[]string{"mysql"}, []string{"redis"}, false}, + {nil, nil, true}, + } + for i, c := range cases { + if got := sameStringSet(c.a, c.b); got != c.want { + t.Errorf("case %d: sameStringSet(%v,%v)=%v want %v", i, c.a, c.b, got, c.want) + } + } +} + +func TestExtractMetadataCommands(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "product.go", `package mysql + +import "github.com/ucloud/ucloud-cli/pkg/cli" + +type product struct{} + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "mysql", Commands: []string{"mysql"}} +} +`) + got, err := extractMetadataCommands(dir) + if err != nil { + t.Fatalf("extractMetadataCommands: %v", err) + } + if len(got) != 1 || got[0] != "mysql" { + t.Fatalf("expected [mysql], got %v", got) + } +} + +func TestCheckCommandsConsistency_Mismatch_Violation(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "products/mysql/product.go", `package mysql + +import "github.com/ucloud/ucloud-cli/pkg/cli" + +type product struct{} + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "mysql", Commands: []string{"mysql", "extra"}} +} +`) + t.Chdir(dir) // Go 1.24+: chdir for this test, auto-restored + products := []Product{ + {Name: "mysql", Dir: "products/mysql", Commands: []string{"mysql"}, Enabled: true}, + } + violations := checkCommandsConsistency(products) + found := false + for _, v := range violations { + if strings.Contains(v, "rule8") && strings.Contains(v, "mysql") { + found = true + break + } + } + if !found { + t.Fatalf("expected rule8 mismatch violation, got: %v", violations) + } +} + +func TestCheckCommandsConsistency_Match_Clean(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "products/mysql/product.go", `package mysql + +import "github.com/ucloud/ucloud-cli/pkg/cli" + +type product struct{} + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "mysql", Commands: []string{"mysql"}} +} +`) + t.Chdir(dir) + products := []Product{ + {Name: "mysql", Dir: "products/mysql", Commands: []string{"mysql"}, Enabled: true}, + } + violations := checkCommandsConsistency(products) + if len(violations) != 0 { + t.Errorf("expected no violations for matching commands, got: %v", violations) + } +} + +// -------------------------------------------------------------------------- +// rule9: §6.1 product import whitelist +// -------------------------------------------------------------------------- + +func TestCheckFile_Rule9_ImportWhitelist(t *testing.T) { + src := `package p + + import ( + _ "fmt" + _ "github.com/spf13/cobra" + _ "github.com/ucloud/ucloud-cli/internal/common" + _ "github.com/ucloud/ucloud-cli/model/status" + _ "github.com/ucloud/ucloud-cli/pkg/cli" + _ "github.com/ucloud/ucloud-cli/products/mysql/internal/mysql" + _ "github.com/ucloud/ucloud-sdk-go/private/services/uhost" + _ "github.com/fatih/color" + ) +` + joined := strings.Join(checkFile(writeFile(t, t.TempDir(), "x.go", src), "mysql"), "\n") + + for _, bad := range []string{ + `rule9: import "github.com/ucloud/ucloud-cli/model/status"`, + `rule9: import "github.com/fatih/color"`, + } { + if !strings.Contains(joined, bad) { + t.Fatalf("missing violation %q in:\n%s", bad, joined) + } + } + for _, ok := range []string{`"fmt"`, `"github.com/spf13/cobra"`, + `"github.com/ucloud/ucloud-cli/internal/common"`, + `"github.com/ucloud/ucloud-cli/pkg/cli"`, + `"github.com/ucloud/ucloud-cli/products/mysql/internal/mysql"`, + `"github.com/ucloud/ucloud-sdk-go/private/services/uhost"`} { + if strings.Contains(joined, "rule9: import "+ok) { + t.Fatalf("false positive on %s in:\n%s", ok, joined) + } + } +} + +func TestCheckFile_Rule9_DoesNotDuplicateRules12(t *testing.T) { + src := "package p\n\n" + + "import (\n" + + "\t_ \"" + moduleRoot + "/base\"\n" + + "\t_ \"" + moduleRoot + "/products/vpc/internal/vpc\"\n" + + ")\n" + joined := strings.Join(checkFile(writeFile(t, t.TempDir(), "x.go", src), "udb"), "\n") + if strings.Count(joined, "ucloud-cli/base") != 1 || strings.Count(joined, "products/vpc") != 1 { + t.Fatalf("rule1/2 territory must be flagged exactly once (by rule1/2, not rule9):\n%s", joined) + } +} + +// -------------------------------------------------------------------------- +// rule10: §2 file layout (grab-bag filenames + one cobra constructor per file) +// -------------------------------------------------------------------------- + +func TestCheckFile_Rule10_OneConstructorPerFile(t *testing.T) { + dir := t.TempDir() + + // Two cobra constructors in one file (a method counts too) → exactly one + // rule10 violation naming the file. + src := `package p + +import "github.com/spf13/cobra" + +func NewCmdList() *cobra.Command { return &cobra.Command{} } + +func (b builder) NewCmdCreate() *cobra.Command { return &cobra.Command{} } +` + path := writeFile(t, dir, "udb/list.go", src) + got := checkFile(path, "udb") + count := 0 + for _, v := range got { + if strings.Contains(v, "rule10") { + count++ + if !strings.Contains(v, path) { + t.Errorf("rule10 violation must name the file %s, got: %v", path, v) + } + } + } + if count != 1 { + t.Fatalf("expected exactly one rule10 violation for two constructors, got %d: %v", count, got) + } + + // One constructor + unrelated funcs (incl. a FuncLit returning + // *cobra.Command inside a body) → zero rule10. + clean := `package p + +import "github.com/spf13/cobra" + +func NewCmdList() *cobra.Command { + build := func() *cobra.Command { return &cobra.Command{} } + return build() +} + +func rows() []string { return nil } + +func (x *thing) status() error { return nil } +` + cleanPath := writeFile(t, dir, "udb/clean.go", clean) + for _, v := range checkFile(cleanPath, "udb") { + if strings.Contains(v, "rule10") { + t.Errorf("unexpected rule10 violation for single-constructor file: %v", v) + } + } + + // _test.go files are exempt from the constructor budget. + testPath := writeFile(t, dir, "udb/list_test.go", src) + for _, v := range checkFile(testPath, "udb") { + if strings.Contains(v, "rule10") { + t.Errorf("unexpected rule10 violation for _test.go file: %v", v) + } + } +} + +func TestCheckFilename_Rule10_GrabBagNames(t *testing.T) { + flagged := []string{ + "products/uhost/helpers.go", + "products/uhost/internal/uhost/utils.go", + "products/udisk/util.go", + "products/eip/common.go", + "products/image/misc.go", + "products/uhost/helpers_test.go", + } + for _, p := range flagged { + got := checkFilename(p) + if len(got) != 1 || !strings.Contains(got[0], "rule10") { + t.Errorf("expected one rule10 violation for %s, got: %v", p, got) + } + } + + clean := []string{ + "products/uhost/list.go", + "products/uhost/rows.go", + "products/uhost/describe.go", + "products/uhost/status.go", + "products/uhost/x.go", + "products/uhost/list_test.go", + "products/uhost/product.yaml", // non-.go files are out of scope + } + for _, p := range clean { + if got := checkFilename(p); len(got) != 0 { + t.Errorf("unexpected rule10 violation for %s: %v", p, got) + } + } +} diff --git a/hack/gen-products/main.go b/hack/gen-products/main.go new file mode 100644 index 0000000000..f8572586b7 --- /dev/null +++ b/hack/gen-products/main.go @@ -0,0 +1,158 @@ +// hack/gen-products generates cmd/products.gen.go from products/*/product.yaml. +// +// Run from repo root: +// +// go run ./hack/gen-products +package main + +import ( + "bytes" + "fmt" + "go/format" + "log" + "os" + "path/filepath" + "sort" + "text/template" + + "gopkg.in/yaml.v2" +) + +const moduleRoot = "github.com/ucloud/ucloud-cli" + +// Product mirrors the products.yaml entry. +type Product struct { + Name string `yaml:"name"` + Dir string `yaml:"-"` // 从 product.yaml 路径推断,不从文件读 + Owners []string `yaml:"owners"` + Commands []string `yaml:"commands"` + Enabled bool `yaml:"enabled"` +} + +// loadProducts scans products/*/product.yaml and returns the products in +// deterministic (path-sorted) order. Dir is derived from each file's directory. +func loadProducts() ([]Product, error) { + matches, err := filepath.Glob("products/*/product.yaml") + if err != nil { + return nil, err + } + sort.Strings(matches) + var products []Product + for _, path := range matches { + raw, readErr := os.ReadFile(path) + if readErr != nil { + return nil, fmt.Errorf("read %s: %w", path, readErr) + } + var p Product + if uErr := yaml.Unmarshal(raw, &p); uErr != nil { + return nil, fmt.Errorf("parse %s: %w", path, uErr) + } + p.Dir = filepath.Dir(path) // products/ + products = append(products, p) + } + return products, nil +} + +// emptySource is used when there are no enabled+present products. +// The single-line function body is what gofmt produces for a trivial function. +// The backtick in the comment cannot appear inside a raw string literal, +// so the constant is built via concatenation. +var emptySource = "// Code generated by hack/gen-products; DO NOT EDIT.\n" + + "package cmd\n" + + "\n" + + `import "github.com/ucloud/ucloud-cli/pkg/cli"` + "\n" + + "\n" + + "// registeredProducts returns the platform-registered products. This is a\n" + + "// stub (empty registry) until the product packages exist; Task F10 runs\n" + + "// `go run ./hack/gen-products` to regenerate it with the real products.\n" + + "func registeredProducts() []cli.Product { return nil }\n" + +// tmplFull is used when there is at least one enabled product. +const tmplFull = `// Code generated by hack/gen-products; DO NOT EDIT. +package cmd + +import ( + "github.com/ucloud/ucloud-cli/pkg/cli" +{{range .Imports}} "{{.}}" +{{end}}) + +// registeredProducts returns the platform-registered products. +func registeredProducts() []cli.Product { + return []cli.Product{ +{{range .Entries}} {{.}}, +{{end}} } +} +` + +// generate produces gofmt'd Go source for cmd/products.gen.go. +// products is the filtered list of enabled products whose on-disk dir exists. +func generate(products []Product) ([]byte, error) { + if len(products) == 0 { + src, err := format.Source([]byte(emptySource)) + if err != nil { + return nil, fmt.Errorf("format empty source: %w", err) + } + return src, nil + } + + type data struct { + Imports []string + Entries []string + } + + var d data + for _, p := range products { + pkgName := filepath.Base(p.Dir) + importPath := moduleRoot + "/" + p.Dir + d.Imports = append(d.Imports, importPath) + d.Entries = append(d.Entries, pkgName+".New()") + } + + t, err := template.New("gen").Parse(tmplFull) + if err != nil { + return nil, fmt.Errorf("parse template: %w", err) + } + + var buf bytes.Buffer + if err := t.Execute(&buf, d); err != nil { + return nil, fmt.Errorf("execute template: %w", err) + } + + src, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("format source: %w\n---\n%s", err, buf.String()) + } + return src, nil +} + +func main() { + outPath := filepath.Join("cmd", "products.gen.go") + + products, err := loadProducts() + if err != nil { + log.Fatalf("load products: %v", err) + } + + var enabled []Product + for _, p := range products { + if !p.Enabled { + continue + } + enabled = append(enabled, p) + } + + log.Printf("registering %d product(s) (of %d total)", len(enabled), len(products)) + for _, p := range enabled { + log.Printf(" + %s (%s.New())", p.Name, filepath.Base(p.Dir)) + } + + src, err := generate(enabled) + if err != nil { + log.Fatalf("generate: %v", err) + } + + if err := os.WriteFile(outPath, src, 0o644); err != nil { + log.Fatalf("write %s: %v", outPath, err) + } + log.Printf("wrote %s", outPath) +} diff --git a/hack/gen-products/main_test.go b/hack/gen-products/main_test.go new file mode 100644 index 0000000000..b8d1439d0e --- /dev/null +++ b/hack/gen-products/main_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "go/format" + "go/parser" + "go/token" + "strings" + "testing" +) + +// TestGenerateEmpty verifies that generate(nil) produces a valid Go file +// containing the empty registeredProducts stub. +func TestGenerateEmpty(t *testing.T) { + src, err := generate(nil) + if err != nil { + t.Fatalf("generate(nil) error: %v", err) + } + + s := string(src) + + if !strings.Contains(s, "func registeredProducts() []cli.Product") { + t.Errorf("missing registeredProducts signature; got:\n%s", s) + } + if !strings.Contains(s, "return nil") { + t.Errorf("expected 'return nil'; got:\n%s", s) + } + + // Must be valid Go (gofmt-clean). + if _, err := format.Source(src); err != nil { + t.Errorf("output is not valid gofmt source: %v", err) + } + // Must parse as valid Go. + fset := token.NewFileSet() + if _, err := parser.ParseFile(fset, "products.gen.go", src, 0); err != nil { + t.Errorf("output does not parse as Go: %v", err) + } +} + +// TestGenerateWithProduct verifies that generate with one enabled product +// emits the correct import path and constructor call. +func TestGenerateWithProduct(t *testing.T) { + products := []Product{ + {Name: "mysql", Dir: "products/mysql", Enabled: true}, + } + + src, err := generate(products) + if err != nil { + t.Fatalf("generate error: %v", err) + } + + s := string(src) + + if !strings.Contains(s, `"github.com/ucloud/ucloud-cli/products/mysql"`) { + t.Errorf("missing import path for products/mysql; got:\n%s", s) + } + if !strings.Contains(s, "mysql.New()") { + t.Errorf("missing mysql.New() in return slice; got:\n%s", s) + } + if !strings.Contains(s, "func registeredProducts() []cli.Product") { + t.Errorf("missing registeredProducts signature; got:\n%s", s) + } + + // Must be valid Go (gofmt-clean). + if _, err := format.Source(src); err != nil { + t.Errorf("output is not valid gofmt source: %v", err) + } + // Must parse as valid Go. + fset := token.NewFileSet() + if _, err := parser.ParseFile(fset, "products.gen.go", src, 0); err != nil { + t.Errorf("output does not parse as Go: %v", err) + } +} + +// TestGenerateEmptySlice verifies that generate([]Product{}) behaves identically +// to generate(nil). +func TestGenerateEmptySlice(t *testing.T) { + fromNil, err := generate(nil) + if err != nil { + t.Fatalf("generate(nil) error: %v", err) + } + fromEmpty, err := generate([]Product{}) + if err != nil { + t.Fatalf("generate([]Product{}) error: %v", err) + } + if string(fromNil) != string(fromEmpty) { + t.Errorf("generate(nil) and generate([]Product{}) differ:\nnull:\n%s\nempty:\n%s", + fromNil, fromEmpty) + } +} diff --git a/hack/owner-gate/main.go b/hack/owner-gate/main.go new file mode 100644 index 0000000000..c846d046ce --- /dev/null +++ b/hack/owner-gate/main.go @@ -0,0 +1,260 @@ +// hack/owner-gate decides whether a pull request can be auto-merged by a +// product owner (product-autonomous PR) or must go through platform review +// (platform PR). See docs/ROADMAP.md P2a. +// +// Ownership is ALWAYS judged against the BASE revision of +// products/X/product.yaml — a PR cannot grant itself ownership by adding the +// author to owners in the same PR. +// +// owner-gate only ROUTES (product / platform / noop) and writes the verdict to +// GITHUB_OUTPUT; it never fails the check itself (exit 0 always, exit 2 only on +// an internal error). Whether a platform PR is BLOCKED is decided downstream in +// the workflow: a platform verdict is Blocking unless `platformCleared` is true +// (the PR author is an admin, OR an admin ≠ author has approved). The workflow +// computes platformCleared via the GitHub API and injects it; an admission step +// turns Blocking into a red required check (exit 1). +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "sort" + "strings" + + "gopkg.in/yaml.v2" +) + +// Decision is owner-gate's verdict. +type Decision struct { + Type string `json:"type"` // "product" | "platform" | "noop" + AutoMergeEligible bool `json:"autoMergeEligible"` // true only for product PRs + Blocking bool `json:"blocking"` // true ⇒ admission step makes the check red + Reason string `json:"reason"` // PR-visible explanation +} + +// changedFile is one entry from `git diff --name-status`. +type changedFile struct { + Path string + Deleted bool +} + +// baseOwnersFunc returns the owners of products//product.yaml at the +// BASE revision, whether that file existed at base, and whether it existed but +// could not be parsed (parseErr) — the three cases drive distinct verdicts. +type baseOwnersFunc func(product string) (owners []string, baseExists bool, parseErr bool) + +// productOf returns X when path is products//<...> (a file inside a product +// subtree). Files directly under products/ and files outside products/ are not +// product files. +func productOf(path string) (string, bool) { + parts := strings.Split(path, "/") + if len(parts) >= 3 && parts[0] == "products" { + return parts[1], true + } + return "", false +} + +// productYAMLDeleted reports whether this PR removes products//product.yaml — +// either by an outright delete or by renaming it away from its canonical path +// (parseNameStatus marks a rename's OLD side Deleted), both of which are +// offboarding and must go through platform review. +func productYAMLDeleted(changed []changedFile, x string) bool { + want := fmt.Sprintf("products/%s/product.yaml", x) + for _, c := range changed { + if c.Deleted && c.Path == want { + return true + } + } + return false +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// parseNameStatus parses `git diff --name-status` output. Each line is +// "\t"; renames/copies are "\t\t". For a +// rename/copy we record BOTH the old and the new path: the new path as the +// edited file, and the old path as removed-from (Deleted for a rename, kept for +// a copy). Recording both ends makes a cross-product move surface as touching +// two products, and a rename-away of product.yaml surface as a deletion. +func parseNameStatus(r io.Reader) ([]changedFile, error) { + var out []changedFile + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := strings.TrimRight(sc.Text(), "\r\n") + if line == "" { + continue + } + fields := strings.Split(line, "\t") + if len(fields) < 2 { + continue + } + status := fields[0] + if (strings.HasPrefix(status, "R") || strings.HasPrefix(status, "C")) && len(fields) >= 3 { + oldPath := fields[len(fields)-2] + newPath := fields[len(fields)-1] + // Rename removes the old path; copy leaves it in place. + out = append(out, changedFile{Path: oldPath, Deleted: strings.HasPrefix(status, "R")}) + out = append(out, changedFile{Path: newPath, Deleted: false}) + continue + } + path := fields[len(fields)-1] + out = append(out, changedFile{Path: path, Deleted: strings.HasPrefix(status, "D")}) + } + return out, sc.Err() +} + +// gitShowOwners reads products//product.yaml at baseSHA and returns its +// owners, whether the file existed at that revision (baseExists), and whether it +// existed but failed to parse (parseErr). Runs in the process working directory. +func gitShowOwners(baseSHA, product string) ([]string, bool, bool) { + ref := fmt.Sprintf("%s:products/%s/product.yaml", baseSHA, product) + raw, err := exec.Command("git", "show", ref).Output() + if err != nil { + // Non-zero exit ⇒ path absent at base ⇒ new product. + return nil, false, false + } + var meta struct { + Owners []string `yaml:"owners"` + } + if err := yaml.Unmarshal(raw, &meta); err != nil { + return nil, true, true // exists but unparseable + } + return meta.Owners, true, false +} + +// platformDecision builds a platform verdict. It is Blocking (→ red) unless the +// PR has been cleared (author is admin, or an admin ≠ author approved); the +// workflow computes `cleared` and the admission step consumes Blocking. +func platformDecision(detail string, cleared bool) Decision { + if cleared { + return Decision{ + Type: "platform", + Blocking: false, + Reason: "✅ 平台 PR 已放行(管理员自提或已获管理员批准),CI 通过后可合。判定:" + detail, + } + } + return Decision{ + Type: "platform", + Blocking: true, + Reason: "🔴 平台 PR 默认硬拦,需管理员 Approve 放行(或由管理员提交)。判定:" + detail, + } +} + +// decide is the pure admission decision. platformCleared comes from the workflow +// (author admin OR admin≠author approved) and only affects platform verdicts. +func decide(changed []changedFile, author string, platformCleared bool, baseOwners baseOwnersFunc) Decision { + if len(changed) == 0 { + return Decision{Type: "noop", Reason: "空改动:无文件变更,无需准入裁决。"} + } + + productSet := map[string]bool{} + var platformFiles []string + for _, c := range changed { + if x, ok := productOf(c.Path); ok { + productSet[x] = true + } else { + platformFiles = append(platformFiles, c.Path) + } + } + + if len(platformFiles) > 0 { + sort.Strings(platformFiles) + return platformDecision(fmt.Sprintf("改动触及平台/受保护路径(%s)", strings.Join(platformFiles, ", ")), platformCleared) + } + + dirs := sortedKeys(productSet) + switch len(dirs) { + case 0: + // Unreachable in practice (empty diff handled above; any non-empty file + // is product or platform). Treat defensively as a no-op. + return Decision{Type: "noop", Reason: "无产品目录改动。"} + case 1: + // handled below + default: + return platformDecision(fmt.Sprintf("跨产品改动(%s),请拆成每产品一个 PR", strings.Join(dirs, ", ")), platformCleared) + } + + x := dirs[0] + owners, baseExists, parseErr := baseOwners(x) + if parseErr { + return platformDecision(fmt.Sprintf("base 版 products/%s/product.yaml 解析失败,无法判定归属,需平台介入修复元数据", x), platformCleared) + } + if !baseExists { + return platformDecision(fmt.Sprintf("新产品 onboarding(base 版无 products/%s/)", x), platformCleared) + } + if productYAMLDeleted(changed, x) { + return platformDecision(fmt.Sprintf("下线删除/搬移 products/%s/product.yaml", x), platformCleared) + } + for _, o := range owners { + if strings.EqualFold(o, author) { + return Decision{ + Type: "product", + AutoMergeEligible: true, + Blocking: false, + Reason: fmt.Sprintf("✅ products/%s 自治:%s 是 base 版 owner,过 CI 后自动合并。", x, author), + } + } + } + return platformDecision(fmt.Sprintf("%s 不是 products/%s 的 base 版 owner(non-owner 改动)", author, x), platformCleared) +} + +func main() { + author := os.Getenv("OWNER_GATE_AUTHOR") + baseSHA := os.Getenv("OWNER_GATE_BASE_SHA") + if author == "" || baseSHA == "" { + fmt.Fprintln(os.Stderr, "owner-gate: OWNER_GATE_AUTHOR and OWNER_GATE_BASE_SHA must be set") + os.Exit(2) + } + // platformCleared: workflow-computed (author admin OR admin≠author approved). + // Empty/absent ⇒ not cleared ⇒ platform stays blocked (fail-closed). + platformCleared := strings.EqualFold(strings.TrimSpace(os.Getenv("OWNER_GATE_PLATFORM_CLEARED")), "true") + + changed, err := parseNameStatus(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "owner-gate: read diff: %v\n", err) + os.Exit(2) + } + + d := decide(changed, author, platformCleared, func(product string) ([]string, bool, bool) { + return gitShowOwners(baseSHA, product) + }) + + enc, _ := json.Marshal(d) + fmt.Println(string(enc)) + + if gho := os.Getenv("GITHUB_OUTPUT"); gho != "" { + if err := writeGitHubOutput(gho, d); err != nil { + fmt.Fprintf(os.Stderr, "owner-gate: write GITHUB_OUTPUT: %v\n", err) + os.Exit(2) + } + } + // 裁决本身从不让 check 失败 —— Blocking 由下游 admission step 消费(exit 1)。 + // 仅内部错误(env 缺失/读 diff 失败/写 output 失败)退非零(exit 2)。 +} + +// writeGitHubOutput appends type/autoMergeEligible/blocking/reason to the GitHub +// Actions step-output file. reason uses a heredoc to stay multiline-safe. +func writeGitHubOutput(path string, d Decision) error { + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + fmt.Fprintf(f, "type=%s\n", d.Type) + fmt.Fprintf(f, "autoMergeEligible=%t\n", d.AutoMergeEligible) + fmt.Fprintf(f, "blocking=%t\n", d.Blocking) + fmt.Fprintf(f, "reason< owners); +// products absent from the map are treated as not existing at base. +func staticOwners(m map[string][]string) baseOwnersFunc { + return func(product string) ([]string, bool, bool) { + o, ok := m[product] + return o, ok, false + } +} + +// staticOwnersParseErr returns a lookup where the named product exists at base +// but fails to parse (parseErr=true). +func staticOwnersParseErr(product string) baseOwnersFunc { + return func(p string) ([]string, bool, bool) { + if p == product { + return nil, true, true + } + return nil, false, false + } +} + +func cf(path string) changedFile { return changedFile{Path: path} } +func cfDel(path string) changedFile { return changedFile{Path: path, Deleted: true} } + +// -------------------------------------------------------------------------- +// productOf +// -------------------------------------------------------------------------- + +func TestProductOf(t *testing.T) { + cases := []struct { + path string + want string + ok bool + }{ + {"products/mysql/internal/mysql/cmd.go", "mysql", true}, + {"products/mysql/product.yaml", "mysql", true}, + {"products/eip/x.go", "eip", true}, + {"products/README.md", "", false}, // products/ 直属文件不属任何产品 + {"pkg/cli/context.go", "", false}, + {"go.mod", "", false}, + {".github/workflows/pr-gate.yml", "", false}, + } + for _, c := range cases { + got, ok := productOf(c.path) + if got != c.want || ok != c.ok { + t.Errorf("productOf(%q)=(%q,%v) want (%q,%v)", c.path, got, ok, c.want, c.ok) + } + } +} + +// -------------------------------------------------------------------------- +// decide — spec 要求的 8 个场景各一例(platformCleared=false:平台默认硬拦) +// -------------------------------------------------------------------------- + +func TestDecide_ProductAutonomous(t *testing.T) { + d := decide( + []changedFile{cf("products/mysql/internal/mysql/cmd.go")}, + "Episkey-G", false, + staticOwners(map[string][]string{"mysql": {"Episkey-G"}}), + ) + if d.Type != "product" || !d.AutoMergeEligible || d.Blocking { + t.Fatalf("expected product+eligible+nonblocking, got %+v", d) + } +} + +func TestDecide_PlatformFile(t *testing.T) { + d := decide( + []changedFile{cf("pkg/cli/context.go")}, + "Episkey-G", false, + staticOwners(map[string][]string{"mysql": {"Episkey-G"}}), + ) + if d.Type != "platform" || d.AutoMergeEligible || !d.Blocking { + t.Fatalf("expected platform+ineligible+blocking, got %+v", d) + } +} + +func TestDecide_CrossProduct(t *testing.T) { + d := decide( + []changedFile{cf("products/mysql/x.go"), cf("products/eip/y.go")}, + "Episkey-G", false, + staticOwners(map[string][]string{"mysql": {"Episkey-G"}, "eip": {"Episkey-G"}}), + ) + if d.Type != "platform" || d.AutoMergeEligible || !d.Blocking { + t.Fatalf("expected platform (cross-product) blocking, got %+v", d) + } +} + +func TestDecide_NonOwnerEdit(t *testing.T) { + d := decide( + []changedFile{cf("products/mysql/internal/mysql/cmd.go")}, + "mallory", false, + staticOwners(map[string][]string{"mysql": {"Episkey-G"}}), + ) + if d.Type != "platform" || d.AutoMergeEligible || !d.Blocking { + t.Fatalf("expected platform (non-owner) blocking, got %+v", d) + } +} + +// base-vs-head 提权反例:HEAD 把 mallory 加进 owners,但 base 版只有 Episkey-G。 +func TestDecide_SelfPromotionRejectedByBase(t *testing.T) { + d := decide( + []changedFile{cf("products/mysql/product.yaml")}, + "mallory", false, + staticOwners(map[string][]string{"mysql": {"Episkey-G"}}), // base: 无 mallory + ) + if d.Type != "platform" || d.AutoMergeEligible || !d.Blocking { + t.Fatalf("expected platform (self-promotion blocked by base), got %+v", d) + } +} + +// 改自己 owners:已是 base 版 owner 的人改 product.yaml(如加 co-owner)仍自治。 +func TestDecide_OwnerEditsOwnOwners(t *testing.T) { + d := decide( + []changedFile{cf("products/mysql/product.yaml")}, + "Episkey-G", false, + staticOwners(map[string][]string{"mysql": {"Episkey-G"}}), + ) + if d.Type != "product" || !d.AutoMergeEligible || d.Blocking { + t.Fatalf("expected product+eligible (owner edits own owners), got %+v", d) + } +} + +// 新建产品:base 版无 products/newprod/ → 平台 PR(onboarding)。 +func TestDecide_NewProductOnboarding(t *testing.T) { + d := decide( + []changedFile{cf("products/newprod/product.yaml"), cf("products/newprod/product.go")}, + "Episkey-G", false, + staticOwners(map[string][]string{}), // base: newprod 不存在 + ) + if d.Type != "platform" || d.AutoMergeEligible || !d.Blocking { + t.Fatalf("expected platform (new product) blocking, got %+v", d) + } +} + +// 删产品:即便 author 是 owner,删除 product.yaml 也走平台审批(下线)。 +func TestDecide_ProductOffboarding(t *testing.T) { + d := decide( + []changedFile{cfDel("products/mysql/product.yaml"), cfDel("products/mysql/internal/mysql/cmd.go")}, + "Episkey-G", false, + staticOwners(map[string][]string{"mysql": {"Episkey-G"}}), + ) + if d.Type != "platform" || d.AutoMergeEligible || !d.Blocking { + t.Fatalf("expected platform (offboarding) blocking, got %+v", d) + } +} + +// -------------------------------------------------------------------------- +// decide — 硬拦改造新增场景 +// -------------------------------------------------------------------------- + +// 平台 PR 被放行(管理员自提或管理员批准)→ 仍 platform,但不再 blocking。 +func TestDecide_PlatformClearedReleases(t *testing.T) { + d := decide( + []changedFile{cf("base/biz_client.go")}, + "carol", true, // platformCleared + staticOwners(map[string][]string{"mysql": {"Episkey-G"}}), + ) + if d.Type != "platform" || d.Blocking || d.AutoMergeEligible { + t.Fatalf("expected platform+nonblocking+ineligible (cleared), got %+v", d) + } +} + +// cleared 只解硬拦,不把 non-owner 升级成 product 自治、不开 auto-merge。 +func TestDecide_ClearedDoesNotPromoteNonOwner(t *testing.T) { + d := decide( + []changedFile{cf("products/mysql/internal/mysql/cmd.go")}, + "mallory", true, // cleared + staticOwners(map[string][]string{"mysql": {"Episkey-G"}}), + ) + if d.Type != "platform" || d.AutoMergeEligible || d.Blocking { + t.Fatalf("expected platform (non-owner) nonblocking+ineligible, got %+v", d) + } +} + +// product 自治路径不受 platformCleared 影响:两态都自动合、永不 blocking。 +func TestDecide_ProductIgnoresCleared(t *testing.T) { + for _, cleared := range []bool{false, true} { + d := decide( + []changedFile{cf("products/mysql/internal/mysql/cmd.go")}, + "Episkey-G", cleared, + staticOwners(map[string][]string{"mysql": {"Episkey-G"}}), + ) + if d.Type != "product" || !d.AutoMergeEligible || d.Blocking { + t.Fatalf("cleared=%v: expected product+eligible+nonblocking, got %+v", cleared, d) + } + } +} + +// 跨产品 rename:old 端与 new 端分属两产品 → cross-product 平台红。 +func TestDecide_CrossProductRename(t *testing.T) { + d := decide( + []changedFile{ + {Path: "products/mysql/cmd/shared.go", Deleted: true}, // rename old 端 + {Path: "products/uhost/cmd/shared.go", Deleted: false}, + }, + "Episkey-G", false, + staticOwners(map[string][]string{"mysql": {"Episkey-G"}, "uhost": {"Episkey-G"}}), + ) + if d.Type != "platform" || !d.Blocking { + t.Fatalf("expected platform (cross-product rename) blocking, got %+v", d) + } +} + +// rename-away product.yaml(搬离 canonical 路径)等同下线 → 平台红。 +func TestDecide_RenameAwayProductYAMLIsOffboarding(t *testing.T) { + d := decide( + []changedFile{ + {Path: "products/mysql/product.yaml", Deleted: true}, // rename old 端 + {Path: "products/mysql/product.yaml.bak", Deleted: false}, + }, + "Episkey-G", false, // 即便作者是 owner + staticOwners(map[string][]string{"mysql": {"Episkey-G"}}), + ) + if d.Type != "platform" || !d.Blocking { + t.Fatalf("expected platform (rename-away offboarding) blocking, got %+v", d) + } +} + +// 空 diff(仅 merge commit / 无改动)→ noop,不当平台拦。 +func TestDecide_EmptyDiffNoop(t *testing.T) { + d := decide(nil, "Episkey-G", false, + staticOwners(map[string][]string{"mysql": {"Episkey-G"}})) + if d.Type != "noop" || d.Blocking || d.AutoMergeEligible { + t.Fatalf("expected noop+nonblocking+ineligible, got %+v", d) + } +} + +// base 版 product.yaml 解析失败 → 独立平台文案(不误称 non-owner)。 +func TestDecide_ParseFailureDistinctReason(t *testing.T) { + d := decide( + []changedFile{cf("products/mysql/internal/mysql/cmd.go")}, + "Episkey-G", false, + staticOwnersParseErr("mysql"), + ) + if d.Type != "platform" || !d.Blocking { + t.Fatalf("expected platform (parse error) blocking, got %+v", d) + } + if !strings.Contains(d.Reason, "解析失败") { + t.Fatalf("expected reason to mention 解析失败, got %q", d.Reason) + } +} + +// -------------------------------------------------------------------------- +// parseNameStatus — 解析 `git diff --name-status`(rename 两端均计入) +// -------------------------------------------------------------------------- + +func TestParseNameStatus(t *testing.T) { + in := "M\tproducts/mysql/product.go\n" + + "A\tproducts/mysql/internal/mysql/new.go\n" + + "D\tproducts/mysql/internal/mysql/old.go\n" + + "R100\tproducts/mysql/a.go\tproducts/mysql/b.go\n" + + "\n" // 空行应被跳过 + got, err := parseNameStatus(strings.NewReader(in)) + if err != nil { + t.Fatalf("parseNameStatus: %v", err) + } + // M, A, D 各 1 条 + rename 产出 old/new 2 条 = 5。 + if len(got) != 5 { + t.Fatalf("expected 5 entries, got %d: %+v", len(got), got) + } + // 普通改动非删除 + if got[0].Deleted || got[0].Path != "products/mysql/product.go" { + t.Errorf("entry[0] should be modified product.go, got %+v", got[0]) + } + // 删除标记 + if !got[2].Deleted || got[2].Path != "products/mysql/internal/mysql/old.go" { + t.Errorf("entry[2] should be deleted old.go, got %+v", got[2]) + } + // rename old 端:标记 Deleted + if !got[3].Deleted || got[3].Path != "products/mysql/a.go" { + t.Errorf("entry[3] should be rename-old a.go (deleted), got %+v", got[3]) + } + // rename new 端:非删除 + if got[4].Deleted || got[4].Path != "products/mysql/b.go" { + t.Errorf("entry[4] should be rename-new b.go (not deleted), got %+v", got[4]) + } +} + +// 跨产品 rename 行:old/new 两端分属不同产品都要计入。 +func TestParseNameStatus_CrossProductRename(t *testing.T) { + in := "R100\tproducts/mysql/cmd/shared.go\tproducts/uhost/cmd/shared.go\n" + got, err := parseNameStatus(strings.NewReader(in)) + if err != nil { + t.Fatalf("parseNameStatus: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 entries (old+new), got %d: %+v", len(got), got) + } + seen := map[string]bool{} + for _, c := range got { + if x, ok := productOf(c.Path); ok { + seen[x] = true + } + } + if !seen["mysql"] || !seen["uhost"] { + t.Fatalf("expected both mysql and uhost in productSet, got %v", seen) + } +} + +// -------------------------------------------------------------------------- +// gitShowOwners — 必须读 BASE 版,而非 HEAD(安全命门) +// -------------------------------------------------------------------------- + +func gitRun(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func TestGitShowOwners_ReadsBaseNotHead(t *testing.T) { + repo := t.TempDir() + gitRun(t, repo, "init", "-q", "-b", "main") + + // base commit: owners = [alice] + writeFile(t, repo, "products/mysql/product.yaml", + "name: mysql\nowners:\n - alice\ncommands: [mysql]\nenabled: true\n") + gitRun(t, repo, "add", "-A") + gitRun(t, repo, "commit", "-qm", "base") + baseSHA := gitRun(t, repo, "rev-parse", "HEAD") + + // head commit: PR 把 bob 加进 owners(自我提权尝试) + writeFile(t, repo, "products/mysql/product.yaml", + "name: mysql\nowners:\n - alice\n - bob\ncommands: [mysql]\nenabled: true\n") + gitRun(t, repo, "add", "-A") + gitRun(t, repo, "commit", "-qm", "head") + + t.Chdir(repo) // gitShowOwners 在进程 cwd 下跑 git + + owners, exists, parseErr := gitShowOwners(baseSHA, "mysql") + if !exists || parseErr { + t.Fatalf("expected base product.yaml to exist and parse, got exists=%v parseErr=%v", exists, parseErr) + } + if len(owners) != 1 || owners[0] != "alice" { + t.Fatalf("expected base owners [alice] (must NOT see head's bob), got %v", owners) + } + + // 不存在的产品 → baseExists=false + if _, exists, _ := gitShowOwners(baseSHA, "ghost"); exists { + t.Fatal("expected ghost product to be absent at base") + } +} + +// base 版 product.yaml 存在但 YAML 解析失败 → parseErr=true、baseExists=true。 +func TestGitShowOwners_ParseError(t *testing.T) { + repo := t.TempDir() + gitRun(t, repo, "init", "-q", "-b", "main") + // 未定义 anchor 的别名引用 → yaml.v2 解析报错。 + writeFile(t, repo, "products/mysql/product.yaml", "owners: *nope\n") + gitRun(t, repo, "add", "-A") + gitRun(t, repo, "commit", "-qm", "base") + baseSHA := gitRun(t, repo, "rev-parse", "HEAD") + + t.Chdir(repo) + owners, exists, parseErr := gitShowOwners(baseSHA, "mysql") + if !exists || !parseErr { + t.Fatalf("expected exists=true parseErr=true, got exists=%v parseErr=%v", exists, parseErr) + } + if owners != nil { + t.Fatalf("expected nil owners on parse error, got %v", owners) + } +} diff --git a/hack/snapshot/completion.go b/hack/snapshot/completion.go new file mode 100644 index 0000000000..e6a7d74bbd --- /dev/null +++ b/hack/snapshot/completion.go @@ -0,0 +1,114 @@ +package snapshot + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// completionResult holds the outcome of classifying a flag's completion func. +type completionResult struct { + registered bool // false → no completion func registered; skip. + isDynamic bool // true → completion requires network (BizClient); record as "dynamic". + candidates []string // non-nil when isDynamic=false and registered=true. +} + +// classifyFlag invokes the completion func for the named flag and classifies it +// as static (fixed candidate set) or dynamic (requires network). +// +// Dynamic detection: SetCompletion closures touch the network-backing globals +// which the test nils after tree construction, causing a nil-pointer panic. +// Platform (cmd) closures dereference platform.BizClient; product (products/mysql) +// closures go through cli.NewServiceClient, which builds an SDK client from +// platform.ClientConfig — so the test nils both (see TestWriteCompletionBaseline). +// We recover from the panic and mark the flag dynamic. A closure may also +// signal dynamic explicitly by returning cobra.ShellCompDirectiveError. +// SetFlagValues closures return a fixed slice and never touch those globals, so +// they succeed without panicking and are recorded as static. +func classifyFlag(c *cobra.Command, flagName string) completionResult { + fn, ok := c.GetFlagCompletionFunc(flagName) + if !ok { + return completionResult{} + } + + var isDynamic bool + var candidates []string + + func() { + defer func() { + if r := recover(); r != nil { + isDynamic = true + } + }() + results, directive := fn(c, []string{}, "") + if directive == cobra.ShellCompDirectiveError { + isDynamic = true + return + } + candidates = results + }() + + return completionResult{registered: true, isDynamic: isDynamic, candidates: candidates} +} + +// RenderCompletion returns a deterministic text dump of completion registrations +// for the entire cobra command tree rooted at root. +// +// Format (one line per flag that has a registered completion func): +// +// \t\tstatic[\t] +// \t\tdynamic +// +// Flags with no registered completion are omitted. +// Subcommands are visited in sorted order; flags are visited in sorted order. +func RenderCompletion(root *cobra.Command) string { return RenderCompletionPlatform(root, nil) } + +// RenderCompletionPlatform is RenderCompletion minus the top-level subtrees +// named in skip. Product-claimed top-level commands are guarded by their own +// goldens under products//testdata/, so the platform golden must not +// duplicate them; only direct children of root are ever pruned. +func RenderCompletionPlatform(root *cobra.Command, skip map[string]bool) string { + var b strings.Builder + var walk func(c *cobra.Command, depth int) + walk = func(c *cobra.Command, depth int) { + // Collect all flags on this command (non-persistent only; persistent flags + // are registered on the defining command and appear there too). + var fs []*pflag.Flag + c.Flags().VisitAll(func(f *pflag.Flag) { fs = append(fs, f) }) + sort.Slice(fs, func(i, j int) bool { return fs[i].Name < fs[j].Name }) + + for _, f := range fs { + r := classifyFlag(c, f.Name) + if !r.registered { + continue // no completion func → skip. + } + if r.isDynamic { + // Registered but requires network/BizClient. + fmt.Fprintf(&b, "%s\t%s\tdynamic\n", c.CommandPath(), f.Name) + } else { + // Static enum — sort candidates for determinism. + sorted := append([]string(nil), r.candidates...) + sort.Strings(sorted) + if len(sorted) == 0 { + fmt.Fprintf(&b, "%s\t%s\tstatic\n", c.CommandPath(), f.Name) + continue + } + fmt.Fprintf(&b, "%s\t%s\tstatic\t%s\n", c.CommandPath(), f.Name, strings.Join(sorted, ",")) + } + } + + ch := c.Commands() + sort.Slice(ch, func(i, j int) bool { return ch[i].Use < ch[j].Use }) + for _, x := range ch { + if depth == 0 && skip[x.Name()] { + continue + } + walk(x, depth+1) + } + } + walk(root, 0) + return b.String() +} diff --git a/hack/snapshot/snapshot.go b/hack/snapshot/snapshot.go new file mode 100644 index 0000000000..bb64973bb0 --- /dev/null +++ b/hack/snapshot/snapshot.go @@ -0,0 +1,47 @@ +package snapshot + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Render returns a deterministic text dump of the entire cobra command tree. +// It captures each command's path, use, short, and each flag's name/shorthand/default/required. +// Completion candidate values are intentionally NOT captured — they are verified separately. +func Render(root *cobra.Command) string { return RenderPlatform(root, nil) } + +// RenderPlatform is Render minus the top-level subtrees named in skip. +// Product-claimed top-level commands are guarded by their own goldens under +// products//testdata/, so the platform golden must not duplicate them; +// only direct children of root are ever pruned. +func RenderPlatform(root *cobra.Command, skip map[string]bool) string { + var b strings.Builder + var walk func(c *cobra.Command, depth int) + walk = func(c *cobra.Command, depth int) { + fmt.Fprintf(&b, "%s\tuse=%s\tshort=%s\n", c.CommandPath(), c.Use, c.Short) + var fs []*pflag.Flag + c.Flags().VisitAll(func(f *pflag.Flag) { fs = append(fs, f) }) + sort.Slice(fs, func(i, j int) bool { return fs[i].Name < fs[j].Name }) + for _, f := range fs { + req := "" + if rs, ok := f.Annotations[cobra.BashCompOneRequiredFlag]; ok && len(rs) > 0 && rs[0] == "true" { + req = "true" + } + fmt.Fprintf(&b, " flag=%s\tshort=%s\tdefault=%s\trequired=%s\n", f.Name, f.Shorthand, f.DefValue, req) + } + ch := c.Commands() + sort.Slice(ch, func(i, j int) bool { return ch[i].Use < ch[j].Use }) + for _, x := range ch { + if depth == 0 && skip[x.Name()] { + continue + } + walk(x, depth+1) + } + } + walk(root, 0) + return b.String() +} diff --git a/hack/snapshot/snapshot_test.go b/hack/snapshot/snapshot_test.go new file mode 100644 index 0000000000..8bea4ad5cf --- /dev/null +++ b/hack/snapshot/snapshot_test.go @@ -0,0 +1,208 @@ +package snapshot + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-cli/cmd" +) + +const goldenPath = "testdata/cmdtree.golden" + +var rootVersionLine = regexp.MustCompile(`UCloud CLI v[^\n]+`) + +func TestWriteBaseline(t *testing.T) { + root := cmd.NewCmdRoot() + cmd.AddChildrenForSnapshot(root) + got := RenderPlatform(root, productSkipSet()) + // The version string (root's Short: "UCloud CLI vX.Y.Z") is an intended, + // separately-managed value — not part of the command-tree structure we guard. + // Normalize it to a stable placeholder so the golden is version-insensitive + // without importing platform-internal version state from outside cmd/. + got = rootVersionLine.ReplaceAllString(got, "UCloud CLI v{VERSION}") + + compareOrWrite(t, goldenPath, got, "WRITE_CMDTREE_GOLDEN") +} + +const completionGoldenPath = "testdata/completion.golden" + +func TestWriteCompletionBaseline(t *testing.T) { + root := cmd.NewCmdRoot() + cmd.AddChildrenForSnapshot(root) + // Disable network-backing runtime state after command construction so + // dynamic completions panic-on-invoke instead of issuing real network calls. + // SetFlagValues closures are immune; SetCompletion closures dereference the + // runtime-backed clients. + cmd.DisableRuntimeForSnapshotCompletion() + got := RenderCompletionPlatform(root, productSkipSet()) + + compareOrWrite(t, completionGoldenPath, got, "WRITE_COMPLETION_GOLDEN") +} + +func TestRenderStructure(t *testing.T) { + root := &cobra.Command{Use: "ucloud"} + sub := &cobra.Command{Use: "demo", Short: "d"} + sub.Flags().String("name", "def", "Required. name") + sub.MarkFlagRequired("name") + root.AddCommand(sub) + got := Render(root) + for _, w := range []string{"ucloud demo", "use=demo", "short=d", "flag=name", "default=def", "required=true"} { + if !strings.Contains(got, w) { + t.Fatalf("missing %q\n%s", w, got) + } + } +} + +// productSkipSet returns the top-level command names claimed by registered +// products — exactly the subtrees the platform golden prunes. +func productSkipSet() map[string]bool { + skip := map[string]bool{} + for _, p := range cmd.ProductsForSnapshot() { + for _, c := range p.Metadata().Commands { + skip[c] = true + } + } + return skip +} + +// renderProduct renders the product-claimed top-level subtrees (sorted by +// command name) from the fully-built root, so CommandPath keeps the +// "ucloud " prefix and lines stay byte-identical to the pre-split golden. +func renderProduct(t *testing.T, root *cobra.Command, commands []string, render func(*cobra.Command) string) string { + t.Helper() + names := append([]string(nil), commands...) + sort.Strings(names) + var b strings.Builder + for _, name := range names { + var target *cobra.Command + for _, ch := range root.Commands() { + if ch.Name() == name { + target = ch + break + } + } + if target == nil { + t.Fatalf("product command %q not found under root — product.yaml/Metadata out of sync?", name) + } + b.WriteString(render(target)) + } + return b.String() +} + +// compareOrWrite implements the golden write/compare protocol shared by all +// snapshot tests. +func compareOrWrite(t *testing.T, path, got, writeEnv string) { + t.Helper() + if os.Getenv(writeEnv) == "1" { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + t.Logf("wrote %s (%d bytes)", path, len(got)) + return + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden: %v — run %s=1 go test ./hack/snapshot to generate", err, writeEnv) + } + if got != string(data) { + t.Fatalf("golden mismatch for %s (refresh: %s=1 go test ./hack/snapshot).\ngot:\n%s\nwant:\n%s", path, writeEnv, got, string(data)) + } +} + +// lineMultisetDiff compares a and b as line multisets and returns up to 10 +// offending lines, each prefixed with its signed residual count (positive: +// surplus in a; negative: surplus in b). An empty result means the multisets +// are equal. +func lineMultisetDiff(a, b string) []string { + count := map[string]int{} + for _, l := range strings.Split(a, "\n") { + count[l]++ + } + for _, l := range strings.Split(b, "\n") { + count[l]-- + } + var diff []string + for l, n := range count { + if n != 0 { + diff = append(diff, fmt.Sprintf("%+d %s", n, l)) + } + } + sort.Strings(diff) + if len(diff) > 10 { + diff = diff[:10] + } + return diff +} + +// TestProductGoldens verifies each product's command subtree against the +// golden the product team owns. Refresh one product: +// +// WRITE_CMDTREE_GOLDEN=1 go test ./hack/snapshot -run 'TestProductGoldens/' +func TestProductGoldens(t *testing.T) { + root := cmd.NewCmdRoot() + cmd.AddChildrenForSnapshot(root) + for _, p := range cmd.ProductsForSnapshot() { + meta := p.Metadata() + t.Run(meta.Name, func(t *testing.T) { + got := renderProduct(t, root, meta.Commands, Render) + path := filepath.Join("..", "..", "products", meta.Name, "testdata", "cmdtree.golden") + compareOrWrite(t, path, got, "WRITE_CMDTREE_GOLDEN") + }) + } +} + +// TestProductCompletionGoldens is the completion-candidate counterpart of +// TestProductGoldens. Refresh: +// +// WRITE_COMPLETION_GOLDEN=1 go test ./hack/snapshot -run 'TestProductCompletionGoldens/' +func TestProductCompletionGoldens(t *testing.T) { + root := cmd.NewCmdRoot() + cmd.AddChildrenForSnapshot(root) + cmd.DisableRuntimeForSnapshotCompletion() + for _, p := range cmd.ProductsForSnapshot() { + meta := p.Metadata() + t.Run(meta.Name, func(t *testing.T) { + got := renderProduct(t, root, meta.Commands, RenderCompletion) + path := filepath.Join("..", "..", "products", meta.Name, "testdata", "completion.golden") + compareOrWrite(t, path, got, "WRITE_COMPLETION_GOLDEN") + }) + } +} + +// TestGoldenPartition guards against silent coverage loss: the full-tree +// render must equal platform render + all product renders as a line multiset. +// A pruning bug that dropped a non-product subtree would fail here — this is +// the permanent replacement for the one-time migration equivalence check. +func TestGoldenPartition(t *testing.T) { + root := cmd.NewCmdRoot() + cmd.AddChildrenForSnapshot(root) + full := Render(root) + parts := RenderPlatform(root, productSkipSet()) + for _, p := range cmd.ProductsForSnapshot() { + parts += renderProduct(t, root, p.Metadata().Commands, Render) + } + if d := lineMultisetDiff(full, parts); len(d) > 0 { + t.Fatalf("golden partition lost or duplicated lines: full render != platform + products: %v", d) + } + + root2 := cmd.NewCmdRoot() + cmd.AddChildrenForSnapshot(root2) + cmd.DisableRuntimeForSnapshotCompletion() + fullC := RenderCompletion(root2) + partsC := RenderCompletionPlatform(root2, productSkipSet()) + for _, p := range cmd.ProductsForSnapshot() { + partsC += renderProduct(t, root2, p.Metadata().Commands, RenderCompletion) + } + if d := lineMultisetDiff(fullC, partsC); len(d) > 0 { + t.Fatalf("completion partition lost or duplicated lines: %v", d) + } +} diff --git a/hack/snapshot/testdata/cmdtree.golden b/hack/snapshot/testdata/cmdtree.golden new file mode 100644 index 0000000000..614457878d --- /dev/null +++ b/hack/snapshot/testdata/cmdtree.golden @@ -0,0 +1,73 @@ +ucloud use=ucloud short=UCloud CLI v{VERSION} + flag=completion short= default=false required= + flag=config short= default=false required= + flag=signup short= default=false required= + flag=version short=v default=false required= +ucloud __schema use=__schema short=Print a machine-readable schema of all commands (for tools/AI) +ucloud api use=api short=Call API +ucloud auth use=auth short=Authenticate ucloud-cli via browser (OAuth) +ucloud auth login use=login short=Log in to UCloud via browser (OAuth) + flag=no-browser short= default=false required= + flag=oauth-base-url short= default= required= +ucloud auth logout use=logout short=Log out: remove local OAuth tokens of the current profile +ucloud config use=config short=add or update configurations + flag=active short= default= required= + flag=agree-upload-log short= default=false required= + flag=base-url short= default= required= + flag=channel-key short= default= required= + flag=max-retry-times short= default=0 required= + flag=private-key short= default= required= + flag=profile short= default= required= + flag=project-id short= default= required= + flag=public-key short= default= required= + flag=region short= default= required= + flag=timeout-sec short= default=0 required= + flag=zone short= default= required= +ucloud config add use=add short=add configuration + flag=active short= default=false required= + flag=agree-upload-log short= default=false required= + flag=base-url short= default=https://api.ucloud.cn/ required= + flag=channel-key short= default= required= + flag=max-retry-times short= default=3 required= + flag=private-key short= default= required=true + flag=profile short= default= required=true + flag=project-id short= default= required= + flag=public-key short= default= required=true + flag=region short= default= required= + flag=timeout-sec short= default=15 required= + flag=zone short= default= required= +ucloud config delete use=delete short=delete configurations by profile name + flag=profile short= default=[] required=true +ucloud config list use=list short=list all configurations +ucloud config update use=update short=update configurations + flag=active short= default= required= + flag=agree-upload-log short= default= required= + flag=base-url short= default= required= + flag=channel-key short= default= required= + flag=max-retry-times short= default= required= + flag=private-key short= default= required= + flag=profile short= default= required=true + flag=project-id short= default= required= + flag=public-key short= default= required= + flag=region short= default= required= + flag=timeout-sec short= default= required= + flag=zone short= default= required= +ucloud gendoc use=gendoc short=Generate documents for all commands + flag=dir short= default= required=true + flag=format short= default=douku required= +ucloud init use=init short=Initialize UCloud CLI options +ucloud project use=project short=List,create,update and delete project +ucloud project create use=create short=Create project + flag=name short= default= required=true + flag=parent-id short= default= required= +ucloud project delete use=delete short=Delete project + flag=id short= default= required=true +ucloud project list use=list short=List project +ucloud project update use=update short=Update project name + flag=id short= default= required=true + flag=name short= default= required=true +ucloud region use=region short=List all region and zone +ucloud signature use=signature short=Calculate ucloud signature + flag=param short=m default=[] required= + flag=private-key short=k default= required=true + flag=url short=u default= required= diff --git a/hack/snapshot/testdata/completion.golden b/hack/snapshot/testdata/completion.golden new file mode 100644 index 0000000000..35d00ce53f --- /dev/null +++ b/hack/snapshot/testdata/completion.golden @@ -0,0 +1,21 @@ +ucloud config active static false,true +ucloud config agree-upload-log static false,true +ucloud config profile static +ucloud config project-id dynamic +ucloud config region dynamic +ucloud config zone dynamic +ucloud config add active static false,true +ucloud config add agree-upload-log static false,true +ucloud config add profile static +ucloud config add project-id dynamic +ucloud config add region dynamic +ucloud config add zone dynamic +ucloud config delete profile static +ucloud config update active static false,true +ucloud config update agree-upload-log static false,true +ucloud config update profile static +ucloud config update project-id dynamic +ucloud config update region dynamic +ucloud config update zone dynamic +ucloud gendoc dir static +ucloud gendoc format static douku,markdown,rst diff --git a/internal/common/common_test.go b/internal/common/common_test.go new file mode 100644 index 0000000000..8bb4c2ab2c --- /dev/null +++ b/internal/common/common_test.go @@ -0,0 +1,76 @@ +package common + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestDateTimeLayout(t *testing.T) { + if DateTimeLayout != "2006-01-02/15:04:05" { + t.Fatalf("DateTimeLayout = %q", DateTimeLayout) + } +} + +func TestFormatDateTime(t *testing.T) { + want := time.Unix(int64(1700000000), 0).Format("2006-01-02/15:04:05") + if got := FormatDateTime(1700000000); got != want { + t.Fatalf("FormatDateTime(1700000000) = %q, want %q", got, want) + } +} + +func TestFormatDate(t *testing.T) { + want := time.Unix(int64(1609459200), 0).Format("2006-01-02") + got := FormatDate(1609459200) + if got != want { + t.Fatalf("FormatDate(1609459200) = %q, want %q", got, want) + } + if len(got) != len("2006-01-02") { + t.Fatalf("FormatDate(1609459200) length = %d, want %d (%q)", len(got), len("2006-01-02"), got) + } +} + +func TestIsBase64Encoded(t *testing.T) { + if !IsBase64Encoded([]byte("aGVsbG8=")) { + t.Fatalf("IsBase64Encoded(%q) = false, want true", "aGVsbG8=") + } + if IsBase64Encoded([]byte("not base64!!!")) { + t.Fatalf("IsBase64Encoded(%q) = true, want false", "not base64!!!") + } +} + +func TestGetHomePath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix-only home semantics") + } + t.Setenv("HOME", "/tmp/uctest-home") + if got := GetHomePath(); got != "/tmp/uctest-home" { + t.Fatalf("GetHomePath() = %q, want /tmp/uctest-home", got) + } +} + +func TestGetFileList(t *testing.T) { + dir := t.TempDir() + for _, n := range []string{"a.cnf", "b.txt"} { + if err := os.WriteFile(filepath.Join(dir, n), nil, 0o600); err != nil { + t.Fatal(err) + } + } + // Last token of COMP_LINE is the directory prefix to complete from. + t.Setenv("COMP_LINE", "ucloud mysql conf upload "+dir) + + got := GetFileList(".cnf") + want := filepath.Join(dir, "a.cnf") + if len(got) != 1 || got[0] != want { + t.Fatalf("GetFileList(.cnf) = %v, want [%s]", got, want) + } +} + +func TestGetFileListNoMatchOrMissingDir(t *testing.T) { + t.Setenv("COMP_LINE", "ucloud mysql conf upload /no/such/dir/xyz") + if got := GetFileList(".cnf"); got != nil { + t.Fatalf("GetFileList on missing dir = %v, want nil", got) + } +} diff --git a/internal/common/encoding.go b/internal/common/encoding.go new file mode 100644 index 0000000000..e49ff43fca --- /dev/null +++ b/internal/common/encoding.go @@ -0,0 +1,11 @@ +// encoding.go —— encoding 纯工具(stdlib-only)。 + +package common + +import "encoding/base64" + +// IsBase64Encoded 判断字节是否为合法的标准 base64 编码 +func IsBase64Encoded(data []byte) bool { + _, err := base64.StdEncoding.DecodeString(string(data)) + return err == nil +} diff --git a/internal/common/format.go b/internal/common/format.go new file mode 100644 index 0000000000..109a65ae62 --- /dev/null +++ b/internal/common/format.go @@ -0,0 +1,25 @@ +// Package common holds non-product, dependency-free utilities shared across +// the platform and the product modules. +// +// Unlike base/ (which is platform-internal and forbidden to products by §6.1), +// anything here is importable by products under products//. Keep it pure: +// no platform singletons, no SDK clients, no authentication/config state, no +// I/O beyond the standard library. See docs §2 "目录归属判据" and §4.6. +package common + +import "time" + +// DateTimeLayout is the canonical timestamp layout used across the CLI. +// Verbatim from base.DateTimeLayout. +const DateTimeLayout = "2006-01-02/15:04:05" + +// FormatDateTime formats a unix-second timestamp as DateTimeLayout. +// Verbatim from base.FormatDateTime. +func FormatDateTime(seconds int) string { + return time.Unix(int64(seconds), 0).Format("2006-01-02/15:04:05") +} + +// FormatDate 格式化时间,把以秒为单位的时间戳格式化为年月日 +func FormatDate(seconds int) string { + return time.Unix(int64(seconds), 0).Format("2006-01-02") +} diff --git a/internal/common/fs.go b/internal/common/fs.go new file mode 100644 index 0000000000..870ea51e4d --- /dev/null +++ b/internal/common/fs.go @@ -0,0 +1,61 @@ +package common + +import ( + "io/ioutil" //nolint:staticcheck // verbatim copy from base; keep ioutil for zero-behavior-change + "os" + "runtime" + "strings" +) + +// GetHomePath returns the user's home directory. +// Verbatim from base.GetHomePath. +func GetHomePath() string { + if runtime.GOOS == "windows" { + home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") + if home == "" { + home = os.Getenv("USERPROFILE") + } + return home + } + return os.Getenv("HOME") +} + +// GetFileList completes file names by suffix for shell completion, reading the +// last token of COMP_LINE as the directory prefix (with ~ expansion). +// Verbatim from base.GetFileList. +func GetFileList(suffix string) []string { + cmdLine := strings.TrimSpace(os.Getenv("COMP_LINE")) + words := strings.Split(cmdLine, " ") + last := words[len(words)-1] + pathPrefix := "." + + if !strings.HasPrefix(last, "-") { + pathPrefix = last + } + hasTilde := false + //https://tiswww.case.edu/php/chet/bash/bashref.html#Tilde-Expansion + if strings.HasPrefix(pathPrefix, "~") { + pathPrefix = strings.Replace(pathPrefix, "~", GetHomePath(), 1) + hasTilde = true + } + files, err := ioutil.ReadDir(pathPrefix) + if err != nil { + return nil + } + names := []string{} + for _, f := range files { + name := f.Name() + if !strings.HasSuffix(name, suffix) { + continue + } + if hasTilde { + pathPrefix = strings.Replace(pathPrefix, GetHomePath(), "~", 1) + } + if strings.HasSuffix(pathPrefix, "/") { + names = append(names, pathPrefix+name) + } else { + names = append(names, pathPrefix+"/"+name) + } + } + return names +} diff --git a/model/context.go b/model/context.go index 0177aa1c43..d41e3019fd 100644 --- a/model/context.go +++ b/model/context.go @@ -15,35 +15,35 @@ type Context struct { data map[string]interface{} } -//Print 打印一行 +// Print 打印一行 func (c *Context) Print(a ...interface{}) (n int, err error) { text := fmt.Sprint(a...) n, err = c.writer.Write([]byte(text)) return } -//Println 打印一行 +// Println 打印一行 func (c *Context) Println(a ...interface{}) (n int, err error) { text := fmt.Sprintln(a...) n, err = c.writer.Write([]byte(text)) return } -//Printf 根据格式字符串打印 +// Printf 根据格式字符串打印 func (c *Context) Printf(format string, a ...interface{}) (n int, err error) { text := fmt.Sprintf(format, a...) n, err = c.writer.Write([]byte(text)) return } -//PrintErr 打印错误 +// PrintErr 打印错误 func (c *Context) PrintErr(uerr error) (n int, err error) { text := fmt.Sprintf("Error:%v\n", uerr) n, err = c.writer.Write([]byte(text)) return } -//GetWriter 获取Writer +// GetWriter 获取Writer func (c *Context) GetWriter() io.Writer { return c.writer } diff --git a/pkg/cli/allregions_test.go b/pkg/cli/allregions_test.go new file mode 100644 index 0000000000..4324a9c9f0 --- /dev/null +++ b/pkg/cli/allregions_test.go @@ -0,0 +1,34 @@ +package cli_test + +import ( + "errors" + "testing" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func TestAllRegionsForwardsProviderAndError(t *testing.T) { + ctx := cli.NewContext(cli.Deps{ + AllRegions: func() ([]string, error) { return []string{"cn-bj2", "us-ca"}, nil }, + }) + regions, err := ctx.AllRegions() + if err != nil || len(regions) != 2 { + t.Fatalf("AllRegions = %v, %v; want 2 regions, nil err", regions, err) + } + + // error must propagate (so --all-region reports a region-fetch failure + // instead of silently listing nothing). + wantErr := errors.New("fetch region failed") + ctxErr := cli.NewContext(cli.Deps{ + AllRegions: func() ([]string, error) { return nil, wantErr }, + }) + if _, err := ctxErr.AllRegions(); !errors.Is(err, wantErr) { + t.Fatalf("AllRegions error = %v, want %v", err, wantErr) + } + + // nil-safe when no provider injected. + empty := cli.NewContext(cli.Deps{}) + if r, err := empty.AllRegions(); r != nil || err != nil { + t.Fatalf("nil provider: want (nil,nil), got (%v,%v)", r, err) + } +} diff --git a/pkg/cli/context.go b/pkg/cli/context.go new file mode 100644 index 0000000000..9735fc2c24 --- /dev/null +++ b/pkg/cli/context.go @@ -0,0 +1,176 @@ +package cli + +import ( + "fmt" + "io" + "sync/atomic" + + "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// OutputFormat controls how command results are rendered. +type OutputFormat int + +const ( + // OutputTable is the default tabular output (iota-zero value). + OutputTable OutputFormat = iota + // OutputJSON renders output as JSON. + OutputJSON + // OutputYAML renders output as YAML. + OutputYAML +) + +// Context is a per-invocation handle that product commands receive. +// It provides access to the authed SDK client, I/O streams, and +// common configuration. Heavy methods (NewServiceClient, PrintList, +// Confirm, Poller, flag binding) are added in later tasks (B2, B5, D1). +type Context struct { + in io.Reader + out io.Writer + err io.Writer + format OutputFormat + defaultsProvider func() command.Defaults + + // Completion candidate providers injected by the host so that bind + // helpers can register dynamic completion without pkg/command importing + // cmd or base. + regionList func() []string + zoneList func(region string) []string + projectList func() []string + // allRegions is the runtime all-region lister (returns an error, unlike the + // completion providers) for non-standard flags like uhost --all-region. + allRegions func() ([]string, error) + + clientConfig func() *ucloud.Config + buildCredential func() *auth.Credential + attachHandlers func(ucloud.ServiceClient) + + handleError func(io.Writer, error) + logInfo func(...string) + logPrint func(io.Writer, ...string) + logWarn func(io.Writer, ...string) + logError func(io.Writer, ...string) + logFilePath func() string + newPoller func(func(string, *request.CommonBase) (interface{}, error), io.Writer, ...PollerOption) Poller + + // errCount tallies HandleError calls this invocation so the host (cmd) can + // set a non-zero exit code when any product error occurred (aws/gcloud + // convention). Atomic because product commands can call HandleError from + // concurrent goroutines (e.g. uhost create's per-instance EIP binding in the + // count>5 fan-out). + errCount int32 +} + +// Deps carries constructor arguments for NewContext. +type Deps struct { + In io.Reader + Out io.Writer + Err io.Writer + Format OutputFormat + + DefaultsProvider func() command.Defaults + RegionList func() []string + ZoneList func(region string) []string + ProjectList func() []string + AllRegions func() ([]string, error) + + ClientConfig func() *ucloud.Config + BuildCredential func() *auth.Credential + AttachHandlers func(ucloud.ServiceClient) + + HandleError func(io.Writer, error) + LogInfo func(...string) + LogPrint func(io.Writer, ...string) + LogWarn func(io.Writer, ...string) + LogError func(io.Writer, ...string) + LogFilePath func() string + NewPoller func(func(string, *request.CommonBase) (interface{}, error), io.Writer, ...PollerOption) Poller +} + +// NewContext constructs a Context from the provided Deps. +func NewContext(d Deps) *Context { + if d.Out == nil { + d.Out = io.Discard + } + if d.Err == nil { + d.Err = io.Discard + } + if d.DefaultsProvider == nil { + d.DefaultsProvider = func() command.Defaults { return command.Defaults{} } + } + if d.HandleError == nil { + d.HandleError = func(w io.Writer, err error) { + if err != nil { + fmt.Fprintln(w, err) + } + } + } + if d.LogInfo == nil { + d.LogInfo = func(...string) {} + } + if d.LogPrint == nil { + d.LogPrint = func(w io.Writer, logs ...string) { + for _, line := range logs { + fmt.Fprintln(w, line) + } + } + } + if d.LogWarn == nil { + d.LogWarn = d.LogPrint + } + if d.LogError == nil { + d.LogError = d.LogPrint + } + if d.LogFilePath == nil { + d.LogFilePath = func() string { return "" } + } + if d.NewPoller == nil { + d.NewPoller = NewPoller + } + return &Context{ + in: d.In, + out: d.Out, + err: d.Err, + format: d.Format, + defaultsProvider: d.DefaultsProvider, + regionList: d.RegionList, + zoneList: d.ZoneList, + projectList: d.ProjectList, + allRegions: d.AllRegions, + clientConfig: d.ClientConfig, + buildCredential: d.BuildCredential, + attachHandlers: d.AttachHandlers, + handleError: d.HandleError, + logInfo: d.LogInfo, + logPrint: d.LogPrint, + logWarn: d.LogWarn, + logError: d.LogError, + logFilePath: d.LogFilePath, + newPoller: d.NewPoller, + } +} + +// Out returns the output writer (stdout). Machine-readable results go here. +func (c *Context) Out() io.Writer { return c.out } + +// Err returns the error/diagnostics writer (stderr). Human-facing narration +// and progress belong here so machine output on Out stays clean. +func (c *Context) Err() io.Writer { return c.err } + +// In returns the input reader. +func (c *Context) In() io.Reader { return c.in } + +// Format returns the output format requested for this invocation. +func (c *Context) Format() OutputFormat { return c.format } + +// SetFormat overrides the output format. The host (cmd) calls this from its +// PersistentPreRun once --output has been parsed, because the Context is built +// at command-registration time, before cobra parses flags. +func (c *Context) SetFormat(f OutputFormat) { c.format = f } + +// Failed reports whether any error was recorded via HandleError this invocation. +func (c *Context) Failed() bool { return atomic.LoadInt32(&c.errCount) > 0 } diff --git a/pkg/cli/context_getters_test.go b/pkg/cli/context_getters_test.go new file mode 100644 index 0000000000..87e0424bed --- /dev/null +++ b/pkg/cli/context_getters_test.go @@ -0,0 +1,80 @@ +package cli_test + +import ( + "testing" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func TestRegionZoneProjectListGetters(t *testing.T) { + ctx := cli.NewContext(cli.Deps{ + RegionList: func() []string { return []string{"cn-bj2"} }, + ZoneList: func(r string) []string { return []string{r + "-01"} }, + ProjectList: func() []string { return []string{"org-x"} }, + }) + + if got := ctx.RegionList(); len(got) != 1 || got[0] != "cn-bj2" { + t.Fatalf("RegionList = %v", got) + } + if got := ctx.ZoneList("cn-bj2"); len(got) != 1 || got[0] != "cn-bj2-01" { + t.Fatalf("ZoneList = %v", got) + } + if got := ctx.ProjectList(); len(got) != 1 || got[0] != "org-x" { + t.Fatalf("ProjectList = %v", got) + } + + // nil-safe when providers absent (non-standard-flag getters must not panic). + empty := cli.NewContext(cli.Deps{}) + if empty.RegionList() != nil || empty.ZoneList("x") != nil || empty.ProjectList() != nil { + t.Fatal("getters must be nil-safe when providers absent") + } +} + +func TestDefaultRegionProjectIDGetters(t *testing.T) { + tests := []struct { + name string + defaults command.Defaults + wantRegion string + wantZone string + wantProjectID string + }{ + { + name: "nil config is nil-safe", + wantRegion: "", + wantZone: "", + wantProjectID: "", + }, + { + name: "empty config returns empty", + defaults: command.Defaults{}, + wantRegion: "", + wantZone: "", + wantProjectID: "", + }, + { + name: "populated config returns configured values", + defaults: command.Defaults{Region: "cn-bj2", Zone: "cn-bj2-04", ProjectID: "org-x"}, + wantRegion: "cn-bj2", + wantZone: "cn-bj2-04", + wantProjectID: "org-x", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := cli.NewContext(cli.Deps{ + DefaultsProvider: func() command.Defaults { return tt.defaults }, + }) + if got := ctx.DefaultRegion(); got != tt.wantRegion { + t.Errorf("DefaultRegion() = %q, want %q", got, tt.wantRegion) + } + if got := ctx.DefaultZone(); got != tt.wantZone { + t.Errorf("DefaultZone() = %q, want %q", got, tt.wantZone) + } + if got := ctx.DefaultProjectID(); got != tt.wantProjectID { + t.Errorf("DefaultProjectID() = %q, want %q", got, tt.wantProjectID) + } + }) + } +} diff --git a/pkg/cli/context_log_test.go b/pkg/cli/context_log_test.go new file mode 100644 index 0000000000..2b2c7c3fb8 --- /dev/null +++ b/pkg/cli/context_log_test.go @@ -0,0 +1,50 @@ +package cli_test + +import ( + "bytes" + "fmt" + "io" + "strings" + "testing" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func TestContextLogForwarders(t *testing.T) { + var err bytes.Buffer + var infoCalls, printCalls, warnCalls, errorCalls int + ctx := cli.NewContext(cli.Deps{ + Err: &err, + LogInfo: func(logs ...string) { + infoCalls += len(logs) + }, + LogPrint: func(w io.Writer, logs ...string) { + printCalls += len(logs) + fmt.Fprint(w, strings.Join(logs, "\n")) + }, + LogWarn: func(w io.Writer, logs ...string) { + warnCalls += len(logs) + fmt.Fprint(w, strings.Join(logs, "\n")) + }, + LogError: func(w io.Writer, logs ...string) { + errorCalls += len(logs) + fmt.Fprint(w, strings.Join(logs, "\n")) + }, + LogFilePath: func() string { return "/tmp/cli.log" }, + }) + + ctx.LogInfo("info") + ctx.LogPrint("print") + ctx.LogWarn("warn") + ctx.LogError("err") + + if infoCalls != 1 || printCalls != 1 || warnCalls != 1 || errorCalls != 1 { + t.Fatalf("log provider calls = %d/%d/%d/%d, want all 1", infoCalls, printCalls, warnCalls, errorCalls) + } + if got := err.String(); !strings.Contains(got, "print") || !strings.Contains(got, "warn") || !strings.Contains(got, "err") { + t.Fatalf("stderr log output = %q, want print/warn/err", got) + } + if !strings.Contains(ctx.LogFilePath(), "cli.log") { + t.Fatalf("LogFilePath = %q, want it to contain cli.log", ctx.LogFilePath()) + } +} diff --git a/pkg/cli/context_test.go b/pkg/cli/context_test.go new file mode 100644 index 0000000000..3c6859e3e8 --- /dev/null +++ b/pkg/cli/context_test.go @@ -0,0 +1,42 @@ +package cli_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func TestFailedFalseUntilHandleError(t *testing.T) { + var out, errw bytes.Buffer + ctx := cli.NewContext(cli.Deps{Out: &out, Err: &errw, Format: cli.OutputJSON}) + + if ctx.Failed() { + t.Fatal("fresh context must not be Failed()") + } + ctx.HandleError(fmt.Errorf("boom")) + if !ctx.Failed() { + t.Fatal("after HandleError, Failed() must be true") + } +} + +func TestPickResourceID(t *testing.T) { + if cli.PickResourceID("udb-x/n") != "udb-x" { + t.Fatal("bad") + } +} + +func TestOutputFormatDefault(t *testing.T) { + // OutputTable must be the iota-zero value + var f cli.OutputFormat + if f != cli.OutputTable { + t.Fatal("zero-value OutputFormat should be OutputTable") + } + + // NewContext with no Format set should report OutputTable via Format() + ctx := cli.NewContext(cli.Deps{}) + if ctx.Format() != cli.OutputTable { + t.Fatal("NewContext with zero Deps should have Format() == OutputTable") + } +} diff --git a/pkg/cli/describe.go b/pkg/cli/describe.go new file mode 100644 index 0000000000..cc2dc9e7dc --- /dev/null +++ b/pkg/cli/describe.go @@ -0,0 +1,12 @@ +package cli + +// DescribeRow is a single attribute/content row for rendering single-resource +// detail views (e.g. `... describe`). When passed to Context.PrintList in table +// mode, the field names "Attribute" and "Content" become the column headers. +// +// It is defined standalone (not aliased to base.DescribeTableRow) on purpose, to +// keep pkg/cli free of a dependency on the base package. +type DescribeRow struct { + Attribute string + Content string +} diff --git a/pkg/cli/forward.go b/pkg/cli/forward.go new file mode 100644 index 0000000000..23551fb87a --- /dev/null +++ b/pkg/cli/forward.go @@ -0,0 +1,190 @@ +package cli + +import ( + "io" + "sync/atomic" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/pkg/ui" +) + +// defaults reads the per-invocation region/zone/project defaults from the host provider. +func (c *Context) defaults() command.Defaults { + if c.defaultsProvider == nil { + return command.Defaults{} + } + return c.defaultsProvider() +} + +// SetCompletion forwards to command.SetCompletion. +func (c *Context) SetCompletion(cmd *cobra.Command, name string, fn func() []string) { + command.SetCompletion(cmd, name, fn) +} + +// SetFlagValues forwards to command.SetFlagValues. +func (c *Context) SetFlagValues(cmd *cobra.Command, name string, values ...string) { + command.SetFlagValues(cmd, name, values...) +} + +// BindRegion binds --region using ctx defaults + injected region completion provider. +func (c *Context) BindRegion(cmd *cobra.Command, req request.Common) { + command.BindRegion(cmd, req, c.defaults(), c.regionList) +} + +// BindZone binds --zone using ctx defaults + injected zone completion provider. +func (c *Context) BindZone(cmd *cobra.Command, req request.Common) { + command.BindZone(cmd, req, c.defaults(), c.zoneList) +} + +// BindZoneEmpty binds --zone with empty default + injected zone completion provider. +func (c *Context) BindZoneEmpty(cmd *cobra.Command, req request.Common) { + command.BindZoneEmpty(cmd, req, c.zoneList) +} + +// BindProjectID binds --project-id using ctx defaults + injected project completion provider. +func (c *Context) BindProjectID(cmd *cobra.Command, req request.Common) { + command.BindProjectID(cmd, req, c.defaults(), c.projectList) +} + +// BindLimit binds --limit into req via reflection. +func (c *Context) BindLimit(cmd *cobra.Command, req interface{}) { command.BindLimit(cmd, req) } + +// BindOffset binds --offset into req via reflection. +func (c *Context) BindOffset(cmd *cobra.Command, req interface{}) { command.BindOffset(cmd, req) } + +// BindChargeType binds --charge-type into req via reflection. +func (c *Context) BindChargeType(cmd *cobra.Command, req interface{}) { + command.BindChargeType(cmd, req) +} + +// BindQuantity binds --quantity into req via reflection. +func (c *Context) BindQuantity(cmd *cobra.Command, req interface{}) { command.BindQuantity(cmd, req) } + +// BindGroup binds --group into req.Tag via reflection. +func (c *Context) BindGroup(cmd *cobra.Command, req interface{}) { command.BindGroup(cmd, req) } + +// RegionList / ZoneList / ProjectList expose the injected completion providers +// for non-standard flags (e.g. --target-region) where the standard Bind* +// helpers don't apply. Nil-safe: return nil when no provider was injected. +func (c *Context) RegionList() []string { + if c.regionList == nil { + return nil + } + return c.regionList() +} + +// ZoneList returns the availability zones for the given region. +func (c *Context) ZoneList(region string) []string { + if c.zoneList == nil { + return nil + } + return c.zoneList(region) +} + +// ProjectList returns the project id/name completion candidates. +func (c *Context) ProjectList() []string { + if c.projectList == nil { + return nil + } + return c.projectList() +} + +// DefaultRegion / DefaultProjectID expose the per-invocation config defaults +// (the same values Bind* helpers use) for hand-written flags where the standard +// Bind* helpers don't apply — e.g. a product command that needs the configured +// default region/project as a flag default but must NOT register region/project +// completion (mirrors the RegionList rationale). Nil-safe: empty when no config. +func (c *Context) DefaultRegion() string { + return c.defaults().Region +} + +// DefaultProjectID returns the per-invocation default project id from config. +func (c *Context) DefaultProjectID() string { + return c.defaults().ProjectID +} + +// DefaultZone returns the per-invocation default availability zone from config, +// for hand-written --zone flags that must NOT register zone completion (same +// rationale as DefaultRegion/DefaultProjectID). Nil-safe: empty when no config. +func (c *Context) DefaultZone() string { + return c.defaults().Zone +} + +// AllRegions returns every region the account can see, propagating the +// fetch error (unlike RegionList, which is for completion and drops it). Used +// by runtime fan-out flags such as uhost --all-region. Nil-safe. +func (c *Context) AllRegions() ([]string, error) { + if c.allRegions == nil { + return nil, nil + } + return c.allRegions() +} + +// BindCommonParams binds all common flags in one call using ctx defaults + +// injected completion providers. It binds region/zone/project when req +// satisfies request.Common, plus --limit/--offset/--charge-type/--quantity for +// whichever of those fields exist on req (absent fields are skipped, no panic). +func (c *Context) BindCommonParams(cmd *cobra.Command, req interface{}) { + command.BindCommonParams(cmd, req, c.defaults(), c.regionList, c.zoneList, c.projectList) +} + +// PrintList renders dataSet to the ctx writer in the ctx format. +func (c *Context) PrintList(dataSet interface{}) { + ui.Printer{Out: c.out, Format: ui.Format(c.format)}.PrintList(dataSet) +} + +// PrintJSON renders dataSet as JSON to the ctx writer. +func (c *Context) PrintJSON(dataSet interface{}) error { return ui.PrintJSON(dataSet, c.out) } + +// Confirm prompts for a yes/no confirmation, returning three outcomes: +// (true,nil) confirmed / (false,nil) declined / (false,err) non-interactive +// without --yes. Interactivity is judged on the input stream (c.in), so tests +// injecting a buffer are non-interactive. The prompt goes to the progress +// writer (stderr in json/yaml) to keep stdout machine-clean. +func (c *Context) Confirm(yes bool, text string) (bool, error) { + return ui.Confirm(c.in, c.ProgressWriter(), yes, ui.IsReaderTTY(c.in), text) +} + +// HandleError renders err (business RetCode / transport error) to stderr — never +// stdout, so machine output on stdout stays clean — and records it to the +// cli.log file / telemetry. +func (c *Context) HandleError(err error) { + atomic.AddInt32(&c.errCount, 1) + c.handleError(c.err, err) +} + +// LogInfo / LogPrint / LogWarn / LogError forward to the platform logger +// (cli.log + optional telemetry, with redaction) for non-request product +// diagnostics (warnings, errors, status). API request logging is handled +// automatically by the platform SDK handler — products do NOT log requests +// themselves (see batch-1 plan Part 0 Task 0.2 / D-C). +// LogInfo writes to the log file only (no console). LogPrint/LogWarn/LogError +// send their console copy to stderr (ctx.Err), never stdout, so machine output +// on stdout stays clean; all four still record to cli.log / telemetry. +func (c *Context) LogInfo(logs ...string) { c.logInfo(logs...) } +func (c *Context) LogPrint(logs ...string) { c.logPrint(c.err, logs...) } +func (c *Context) LogWarn(logs ...string) { c.logWarn(c.err, logs...) } +func (c *Context) LogError(logs ...string) { c.logError(c.err, logs...) } + +// LogFilePath returns the path of the CLI log file (e.g. for "check logs in …"). +func (c *Context) LogFilePath() string { return c.logFilePath() } + +// PickResourceID extracts the resource ID from a "resourceID/name" string. +func (c *Context) PickResourceID(s string) string { return PickResourceID(s) } + +// Poller returns a platform poller bound to ctx's writer. +func (c *Context) Poller(describeFunc func(string, *request.CommonBase) (interface{}, error), opts ...PollerOption) Poller { + return c.newPoller(describeFunc, c.out, opts...) +} + +// PollerTo wraps base.NewSpoller bound to an explicit writer, so callers can +// route progress narration to stderr (e.g. in json/yaml mode) while keeping +// machine output on stdout. Products cannot import base directly, so this +// exposes the writer-parameterized poller through the Context. +func (c *Context) PollerTo(w io.Writer, describeFunc func(string, *request.CommonBase) (interface{}, error), opts ...PollerOption) Poller { + return c.newPoller(describeFunc, w, opts...) +} diff --git a/pkg/cli/forward_test.go b/pkg/cli/forward_test.go new file mode 100644 index 0000000000..93f59d5a10 --- /dev/null +++ b/pkg/cli/forward_test.go @@ -0,0 +1,88 @@ +package cli_test + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func TestContextForwarders(t *testing.T) { + var buf bytes.Buffer + ctx := cli.NewContext(cli.Deps{ + Out: &buf, + Format: cli.OutputJSON, + RegionList: func() []string { return []string{"r"} }, + }) + + ctx.PrintList([]struct{ Name string }{{Name: "a"}}) + if !strings.Contains(buf.String(), `"Name"`) { + t.Fatalf("PrintList(JSON) output missing \"Name\": %q", buf.String()) + } + + if got := ctx.PickResourceID("udb-x/n"); got != "udb-x" { + t.Fatalf("PickResourceID = %q, want udb-x", got) + } +} + +// ctxFakeReq carries every optional reflection-bound field. +type ctxFakeReq struct { + request.CommonBase + Limit *int + Offset *int + ChargeType *string + Quantity *int +} + +func TestContextBindCommonParams(t *testing.T) { + ctx := cli.NewContext(cli.Deps{ + DefaultsProvider: func() command.Defaults { + return command.Defaults{Region: "cn-bj2", Zone: "cn-bj2-02", ProjectID: "org-x"} + }, + RegionList: func() []string { return []string{"cn-bj2"} }, + ZoneList: func(region string) []string { return []string{region} }, + ProjectList: func() []string { return []string{"org-x"} }, + }) + + // Full request: every common flag must be registered, ctx defaults applied. + cmd := &cobra.Command{Use: "x"} + req := &ctxFakeReq{} + ctx.BindCommonParams(cmd, req) + + for _, name := range []string{"region", "zone", "project-id", "limit", "offset", "charge-type", "quantity"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag %q not registered", name) + } + } + if f := cmd.Flags().Lookup("region"); f == nil || f.DefValue != "cn-bj2" { + t.Fatalf("region default not taken from ctx config: %+v", f) + } + + // Request satisfying only request.Common: list/charge flags skipped, no panic. + cmdCommon := &cobra.Command{Use: "y"} + ctx.BindCommonParams(cmdCommon, &request.CommonBase{}) + for _, name := range []string{"limit", "offset", "charge-type", "quantity"} { + if cmdCommon.Flags().Lookup(name) != nil { + t.Errorf("flag %q registered for plain CommonBase, want skipped", name) + } + } +} + +func TestContextPollerToReturnsProductCompatiblePoller(t *testing.T) { + ctx := cli.NewContext(cli.Deps{ + NewPoller: func(describe func(string, *request.CommonBase) (interface{}, error), out io.Writer, opts ...cli.PollerOption) cli.Poller { + return cli.NewPoller(describe, out, opts...) + }, + }) + + ctx.PollerTo(io.Discard, func(string, *request.CommonBase) (interface{}, error) { + return struct{ State string }{State: "RUNNING"}, nil + }).Spoll("res-1", "creating", []string{"RUNNING"}) +} diff --git a/pkg/cli/poller.go b/pkg/cli/poller.go new file mode 100644 index 0000000000..8a9b9fa13e --- /dev/null +++ b/pkg/cli/poller.go @@ -0,0 +1,198 @@ +package cli + +import ( + "fmt" + "io" + "reflect" + "time" + + "github.com/ucloud/ucloud-sdk-go/ucloud/helpers/waiter" + "github.com/ucloud/ucloud-sdk-go/ucloud/log" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/ui" +) + +type PollResult struct { + Done bool + Timeout bool + Err error +} + +type Poller interface { + Spoll(resourceID, pollText string, targetStates []string) + Sspoll(resourceID, pollText string, targetStates []string, block *Block, common *request.CommonBase) *PollResult +} + +type poller struct { + describe func(string, *request.CommonBase) (interface{}, error) + out io.Writer + stateFields []string + commandTimeout time.Duration + timeout time.Duration +} + +// builtinPollTimeout 是同步轮询的内置兜底总超时。 +const builtinPollTimeout = 10 * time.Minute + +// userPollTimeout 是用户经 --wait-timeout-sec 指定的轮询超时(cmd 层启动时注入)。 +// 0 表示用户未指定。它是全局最高优先级,覆盖任何命令自设的默认。 +var userPollTimeout time.Duration + +// SetUserPollTimeout 设置用户层轮询超时(cmd 层接 --wait-timeout-sec)。 +// 非正值被忽略:SDK 的 waiter 在 Timeout==0 时直接报错(errTimeoutConf), +// 故此时保留其余层级,不将其置 0。 +func SetUserPollTimeout(d time.Duration) { + if d > 0 { + userPollTimeout = d + } +} + +// effectivePollTimeout 按 用户 > 命令自设 > 内置 的优先级裁决最终超时。 +func effectivePollTimeout(commandTimeout time.Duration) time.Duration { + if userPollTimeout > 0 { + return userPollTimeout + } + if commandTimeout > 0 { + return commandTimeout + } + return builtinPollTimeout +} + +// PollerOption 定制单个 poller 的创建。 +type PollerOption func(*poller) + +// WithTimeout 让单个命令声明自己的默认轮询超时。非正值被忽略。 +// 优先级低于用户 --wait-timeout-sec、高于内置默认(见 effectivePollTimeout)。 +func WithTimeout(d time.Duration) PollerOption { + return func(p *poller) { + if d > 0 { + p.commandTimeout = d + } + } +} + +func NewPoller(describe func(string, *request.CommonBase) (interface{}, error), out io.Writer, opts ...PollerOption) Poller { + p := &poller{ + describe: describe, + out: out, + stateFields: []string{"State", "Status"}, + } + for _, o := range opts { + o(p) + } + p.timeout = effectivePollTimeout(p.commandTimeout) + return p +} + +func (p *poller) Spoll(resourceID, pollText string, targetStates []string) { + done := make(chan bool) + go func() { + if _, err := p.wait(resourceID, targetStates, nil); err != nil { + log.Error(err) + if _, ok := err.(*waiter.TimeoutError); ok { + done <- false + return + } + } + done <- true + }() + + if !ui.IsTTY(p.out) { + if <-done { + fmt.Fprintf(p.out, "%s...done\n", pollText) + } else { + fmt.Fprintf(p.out, "%s...timeout\n", pollText) + } + return + } + spinner := ui.NewDotSpinner(p.out) + spinner.Start(pollText) + ret := <-done + if ret { + spinner.Stop() + } else { + spinner.Timeout() + } +} + +func (p *poller) Sspoll(resourceID, pollText string, targetStates []string, block *Block, common *request.CommonBase) *PollResult { + pollRetChan := make(chan PollResult) + go func() { + ret := PollResult{Done: true} + if _, err := p.wait(resourceID, targetStates, common); err != nil { + ret.Done = false + ret.Err = err + if _, ok := err.(*waiter.TimeoutError); ok { + ret.Timeout = true + } + } + pollRetChan <- ret + }() + + if !ui.IsTTY(p.out) { + ret := <-pollRetChan + if ret.Timeout { + fmt.Fprintf(p.out, "%s...timeout\n", pollText) + } else { + fmt.Fprintf(p.out, "%s...done\n", pollText) + } + return &ret + } + + spin := ui.NewDotSpin(p.out, pollText) + if block != nil { + _ = block.SetSpin(spin) + } + ret := <-pollRetChan + if ret.Timeout { + spin.Timeout() + } else { + spin.Stop() + } + return &ret +} + +func (p *poller) wait(resourceID string, targetStates []string, common *request.CommonBase) (interface{}, error) { + w := waiter.StateWaiter{ + Pending: []string{"pending"}, + Target: []string{"avaliable"}, + Refresh: func() (interface{}, string, error) { + inst, err := p.describe(resourceID, common) + if err != nil { + return nil, "", err + } + if inst == nil { + return nil, "pending", nil + } + state, err := p.state(inst) + if err != nil { + return nil, "", err + } + for _, target := range targetStates { + if target == state { + return inst, "avaliable", nil + } + } + return nil, "pending", nil + }, + Timeout: p.timeout, + } + return w.Wait() +} + +func (p *poller) state(inst interface{}) (string, error) { + instValue := reflect.Indirect(reflect.ValueOf(inst)) + if instValue.Kind() != reflect.Struct { + return "", fmt.Errorf("Instance is not struct") + } + instType := instValue.Type() + for i := 0; i < instValue.NumField(); i++ { + for _, sf := range p.stateFields { + if instType.Field(i).Name == sf { + return instValue.Field(i).String(), nil + } + } + } + return "", nil +} diff --git a/pkg/cli/poller_test.go b/pkg/cli/poller_test.go new file mode 100644 index 0000000000..b0e599533b --- /dev/null +++ b/pkg/cli/poller_test.go @@ -0,0 +1,157 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/ui" +) + +type pollerDoneInstance struct { + State string +} + +func TestPollerSpollNonTTYDone(t *testing.T) { + describeFunc := func(resourceID string, _ *request.CommonBase) (interface{}, error) { + return &pollerDoneInstance{State: "DONE"}, nil + } + + buf := &bytes.Buffer{} + NewPoller(describeFunc, buf).Spoll("res-001", "creating", []string{"DONE"}) + + out := buf.String() + if !strings.Contains(out, "creating...done\n") { + t.Errorf("expected 'creating...done\\n' in output, got: %q", out) + } + if strings.ContainsRune(out, '⣾') { + t.Errorf("spinner frame rune '⣾' leaked into non-TTY output: %q", out) + } +} + +func TestPollerSpollNonTTYNoSpinnerFrames(t *testing.T) { + describeFunc := func(resourceID string, _ *request.CommonBase) (interface{}, error) { + return &pollerDoneInstance{State: "ACTIVE"}, nil + } + + buf := &bytes.Buffer{} + NewPoller(describeFunc, buf).Spoll("res-003", "activating", []string{"ACTIVE"}) + + out := buf.String() + if !strings.Contains(out, "activating...done\n") { + t.Errorf("expected 'activating...done\\n' in output, got: %q", out) + } + for _, r := range []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} { + if strings.ContainsRune(out, r) { + t.Errorf("spinner frame rune %q leaked into non-TTY output: %q", r, out) + } + } +} + +func TestPollerSspollNonTTYDone(t *testing.T) { + describeFunc := func(resourceID string, _ *request.CommonBase) (interface{}, error) { + return &pollerDoneInstance{State: "DONE"}, nil + } + + buf := &bytes.Buffer{} + ret := NewPoller(describeFunc, buf).Sspoll("res-001", "creating", []string{"DONE"}, ui.NewBlock(), &request.CommonBase{}) + + if ret == nil || !ret.Done { + t.Fatalf("Sspoll non-TTY: want Done=true, got %+v", ret) + } + out := buf.String() + if !strings.Contains(out, "creating...done\n") { + t.Errorf("expected 'creating...done\\n' in output, got: %q", out) + } + for _, r := range []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} { + if strings.ContainsRune(out, r) { + t.Errorf("spinner frame %q leaked into non-TTY Sspoll output: %q", r, out) + } + } +} + +// pollNoop 是仅用于构造 poller 的空 describe 函数。 +func pollNoop(string, *request.CommonBase) (interface{}, error) { return nil, nil } + +func TestNewPollerDefaultTimeout(t *testing.T) { + p, ok := NewPoller(pollNoop, &bytes.Buffer{}).(*poller) + if !ok { + t.Fatalf("NewPoller did not return *poller") + } + if p.timeout != 10*time.Minute { + t.Errorf("default poll timeout = %v, want 10m", p.timeout) + } +} + +func TestSetUserPollTimeoutOverride(t *testing.T) { + defer func() { userPollTimeout = 0 }() // 直接复位;SetUserPollTimeout(0) 会被守卫忽略 + SetUserPollTimeout(30 * time.Minute) + p := NewPoller(pollNoop, &bytes.Buffer{}).(*poller) + if p.timeout != 30*time.Minute { + t.Errorf("after SetUserPollTimeout(30m), timeout = %v, want 30m", p.timeout) + } +} + +func TestSetUserPollTimeoutIgnoresNonPositive(t *testing.T) { + defer func() { userPollTimeout = 0 }() + SetUserPollTimeout(30 * time.Minute) + SetUserPollTimeout(0) // 忽略 + SetUserPollTimeout(-5 * time.Minute) // 忽略 + p := NewPoller(pollNoop, &bytes.Buffer{}).(*poller) + if p.timeout != 30*time.Minute { + t.Errorf("non-positive SetUserPollTimeout must be ignored, timeout = %v, want 30m", p.timeout) + } +} + +func TestEffectivePollTimeoutPriority(t *testing.T) { + defer func() { userPollTimeout = 0 }() + + // 都未设 → builtin + userPollTimeout = 0 + if got := effectivePollTimeout(0); got != 10*time.Minute { + t.Errorf("no user, no command: got %v, want 10m", got) + } + // 仅命令自设 → command + userPollTimeout = 0 + if got := effectivePollTimeout(20 * time.Minute); got != 20*time.Minute { + t.Errorf("command only: got %v, want 20m", got) + } + // 用户已设 → user 覆盖命令 + userPollTimeout = 15 * time.Minute + if got := effectivePollTimeout(20 * time.Minute); got != 15*time.Minute { + t.Errorf("user overrides command: got %v, want 15m", got) + } +} + +func TestWithTimeoutSetsCommandTimeout(t *testing.T) { + defer func() { userPollTimeout = 0 }() + userPollTimeout = 0 + p := NewPoller(pollNoop, &bytes.Buffer{}, WithTimeout(30*time.Minute)).(*poller) + if p.commandTimeout != 30*time.Minute { + t.Errorf("commandTimeout = %v, want 30m", p.commandTimeout) + } + if p.timeout != 30*time.Minute { + t.Errorf("effective timeout with command option = %v, want 30m", p.timeout) + } +} + +func TestWithTimeoutIgnoresNonPositive(t *testing.T) { + defer func() { userPollTimeout = 0 }() + userPollTimeout = 0 + p := NewPoller(pollNoop, &bytes.Buffer{}, WithTimeout(0), WithTimeout(-5*time.Minute)).(*poller) + if p.timeout != 10*time.Minute { + t.Errorf("non-positive WithTimeout must be ignored, timeout = %v, want builtin 10m", p.timeout) + } +} + +func TestUserFlagOverridesCommandOption(t *testing.T) { + defer func() { userPollTimeout = 0 }() + SetUserPollTimeout(20 * time.Minute) + p := NewPoller(pollNoop, &bytes.Buffer{}, WithTimeout(30*time.Minute)).(*poller) + if p.timeout != 20*time.Minute { + t.Errorf("user flag must override command option: timeout = %v, want 20m", p.timeout) + } +} diff --git a/pkg/cli/product.go b/pkg/cli/product.go new file mode 100644 index 0000000000..769f22a89d --- /dev/null +++ b/pkg/cli/product.go @@ -0,0 +1,23 @@ +package cli + +import "github.com/spf13/cobra" + +// Metadata identifies a product and its owners. +// Commands declares the top-level command names this product claims. It is the +// basis for golden partitioning: the platform golden (hack/snapshot/testdata) +// prunes exactly these subtrees, and the product's own goldens +// (products//testdata) cover them. It must match product.yaml (rule-8). +// The actual cobra command trees are built by NewCommand. +type Metadata struct { + Name string + Owners []string + Commands []string + Version string +} + +// Product is a self-contained product module the platform registers. +// NewCommand builds all top-level cobra command subtrees this product owns. +type Product interface { + Metadata() Metadata + NewCommand(ctx *Context) []*cobra.Command +} diff --git a/pkg/cli/progress.go b/pkg/cli/progress.go new file mode 100644 index 0000000000..346d2d741b --- /dev/null +++ b/pkg/cli/progress.go @@ -0,0 +1,112 @@ +package cli + +import ( + "fmt" + "io" + "sync" + "time" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/ui" +) + +// Block is the platform alias for ui.Block, so product packages get the +// concurrent-progress block type without importing platform internals. +type Block = ui.Block + +// Progress is a per-invocation concurrent-progress session bound to the ctx +// progress writer (stdout in table mode, stderr in json/yaml). +type Progress struct { + out io.Writer + doc *ui.Document +} + +// NewProgress builds a Progress bound to the ctx progress writer. Non-TTY +// writers suppress animation (handled inside ui.NewDocument). +func (c *Context) NewProgress() *Progress { + w := c.ProgressWriter() + return &Progress{out: w, doc: ui.NewDocument(w)} +} + +// Disable switches off per-block animation (the count>5 aggregate path). +func (p *Progress) Disable() { p.doc.Disable() } + +// Animated reports whether the progress document renders live frames. It is +// false when the bound writer is not a TTY (pipe/file/json mode) or Disable() +// was called for the aggregate count>5 path. When false, block content is never +// shown, so callers must surface errors to stderr (ctx.Err()) themselves. +func (p *Progress) Animated() bool { return !p.doc.Disabled() } + +// NewBlock appends a fresh block to the document and returns it. +func (p *Progress) NewBlock() *Block { + b := ui.NewBlock() + p.doc.Append(b) + return b +} + +// Refresh prints an aggregate counter line to the progress writer. +func (p *Progress) Refresh(text string) { ui.NewRefresh(p.out).Do(text) } + +// Sspoll runs the concurrent poller into block, bound to the progress writer. +func (p *Progress) Sspoll(describe func(string, *request.CommonBase) (interface{}, error), + resourceID, text string, targetStates []string, block *Block, common *request.CommonBase, opts ...PollerOption) { + NewPoller(describe, p.out, opts...).Sspoll(resourceID, text, targetStates, block, common) +} + +// ConcurrentAction runs actionFunc over reqs with bounded concurrency (limit), +// aggregating a refresh counter when count>5. It is a verbatim port of +// cmd/util.go concurrentAction, rebound to the ctx progress writer. Products +// call this instead of touching platform internals. +func (c *Context) ConcurrentAction(reqs []request.Common, limit int, actionFunc func(request.Common) (bool, []string)) { + if limit <= 0 { + limit = 10 + } + w := c.ProgressWriter() + refresh := ui.NewRefresh(w) + count := len(reqs) + var wg sync.WaitGroup + result := make(chan bool) + tokens := make(chan bool, limit) // 控制并发量,最多 limit 个并发 + success, fail := 0, 0 + + // 同时执行任务数量大于 5 时,不再单独显示每个任务,而是聚合显示。 + if count > 5 { + refresh.Do(fmt.Sprintf("total:%d, doing:%d, success:%d, fail:%d", count, len(tokens), success, fail)) + } + go func() { + for { + select { + case ret := <-result: + if ret { + success++ + } else { + fail++ + } + case <-time.Tick(time.Second / 30): + if count == (success+fail) && fail > 0 { + fmt.Fprintf(w, "Check logs in %s\n", c.LogFilePath()) + return + } + if count > 5 { + refresh.Do(fmt.Sprintf("total:%d, doing:%d, success:%d, fail:%d", count, len(tokens), success, fail)) + } + } + } + }() + + for _, req := range reqs { + wg.Add(1) + go func(req request.Common) { + tokens <- true + ok, logs := actionFunc(req) + result <- ok + logs = append([]string{"========================================"}, logs...) + c.LogInfo(logs...) + <-tokens + time.Sleep(time.Second / 5) + wg.Done() + }(req) + } + wg.Wait() +} diff --git a/pkg/cli/progress_test.go b/pkg/cli/progress_test.go new file mode 100644 index 0000000000..13a2eceac9 --- /dev/null +++ b/pkg/cli/progress_test.go @@ -0,0 +1,62 @@ +package cli_test + +import ( + "bytes" + "strings" + "sync/atomic" + "testing" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func TestProgressRefreshWritesToCtxWriter(t *testing.T) { + var out bytes.Buffer + ctx := cli.NewContext(cli.Deps{Out: &out, Err: &out, Format: cli.OutputTable}) + + p := ctx.NewProgress() + if p == nil { + t.Fatal("NewProgress returned nil") + } + p.Refresh("total:2, success:1, fail:0") + + if !strings.Contains(out.String(), "total:2") { + t.Fatalf("Refresh did not write to ctx writer: %q", out.String()) + } +} + +func TestProgressNewBlock(t *testing.T) { + var out bytes.Buffer + ctx := cli.NewContext(cli.Deps{Out: &out, Format: cli.OutputJSON}) + + p := ctx.NewProgress() + if b := p.NewBlock(); b == nil { + t.Fatal("NewBlock returned nil") + } +} + +func TestConcurrentActionRunsAllReqs(t *testing.T) { + t.Setenv("COMP_LINE", "1") // base.LogInfo becomes a no-op (uninitialized global logger otherwise panics) + var out bytes.Buffer + ctx := cli.NewContext(cli.Deps{ + Out: &out, + Err: &out, + Format: cli.OutputJSON, + LogInfo: func(...string) {}, + LogFilePath: func() string { return "/tmp/cli.log" }, + }) + + var n int32 + actionFunc := func(req request.Common) (bool, []string) { + atomic.AddInt32(&n, 1) + return true, nil + } + reqs := []request.Common{&request.CommonBase{}, &request.CommonBase{}, &request.CommonBase{}} + + ctx.ConcurrentAction(reqs, 2, actionFunc) + + if got := atomic.LoadInt32(&n); got != 3 { + t.Fatalf("ConcurrentAction ran actionFunc %d times, want 3", got) + } +} diff --git a/pkg/cli/result.go b/pkg/cli/result.go new file mode 100644 index 0000000000..e5f94d40b7 --- /dev/null +++ b/pkg/cli/result.go @@ -0,0 +1,41 @@ +package cli + +import "io" + +// OpResultRow is the platform-standard structured result of a write command +// (create/delete/start/stop/resize/...). It is emitted only in machine +// (json/yaml) modes via EmitResult; in table mode the human narration on stdout +// is the result. Field names are the JSON keys (no json tags), matching the +// CLI's existing row convention. Products use this instead of each defining +// their own OpResultRow (see batch-1 plan D-A; promoted from products/udb). +type OpResultRow struct { + ResourceID string + Action string + Status string +} + +// ProgressWriter returns the writer for human-facing narration and progress: +// +// - Table mode: stdout, so the interactive experience is unchanged. +// - JSON/YAML mode: stderr, so stdout carries only the structured result and +// stays machine-parseable (gcloud convention: progress on stderr, result on +// stdout). +func (c *Context) ProgressWriter() io.Writer { + if c.format == OutputTable { + return c.out + } + return c.err +} + +// EmitResult prints structured operation-result rows to stdout, but only in +// machine (json/yaml) modes. In table mode it is a no-op: the human narration +// already written to stdout is the result, so no extra table is added. +func (c *Context) EmitResult(rows ...OpResultRow) { + if c.format == OutputTable { + return + } + if rows == nil { + rows = []OpResultRow{} + } + c.PrintList(rows) +} diff --git a/pkg/cli/result_test.go b/pkg/cli/result_test.go new file mode 100644 index 0000000000..1f9579baa6 --- /dev/null +++ b/pkg/cli/result_test.go @@ -0,0 +1,77 @@ +package cli_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func TestEmitResultJSONWritesStructuredRowToStdout(t *testing.T) { + var out bytes.Buffer + ctx := cli.NewContext(cli.Deps{Out: &out, Format: cli.OutputJSON}) + + ctx.EmitResult(cli.OpResultRow{ResourceID: "eip-abc", Action: "allocate", Status: "Available"}) + + s := out.String() + for _, want := range []string{"eip-abc", "allocate", "Available", `"ResourceID"`, `"Action"`, `"Status"`} { + if !strings.Contains(s, want) { + t.Fatalf("EmitResult(JSON) missing %q in %q", want, s) + } + } +} + +func TestEmitResultTableIsNoOp(t *testing.T) { + var out bytes.Buffer + ctx := cli.NewContext(cli.Deps{Out: &out, Format: cli.OutputTable}) + + ctx.EmitResult(cli.OpResultRow{ResourceID: "eip-abc", Action: "allocate", Status: "Available"}) + + if out.Len() != 0 { + t.Fatalf("EmitResult(table) must be a no-op, got %q", out.String()) + } +} + +func TestProgressWriterRoutesByFormat(t *testing.T) { + var out, err bytes.Buffer + + table := cli.NewContext(cli.Deps{Out: &out, Err: &err, Format: cli.OutputTable}) + if table.ProgressWriter() != table.Out() { + t.Fatal("table mode: ProgressWriter must route to Out (stdout)") + } + + js := cli.NewContext(cli.Deps{Out: &out, Err: &err, Format: cli.OutputJSON}) + if js.ProgressWriter() != js.Err() { + t.Fatal("json mode: ProgressWriter must route to Err (stderr)") + } + + yml := cli.NewContext(cli.Deps{Out: &out, Err: &err, Format: cli.OutputYAML}) + if yml.ProgressWriter() != yml.Err() { + t.Fatal("yaml mode: ProgressWriter must route to Err (stderr)") + } +} + +func TestEmitResultJSONEmptyRowsIsEmptyArrayNotNull(t *testing.T) { + var out bytes.Buffer + ctx := cli.NewContext(cli.Deps{Out: &out, Format: cli.OutputJSON}) + + var rows []cli.OpResultRow // nil slice — the all-failed case + ctx.EmitResult(rows...) + + if got := out.String(); got != "[]\n" { + t.Fatalf("EmitResult(JSON) empty must be %q, got %q", "[]\n", got) + } +} + +func TestEmitResultYAMLEmptyRowsIsEmptyArray(t *testing.T) { + var out bytes.Buffer + ctx := cli.NewContext(cli.Deps{Out: &out, Format: cli.OutputYAML}) + + var rows []cli.OpResultRow + ctx.EmitResult(rows...) + + if got := out.String(); got != "[]\n" { + t.Fatalf("EmitResult(YAML) empty must be %q, got %q", "[]\n", got) + } +} diff --git a/pkg/cli/schema.go b/pkg/cli/schema.go new file mode 100644 index 0000000000..d82afce6f7 --- /dev/null +++ b/pkg/cli/schema.go @@ -0,0 +1,58 @@ +package cli + +import ( + "encoding/json" + "sort" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// SchemaFlag describes one flag in the schema. +type SchemaFlag struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Default string `json:"default,omitempty"` + Usage string `json:"usage,omitempty"` + Required bool `json:"required"` +} + +// SchemaCommand describes one command in the schema. +type SchemaCommand struct { + Path string `json:"path"` + Use string `json:"use"` + Short string `json:"short,omitempty"` + Flags []SchemaFlag `json:"flags,omitempty"` +} + +// RenderSchemaJSON walks the command tree (sorted, deterministic) and returns +// a JSON array of SchemaCommand. Hidden/internal commands ARE included. +func RenderSchemaJSON(root *cobra.Command) (string, error) { + var cmds []SchemaCommand + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + sc := SchemaCommand{Path: c.CommandPath(), Use: c.Use, Short: c.Short} + var fs []*pflag.Flag + c.Flags().VisitAll(func(f *pflag.Flag) { fs = append(fs, f) }) + sort.Slice(fs, func(i, j int) bool { return fs[i].Name < fs[j].Name }) + for _, f := range fs { + required := false + if rs, ok := f.Annotations[cobra.BashCompOneRequiredFlag]; ok && len(rs) > 0 && rs[0] == "true" { + required = true + } + sc.Flags = append(sc.Flags, SchemaFlag{Name: f.Name, Shorthand: f.Shorthand, Default: f.DefValue, Usage: f.Usage, Required: required}) + } + cmds = append(cmds, sc) + children := c.Commands() + sort.Slice(children, func(i, j int) bool { return children[i].Use < children[j].Use }) + for _, x := range children { + walk(x) + } + } + walk(root) + b, err := json.MarshalIndent(cmds, "", " ") + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/pkg/cli/serviceclient.go b/pkg/cli/serviceclient.go new file mode 100644 index 0000000000..612a75c4dc --- /dev/null +++ b/pkg/cli/serviceclient.go @@ -0,0 +1,21 @@ +package cli + +import ( + ucloud "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" +) + +// NewServiceClient returns an authed SDK service client for the active profile. +// The host injects the credential + handler path, so oauth and AK/SK profiles +// still share one code path (§9: no auth regression). ctor is e.g. udb.NewClient. +// +// Go methods cannot have type parameters, so this is a package-level generic +// function rather than a *Context method. +func NewServiceClient[T ucloud.ServiceClient](ctx *Context, ctor func(*ucloud.Config, *auth.Credential) T) T { + if ctx == nil || ctx.clientConfig == nil || ctx.buildCredential == nil || ctx.attachHandlers == nil { + panic("cli.NewServiceClient called without service-client dependencies") + } + c := ctor(ctx.clientConfig(), ctx.buildCredential()) + ctx.attachHandlers(c) + return c +} diff --git a/pkg/cli/serviceclient_test.go b/pkg/cli/serviceclient_test.go new file mode 100644 index 0000000000..d89bb458f8 --- /dev/null +++ b/pkg/cli/serviceclient_test.go @@ -0,0 +1,41 @@ +package cli_test + +import ( + "testing" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-sdk-go/services/udb" + "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" +) + +func TestNewServiceClientUsesInjectedProviders(t *testing.T) { + attached := false + ctx := cli.NewContext(cli.Deps{ + ClientConfig: func() *ucloud.Config { return &ucloud.Config{} }, + BuildCredential: func() *auth.Credential { + return &auth.Credential{PublicKey: "pk", PrivateKey: "sk"} + }, + AttachHandlers: func(sc ucloud.ServiceClient) { + attached = true + }, + }) + + c := cli.NewServiceClient(ctx, udb.NewClient) + if c == nil { + t.Fatal("NewServiceClient returned nil") + } + if !attached { + t.Fatal("NewServiceClient did not call AttachHandlers provider") + } +} + +func TestNewServiceClientRequiresInjectedProviders(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("NewServiceClient without providers did not panic") + } + }() + + _ = cli.NewServiceClient(cli.NewContext(cli.Deps{}), udb.NewClient) +} diff --git a/pkg/cli/util.go b/pkg/cli/util.go new file mode 100644 index 0000000000..5d190ec0f0 --- /dev/null +++ b/pkg/cli/util.go @@ -0,0 +1,41 @@ +package cli + +import ( + "fmt" + "io/ioutil" //nolint:staticcheck // keep ioutil for zero-behavior-change verbatim copy + "strings" + + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" +) + +// PickResourceID extracts the resource ID from a "resourceID/name" string. +// Example: "uhost-xxx/uhost-name" => "uhost-xxx" +func PickResourceID(str string) string { + if strings.Index(str, "/") > -1 { + return strings.SplitN(str, "/", 2)[0] + } + return str +} + +// ParseError converts an error to a human-readable string. +func ParseError(err error) string { + if uErr, ok := err.(uerr.Error); ok && uErr.Code() != 0 { + format := "Something wrong. RetCode:%d. Message:%s" + message := uErr.Message() + if uErr.Code() == -1 || uErr.Code() == -2 { + message = "request timeout, retry later please" + } + return fmt.Sprintf(format, uErr.Code(), message) + } + return fmt.Sprintf("Error:%v", err) +} + +// ReadFile reads the contents of the named file and returns them as a string. +// Relocated verbatim from cmd/ulb.go readFile with exported name. +func ReadFile(file string) (string, error) { + byts, err := ioutil.ReadFile(file) + if err != nil { + return "", err + } + return string(byts), nil +} diff --git a/pkg/command/bind.go b/pkg/command/bind.go new file mode 100644 index 0000000000..7f9e4e43bf --- /dev/null +++ b/pkg/command/bind.go @@ -0,0 +1,145 @@ +package command + +import ( + "reflect" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" +) + +// Defaults carries the per-invocation default region/zone/project for flag binding. +type Defaults struct { + Region string + Zone string + ProjectID string +} + +// BindRegion binds a --region flag whose value is shared with req via SetRegionRef. +func BindRegion(cmd *cobra.Command, req request.Common, def Defaults, regionList func() []string) { + var region string + cmd.Flags().StringVar(®ion, "region", def.Region, "Optional. Override default region for this command invocation, see 'ucloud region'") + SetCompletion(cmd, "region", regionList) + req.SetRegionRef(®ion) +} + +// BindRegionS binds a --region flag into the caller-provided region pointer. +func BindRegionS(cmd *cobra.Command, region *string, def Defaults, regionList func() []string) { + *region = def.Region + cmd.Flags().StringVar(region, "region", def.Region, "Optional. Override default region for this command invocation, see 'ucloud region'") + SetCompletion(cmd, "region", regionList) +} + +// BindZone binds a --zone flag (default = def.Zone) whose completion is +// zoneList(req.GetRegion()) evaluated lazily. +func BindZone(cmd *cobra.Command, req request.Common, def Defaults, zoneList func(region string) []string) { + var zone string + cmd.Flags().StringVar(&zone, "zone", def.Zone, "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + SetCompletion(cmd, "zone", func() []string { return zoneList(req.GetRegion()) }) + req.SetZoneRef(&zone) +} + +// BindZoneEmpty is like BindZone but the default is "" (matches cmd's bindZoneEmpty). +func BindZoneEmpty(cmd *cobra.Command, req request.Common, zoneList func(region string) []string) { + var zone string + cmd.Flags().StringVar(&zone, "zone", "", "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + SetCompletion(cmd, "zone", func() []string { return zoneList(req.GetRegion()) }) + req.SetZoneRef(&zone) +} + +// BindProjectID binds a --project-id flag shared with req via SetProjectIdRef. +func BindProjectID(cmd *cobra.Command, req request.Common, def Defaults, projectList func() []string) { + var project string + cmd.Flags().StringVar(&project, "project-id", def.ProjectID, "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + SetCompletion(cmd, "project-id", projectList) + req.SetProjectIdRef(&project) +} + +// BindProjectIDS binds a --project-id flag into the caller-provided project pointer. +func BindProjectIDS(cmd *cobra.Command, project *string, def Defaults, projectList func() []string) { + *project = def.ProjectID + cmd.Flags().StringVar(project, "project-id", def.ProjectID, "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + SetCompletion(cmd, "project-id", projectList) +} + +// BindLimit binds a --limit flag into req.Limit via reflection. +func BindLimit(cmd *cobra.Command, req interface{}) { + limit := cmd.Flags().Int("limit", 100, "Optional. The maximum number of resources per page") + reflect.ValueOf(req).Elem().FieldByName("Limit").Set(reflect.ValueOf(limit)) +} + +// BindOffset binds a --offset flag into req.Offset via reflection. +func BindOffset(cmd *cobra.Command, req interface{}) { + offset := cmd.Flags().Int("offset", 0, "Optional. The index(a number) of resource which start to list") + reflect.ValueOf(req).Elem().FieldByName("Offset").Set(reflect.ValueOf(offset)) +} + +// BindChargeType binds a --charge-type flag into req.ChargeType via reflection. +func BindChargeType(cmd *cobra.Command, req interface{}) { + chargeType := cmd.Flags().String("charge-type", "Month", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly; 'Dynamic', pay hourly; 'Trial', free trial(need permission)") + reflect.ValueOf(req).Elem().FieldByName("ChargeType").Set(reflect.ValueOf(chargeType)) + SetFlagValues(cmd, "charge-type", "Month", "Dynamic", "Year") +} + +// BindQuantity binds a --quantity flag into req.Quantity via reflection. +func BindQuantity(cmd *cobra.Command, req interface{}) { + quantity := cmd.Flags().Int("quantity", 1, "Optional. The duration of the instance. N years/months.") + reflect.ValueOf(req).Elem().FieldByName("Quantity").Set(reflect.ValueOf(quantity)) +} + +// BindGroup binds a --group flag into req.Tag via reflection (verbatim from +// cmd/util.go bindGroup; req must have a settable `Tag *string` field). +func BindGroup(cmd *cobra.Command, req interface{}) { + group := cmd.Flags().String("group", "", "Optional. Business group") + reflect.ValueOf(req).Elem().FieldByName("Tag").Set(reflect.ValueOf(group)) +} + +// hasField reports whether req (a pointer to a struct) has a settable field +// with the given name. It is used to guard optional reflection-bound flags so +// that a req lacking the field is simply skipped instead of panicking. +func hasField(req interface{}, name string) bool { + v := reflect.ValueOf(req) + if v.Kind() != reflect.Ptr || v.IsNil() { + return false + } + v = v.Elem() + if v.Kind() != reflect.Struct { + return false + } + f := v.FieldByName(name) + return f.IsValid() && f.CanSet() +} + +// BindCommonParams binds all common flags onto cmd in a single call. +// +// It always binds region/zone/project when req satisfies request.Common, +// reusing the presence-safe per-field binders. It then binds the optional +// list/charge flags (--limit/--offset/--charge-type/--quantity) ONLY for the +// fields that actually exist on req, so a request lacking them does not panic. +func BindCommonParams( + cmd *cobra.Command, + req interface{}, + def Defaults, + regionList func() []string, + zoneList func(region string) []string, + projectList func() []string, +) { + if common, ok := req.(request.Common); ok { + BindRegion(cmd, common, def, regionList) + BindZone(cmd, common, def, zoneList) + BindProjectID(cmd, common, def, projectList) + } + + if hasField(req, "Limit") { + BindLimit(cmd, req) + } + if hasField(req, "Offset") { + BindOffset(cmd, req) + } + if hasField(req, "ChargeType") { + BindChargeType(cmd, req) + } + if hasField(req, "Quantity") { + BindQuantity(cmd, req) + } +} diff --git a/pkg/command/bind_test.go b/pkg/command/bind_test.go new file mode 100644 index 0000000000..84b87bced7 --- /dev/null +++ b/pkg/command/bind_test.go @@ -0,0 +1,205 @@ +package command_test + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newCmd() *cobra.Command { + cmd := &cobra.Command{Use: "x"} + cmd.Flags().String("f", "", "") + return cmd +} + +// flagCandidates returns the registered upstream completion candidates for a flag. +// cobra.Completion is an alias for string, so the result is a plain []string. +func flagCandidates(t *testing.T, cmd *cobra.Command, name string) []string { + t.Helper() + fn, ok := cmd.GetFlagCompletionFunc(name) + if !ok || fn == nil { + t.Fatalf("no completion registered for flag %q", name) + } + comps, _ := fn(cmd, nil, "") + return comps +} + +func TestSetCompletionRegisters(t *testing.T) { + cmd := newCmd() + command.SetCompletion(cmd, "f", func() []string { return []string{"a", "b"} }) + + if got := flagCandidates(t, cmd, "f"); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("completion func returned %v, want [a b]", got) + } +} + +func TestSetFlagValuesRegisters(t *testing.T) { + cmd := newCmd() + command.SetFlagValues(cmd, "f", "x", "y") + + if got := flagCandidates(t, cmd, "f"); !reflect.DeepEqual(got, []string{"x", "y"}) { + t.Fatalf("completion candidates = %v, want [x y]", got) + } +} + +func TestBindRegionDefaultAndRef(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + req := &request.CommonBase{} + + command.BindRegion(cmd, req, command.Defaults{Region: "cn-bj2"}, func() []string { return []string{"cn-bj2"} }) + + flag := cmd.Flags().Lookup("region") + if flag == nil { + t.Fatal("region flag not registered") + } + if flag.DefValue != "cn-bj2" { + t.Fatalf("region default = %q, want cn-bj2", flag.DefValue) + } + // Completion registered (upstream). + if _, ok := cmd.GetFlagCompletionFunc("region"); !ok { + t.Fatal("region completion func not registered") + } + // Ref wiring: setting the flag must update req's region (shared storage). + if err := cmd.Flags().Set("region", "cn-sh2"); err != nil { + t.Fatalf("set region flag: %v", err) + } + if got := req.GetRegion(); got != "cn-sh2" { + t.Fatalf("req.GetRegion() = %q, want cn-sh2 (ref wiring broken)", got) + } +} + +func TestBindZoneEmptyDefault(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + req := &request.CommonBase{} + + command.BindZoneEmpty(cmd, req, func(region string) []string { return []string{region} }) + + flag := cmd.Flags().Lookup("zone") + if flag == nil { + t.Fatal("zone flag not registered") + } + if flag.DefValue != "" { + t.Fatalf("zone default = %q, want empty", flag.DefValue) + } +} + +// fakeReq exercises the reflection-based binders (Limit/Offset/ChargeType/Quantity). +type fakeReq struct { + request.CommonBase + Limit *int + Offset *int + ChargeType *string + Quantity *int +} + +func TestBindLimitOffsetChargeTypeQuantity(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + req := &fakeReq{} + + command.BindLimit(cmd, req) + command.BindOffset(cmd, req) + command.BindChargeType(cmd, req) + command.BindQuantity(cmd, req) + + if req.Limit == nil || *req.Limit != 100 { + t.Fatalf("limit default not wired: %v", req.Limit) + } + if req.Offset == nil || *req.Offset != 0 { + t.Fatalf("offset default not wired: %v", req.Offset) + } + if req.ChargeType == nil || *req.ChargeType != "Month" { + t.Fatalf("charge-type default not wired: %v", req.ChargeType) + } + if req.Quantity == nil || *req.Quantity != 1 { + t.Fatalf("quantity default not wired: %v", req.Quantity) + } + if got := flagCandidates(t, cmd, "charge-type"); !reflect.DeepEqual(got, []string{"Month", "Dynamic", "Year"}) { + t.Fatalf("charge-type completion values = %v", got) + } +} + +// partialReq has only some of the optional reflection fields (no Limit/Offset). +type partialReq struct { + request.CommonBase + ChargeType *string + Quantity *int +} + +func TestBindCommonParams(t *testing.T) { + regionList := func() []string { return []string{"cn-bj2"} } + zoneList := func(region string) []string { return []string{region} } + projectList := func() []string { return []string{"org-x"} } + def := command.Defaults{Region: "cn-bj2", Zone: "cn-bj2-02", ProjectID: "org-x"} + + cases := []struct { + name string + req interface{} + want []string // flags that MUST be registered + notWant []string // flags that MUST NOT be registered + }{ + { + name: "all fields present", + req: &fakeReq{}, + want: []string{"region", "zone", "project-id", "limit", "offset", "charge-type", "quantity"}, + notWant: nil, + }, + { + name: "missing limit and offset", + req: &partialReq{}, + want: []string{"region", "zone", "project-id", "charge-type", "quantity"}, + notWant: []string{"limit", "offset"}, + }, + { + name: "only request.Common", + req: &request.CommonBase{}, + want: []string{"region", "zone", "project-id"}, + notWant: []string{"limit", "offset", "charge-type", "quantity"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + // Must NOT panic regardless of which optional fields the req carries. + command.BindCommonParams(cmd, tc.req, def, regionList, zoneList, projectList) + + for _, name := range tc.want { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag %q not registered, want registered", name) + } + } + for _, name := range tc.notWant { + if cmd.Flags().Lookup(name) != nil { + t.Errorf("flag %q registered, want skipped", name) + } + } + }) + } +} + +func TestBindCommonParamsRefWiring(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + req := &fakeReq{} + + command.BindCommonParams(cmd, req, + command.Defaults{Region: "cn-bj2"}, + func() []string { return []string{"cn-bj2"} }, + func(region string) []string { return []string{region} }, + func() []string { return []string{"org-x"} }, + ) + + if err := cmd.Flags().Set("region", "cn-sh2"); err != nil { + t.Fatalf("set region flag: %v", err) + } + if got := req.GetRegion(); got != "cn-sh2" { + t.Fatalf("req.GetRegion() = %q, want cn-sh2 (ref wiring broken)", got) + } + if req.Limit == nil || *req.Limit != 100 { + t.Fatalf("limit default not wired: %v", req.Limit) + } +} diff --git a/pkg/command/bindgroup_test.go b/pkg/command/bindgroup_test.go new file mode 100644 index 0000000000..44d71d73e9 --- /dev/null +++ b/pkg/command/bindgroup_test.go @@ -0,0 +1,27 @@ +package command_test + +import ( + "testing" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func TestBindGroup(t *testing.T) { + type req struct{ Tag *string } + cmd := &cobra.Command{Use: "x"} + r := &req{} + + command.BindGroup(cmd, r) + + if cmd.Flags().Lookup("group") == nil { + t.Fatal("--group flag not registered") + } + if r.Tag == nil { + t.Fatal("req.Tag not bound") + } + if *r.Tag != "" { + t.Fatalf("req.Tag default = %q, want empty", *r.Tag) + } +} diff --git a/pkg/command/completion.go b/pkg/command/completion.go new file mode 100644 index 0000000000..8b86740a6d --- /dev/null +++ b/pkg/command/completion.go @@ -0,0 +1,32 @@ +package command + +import ( + "github.com/spf13/cobra" +) + +// SetCompletion registers a dynamic completion candidate provider for a flag, +// via upstream cobra's RegisterFlagCompletionFunc. +func SetCompletion(cmd *cobra.Command, name string, fn func() []string) { + _ = cmd.RegisterFlagCompletionFunc(name, func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return fn(), cobra.ShellCompDirectiveNoFileComp + }) +} + +// SetPersistentCompletion registers a dynamic completion provider for a +// persistent flag. Upstream RegisterFlagCompletionFunc resolves persistent +// flags itself, so this is identical to SetCompletion; kept as a distinct name +// for call-site clarity (the profile flag in cmd/root.go is persistent). +func SetPersistentCompletion(cmd *cobra.Command, name string, fn func() []string) { + _ = cmd.RegisterFlagCompletionFunc(name, func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return fn(), cobra.ShellCompDirectiveNoFileComp + }) +} + +// SetFlagValues registers a static completion candidate set for a flag, via +// upstream cobra's RegisterFlagCompletionFunc. +func SetFlagValues(cmd *cobra.Command, name string, values ...string) { + vals := append([]string(nil), values...) + _ = cmd.RegisterFlagCompletionFunc(name, func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return vals, cobra.ShellCompDirectiveNoFileComp + }) +} diff --git a/pkg/ui/ansi.go b/pkg/ui/ansi.go new file mode 100644 index 0000000000..2f61855fb2 --- /dev/null +++ b/pkg/ui/ansi.go @@ -0,0 +1,18 @@ +package ui + +import "fmt" + +const ansiCSI = "\x1b[" + +var ( + ansiCursorLeft = fmt.Sprintf("%sG", ansiCSI) + ansiEraseDown = fmt.Sprintf("%sJ", ansiCSI) +) + +func ansiCursorUp(count int) string { + return fmt.Sprintf("%s%dA", ansiCSI, count) +} + +func ansiCursorPrevLine(count int) string { + return fmt.Sprintf("%s%dF", ansiCSI, count) +} diff --git a/pkg/ui/confirm.go b/pkg/ui/confirm.go new file mode 100644 index 0000000000..28e55904d4 --- /dev/null +++ b/pkg/ui/confirm.go @@ -0,0 +1,35 @@ +package ui + +import ( + "fmt" + "io" + "strings" +) + +// Confirm prompts for a yes/no answer and reports one of three outcomes: +// +// - (true, nil): confirmed (yes==true short-circuits, or user answered y/yes) +// - (false, nil): declined (user answered anything else) +// - (false, err): could not prompt — not interactive and no --yes. Callers +// must surface err (non-zero exit) instead of silently skipping, matching +// gcloud/aliyun: a destructive op in a pipe/CI needs an explicit --yes. +// +// The prompt appends " (y/n):" if not already present. +func Confirm(in io.Reader, out io.Writer, yes, interactive bool, text string) (bool, error) { + if yes { + return true, nil + } + if !interactive { + return false, fmt.Errorf("refusing to prompt for confirmation in non-interactive mode; pass --yes to proceed") + } + if !strings.HasSuffix(text, "(y/n):") { + text += " (y/n):" + } + fmt.Fprint(out, text) + var answer string + if _, err := fmt.Fscanf(in, "%s\n", &answer); err != nil { + return false, nil + } + answer = strings.ToLower(strings.Trim(answer, " ")) + return answer == "y" || answer == "yes", nil +} diff --git a/pkg/ui/confirm_test.go b/pkg/ui/confirm_test.go new file mode 100644 index 0000000000..736568fead --- /dev/null +++ b/pkg/ui/confirm_test.go @@ -0,0 +1,40 @@ +package ui_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/ucloud/ucloud-cli/pkg/ui" +) + +func TestConfirmYesShortCircuits(t *testing.T) { + ok, err := ui.Confirm(strings.NewReader(""), &bytes.Buffer{}, true, false, "delete?") + if err != nil || !ok { + t.Fatalf("yes=true must be (true,nil), got (%v,%v)", ok, err) + } +} + +func TestConfirmNonInteractiveNoYesErrors(t *testing.T) { + ok, err := ui.Confirm(strings.NewReader(""), &bytes.Buffer{}, false, false, "delete?") + if err == nil { + t.Fatal("non-interactive without --yes must return an error") + } + if ok { + t.Fatal("non-interactive must not confirm") + } +} + +func TestConfirmInteractiveYes(t *testing.T) { + ok, err := ui.Confirm(strings.NewReader("y\n"), &bytes.Buffer{}, false, true, "delete?") + if err != nil || !ok { + t.Fatalf(`interactive "y" must be (true,nil), got (%v,%v)`, ok, err) + } +} + +func TestConfirmInteractiveNo(t *testing.T) { + ok, err := ui.Confirm(strings.NewReader("n\n"), &bytes.Buffer{}, false, true, "delete?") + if err != nil || ok { + t.Fatalf(`interactive "n" must be (false,nil), got (%v,%v)`, ok, err) + } +} diff --git a/pkg/ui/document.go b/pkg/ui/document.go new file mode 100644 index 0000000000..8d3f1b95ee --- /dev/null +++ b/pkg/ui/document.go @@ -0,0 +1,184 @@ +package ui + +import ( + "fmt" + "io" + "sync" + "time" +) + +// Document is a writer-bound live rendering surface for progress blocks. +type Document struct { + blocks []*Block + mux sync.RWMutex + framesPerSecond int + once sync.Once + out io.Writer + ticker *time.Ticker + disable bool +} + +func (d *Document) reset() { + size := 0 + d.mux.RLock() + for _, block := range d.blocks { + size += block.printLineNum + } + d.mux.RUnlock() + if size != 0 { + fmt.Fprint(d.out, ansiCursorLeft+ansiCursorPrevLine(size)+ansiEraseDown) + } +} + +func (d *Document) Disable() { + d.disable = true +} + +func (d *Document) Disabled() bool { + return d.disable +} + +func (d *Document) SetWriter(out io.Writer) { + d.out = out +} + +func (d *Document) Content() []string { + var lines []string + for _, block := range d.blocks { + for _, line := range <-block.getLines { + lines = append(lines, line) + } + } + return lines +} + +func (d *Document) Render() { + if d.disable { + return + } + d.once.Do(func() { + go func() { + for range d.ticker.C { + d.reset() + d.mux.RLock() + for _, block := range d.blocks { + block.printLineNum = 0 + for _, line := range <-block.getLines { + fmt.Fprintln(d.out, line) + block.printLineNum++ + } + fmt.Fprintf(d.out, "\n") + block.printLineNum++ + } + d.mux.RUnlock() + } + }() + }) +} + +func (d *Document) Append(b *Block) { + d.Render() + d.mux.Lock() + defer d.mux.Unlock() + d.blocks = append(d.blocks, b) +} + +func (d *Document) GetLastBlock() *Block { + d.mux.Lock() + defer d.mux.Unlock() + if len(d.blocks) == 0 { + return nil + } + return d.blocks[len(d.blocks)-1] +} + +func (d *Document) GetBlockCount() int { + d.mux.Lock() + defer d.mux.Unlock() + return len(d.blocks) +} + +func NewDocument(out io.Writer) *Document { + doc := &Document{ + out: out, + framesPerSecond: 20, + disable: !IsTTY(out), + } + doc.ticker = time.NewTicker(time.Second / time.Duration(doc.framesPerSecond)) + return doc +} + +// Block is one progress document block, including an optional spinner and text. +type Block struct { + spinner *Spin + spinnerIndex int + printLineNum int + lines []string + updateLine chan updateBlockLine + getLines chan []string +} + +func (b *Block) Update(text string, index int) { + b.updateLine <- updateBlockLine{text, index} +} + +func (b *Block) Append(text string) { + b.updateLine <- updateBlockLine{text, -1} +} + +func (b *Block) SetSpin(s *Spin) error { + if b.spinner != nil { + return fmt.Errorf("block has spinner already") + } + b.spinner = s + b.spinnerIndex = len(<-b.getLines) + strsCh := b.spinner.renderToString() + go func() { + for text := range strsCh { + if len(<-b.getLines) == 0 { + b.Append(text) + } else { + b.Update(text, b.spinnerIndex) + } + } + }() + return nil +} + +type updateBlockLine struct { + line string + index int +} + +func NewSpinBlock(s *Spin) *Block { + block := NewBlock() + if s != nil { + block.SetSpin(s) + } + return block +} + +func NewBlock() *Block { + block := &Block{ + lines: []string{}, + updateLine: make(chan updateBlockLine), + getLines: make(chan []string), + } + + go func() { + for { + select { + case updateLine := <-block.updateLine: + index, line := updateLine.index, updateLine.line + if index < 0 { + block.lines = append(block.lines, line) + } else { + block.lines[index] = line + } + case block.getLines <- block.lines: + } + } + }() + + return block +} diff --git a/pkg/ui/printer.go b/pkg/ui/printer.go new file mode 100644 index 0000000000..2747bde981 --- /dev/null +++ b/pkg/ui/printer.go @@ -0,0 +1,165 @@ +package ui + +import ( + "encoding/json" + "fmt" + "io" + "reflect" + "strconv" + "strings" + "unicode" + + "gopkg.in/yaml.v2" +) + +// Format controls the output format of a Printer. +// The numeric values must stay in sync with pkg/cli.OutputFormat (Table=0, JSON=1, YAML=2). +type Format int + +const ( + Table Format = iota // 0 + JSON // 1 + YAML // 2 +) + +// Printer renders structured data to Out in the requested Format. +type Printer struct { + Out io.Writer + Format Format +} + +// PrintList renders dataSet (a slice or array of structs) to p.Out. +// For Table: derives column names from the first element's field names. +// For JSON/YAML: serializes the whole dataSet. +// Non-slice/array input with Table format renders nothing (mirrors base.PrintTableS behaviour). +func (p Printer) PrintList(dataSet interface{}) { + switch p.Format { + case JSON: + b, err := json.MarshalIndent(dataSet, "", " ") + if err != nil { + return + } + fmt.Fprintln(p.Out, string(b)) + case YAML: + b, err := yaml.Marshal(dataSet) + if err != nil { + return + } + _, _ = p.Out.Write(b) + default: // Table + val := reflect.ValueOf(dataSet) + fieldNameList := make([]string, 0) + if val.Kind() == reflect.Slice || val.Kind() == reflect.Array { + if val.Len() > 0 { + elemType := val.Index(0).Type() + for i := 0; i < elemType.NumField(); i++ { + fieldNameList = append(fieldNameList, elemType.Field(i).Name) + } + } + displaySlice(p.Out, val, fieldNameList) + } + } +} + +// PrintJSON renders dataSet as indented JSON to out. +func PrintJSON(dataSet interface{}, out io.Writer) error { + b, err := json.MarshalIndent(dataSet, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(out, string(b)) + if err != nil { + return err + } + return nil +} + +// gap is the number of spaces between table columns. +const gap = 2 + +// calcCutWidth counts extra display-width consumed by CJK/non-Latin punctuation +// (each such rune occupies 2 terminal cells but len() counts 3 bytes, so the +// difference — 1 extra cell per rune — must be subtracted when padding). +func calcCutWidth(text string) int { + set := []*unicode.RangeTable{unicode.Han, unicode.Punct} + width := 0 + for _, r := range text { + if unicode.IsOneOf(set, r) && r > unicode.MaxLatin1 { + width++ + } + } + return width +} + +// calcWidth returns the terminal display width of text, +// counting CJK/non-Latin punctuation as 2 cells and ASCII as 1. +func calcWidth(text string) int { + set := []*unicode.RangeTable{unicode.Han, unicode.Punct} + width := 0 + for _, r := range text { + if unicode.IsOneOf(set, r) && r > unicode.MaxLatin1 { + width += 2 + } else { + width++ + } + } + return width +} + +func displaySlice(out io.Writer, listVal reflect.Value, fieldList []string) { + showFieldMap := make(map[string]int) + for _, field := range fieldList { + showFieldMap[field] = len([]rune(field)) + } + rowList := make([]map[string]interface{}, 0) + for i := 0; i < listVal.Len(); i++ { + elemVal := listVal.Index(i) + elemType := elemVal.Type() + var rows []map[string]interface{} + for j := 0; j < elemVal.NumField(); j++ { + field := elemVal.Field(j) + fieldName := elemType.Field(j).Name + if _, ok := showFieldMap[fieldName]; ok { + if field.Kind() == reflect.Ptr { + field = field.Elem() + } + text := fmt.Sprintf("%v", field.Interface()) + cells := strings.Split(text, "\n") + for i, cell := range cells { + width := calcWidth(cell) + if showFieldMap[fieldName] < width { + showFieldMap[fieldName] = width + } + if len(rows) == i { + rows = append(rows, make(map[string]interface{})) + } + rows[i][fieldName] = cell + } + } + } + rowList = append(rowList, rows...) + } + printTable(out, rowList, fieldList, showFieldMap) +} + +func printTable(out io.Writer, rowList []map[string]interface{}, fieldList []string, fieldWidthMap map[string]int) { + for _, field := range fieldList { + tmpl := "%-" + strconv.Itoa(fieldWidthMap[field]+gap) + "s" + fmt.Fprintf(out, tmpl, field) + } + if len(fieldList) != 0 { + fmt.Fprintf(out, "\n") + } + for _, row := range rowList { + for _, field := range fieldList { + cutWidth := calcCutWidth(fmt.Sprintf("%v", row[field])) + tmpl := "%-" + strconv.Itoa(fieldWidthMap[field]-cutWidth+gap) + "v" + if row[field] != nil { + fmt.Fprintf(out, tmpl, row[field]) + } else { + fmt.Fprintf(out, tmpl, "") + } + } + fmt.Fprintf(out, "\n") + } +} diff --git a/pkg/ui/printer_test.go b/pkg/ui/printer_test.go new file mode 100644 index 0000000000..67bb2a7dfa --- /dev/null +++ b/pkg/ui/printer_test.go @@ -0,0 +1,66 @@ +package ui_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/ucloud/ucloud-cli/pkg/ui" +) + +type row struct{ Name, Status string } + +func TestPrinterFormats(t *testing.T) { + for _, c := range []struct { + f ui.Format + want string + }{ + {ui.Table, "Name"}, {ui.JSON, `"Name"`}, {ui.YAML, "name:"}, + } { + var b bytes.Buffer + ui.Printer{Out: &b, Format: c.f}.PrintList([]row{{"mydb", "Running"}}) + if !strings.Contains(b.String(), c.want) { + t.Fatalf("fmt %v missing %q: %s", c.f, c.want, b.String()) + } + } +} + +func TestConfirm(t *testing.T) { + var buf bytes.Buffer + + // yes=true should always return true without reading input + ok, err := ui.Confirm(nil, &buf, true, false, "x") + if err != nil || !ok { + t.Fatalf("Confirm with yes=true should return (true,nil), got (%v,%v)", ok, err) + } + + // "y" input should return true + ok, err = ui.Confirm(strings.NewReader("y\n"), &buf, false, true, "ok?") + if err != nil || !ok { + t.Fatalf("Confirm with 'y' input should return (true,nil), got (%v,%v)", ok, err) + } + + // "yes" input should return true + buf.Reset() + ok, err = ui.Confirm(strings.NewReader("yes\n"), &buf, false, true, "ok?") + if err != nil || !ok { + t.Fatalf("Confirm with 'yes' input should return (true,nil), got (%v,%v)", ok, err) + } + + // "n" input should return false + buf.Reset() + ok, err = ui.Confirm(strings.NewReader("n\n"), &buf, false, true, "ok?") + if err != nil || ok { + t.Fatalf("Confirm with 'n' input should return (false,nil), got (%v,%v)", ok, err) + } +} + +func TestPrintJSON(t *testing.T) { + var buf bytes.Buffer + if err := ui.PrintJSON(map[string]int{"a": 1}, &buf); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "\"a\": 1") { + t.Fatalf("unexpected: %q", buf.String()) + } +} diff --git a/pkg/ui/prompt.go b/pkg/ui/prompt.go new file mode 100644 index 0000000000..23e7fedd77 --- /dev/null +++ b/pkg/ui/prompt.go @@ -0,0 +1,23 @@ +package ui + +import ( + "fmt" + "strings" +) + +// Prompt asks for y/n confirmation on the process stdin/stdout. +func Prompt(text string) (bool, error) { + if !strings.HasSuffix(text, "(y/n):") { + text += " (y/n):" + } + fmt.Print(text) + var agreeClose string + _, err := fmt.Scanf("%s\n", &agreeClose) + if err != nil { + return false, err + } + agreeClose = strings.Trim(agreeClose, " ") + agreeClose = strings.ToLower(agreeClose) + + return agreeClose == "y" || agreeClose == "yes", nil +} diff --git a/pkg/ui/refresh.go b/pkg/ui/refresh.go new file mode 100644 index 0000000000..7ea199ddba --- /dev/null +++ b/pkg/ui/refresh.go @@ -0,0 +1,25 @@ +package ui + +import ( + "fmt" + "io" +) + +// Refresh rewrites a single progress line on each Do call. +type Refresh struct { + out io.Writer + reset bool +} + +func (r *Refresh) Do(text string) { + if r.reset { + fmt.Fprint(r.out, ansiCursorLeft+ansiCursorUp(1)+ansiEraseDown) + } else { + r.reset = true + } + fmt.Fprintln(r.out, text) +} + +func NewRefresh(out io.Writer) *Refresh { + return &Refresh{out: out} +} diff --git a/pkg/ui/spinner.go b/pkg/ui/spinner.go new file mode 100644 index 0000000000..e3c7fc29ee --- /dev/null +++ b/pkg/ui/spinner.go @@ -0,0 +1,180 @@ +package ui + +import ( + "fmt" + "io" + "runtime" + "sync" + "time" +) + +const windows = "windows" + +var spinnerFrames = []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} + +// Spinner renders an animated single-line spinner to a writer. +type Spinner struct { + out io.Writer + frames []rune + framesPerSecond int + DoingText string + DoneText string + TimeoutText string + ticker *time.Ticker + output string +} + +func (s *Spinner) Start(doingText string) { + if doingText != "" { + s.DoingText = doingText + } + s.ticker = time.NewTicker(time.Second / time.Duration(s.framesPerSecond)) + s.render() +} + +func (s *Spinner) Stop() { + s.ticker.Stop() + s.reset() + fmt.Fprintf(s.out, "%s...%s\n", s.DoingText, s.DoneText) +} + +func (s *Spinner) Timeout() { + s.ticker.Stop() + s.reset() + fmt.Fprintf(s.out, "%s...%s\n", s.DoingText, s.TimeoutText) +} + +func (s *Spinner) Fail(err error) { + s.ticker.Stop() + s.reset() + fmt.Fprintf(s.out, "%s...fail: %v\n", s.DoingText, err) +} + +func (s *Spinner) reset() { + if s.output == "" { + return + } + fmt.Fprint(s.out, ansiCursorLeft+ansiCursorUp(1)+ansiEraseDown) + s.output = "" +} + +func (s *Spinner) render() { + nextFrame := s.newFrameFactory() + go func() { + send := false + for range s.ticker.C { + if runtime.GOOS == windows { + if !send { + fmt.Fprintf(s.out, "%s...\n", s.DoingText) + send = true + } + continue + } + frame := nextFrame() + s.reset() + s.output = fmt.Sprintf("%s...%c\n", s.DoingText, frame) + fmt.Fprint(s.out, s.output) + } + }() +} + +func (s *Spinner) newFrameFactory() func() rune { + index := 0 + size := len(s.frames) + return func() rune { + char := s.frames[index%size] + index++ + return char + } +} + +func NewDotSpinner(out io.Writer) *Spinner { + return &Spinner{ + out: out, + frames: spinnerFrames, + framesPerSecond: 12, + DoingText: "running", + DoneText: "done", + TimeoutText: "timeout", + } +} + +// Spin renders spinner frames as strings for a Document block. +type Spin struct { + out io.Writer + frames []rune + framesPerSecond int + DoingText string + DoneText string + TimeoutText string + ticker *time.Ticker + output string + textChan chan string + wg sync.WaitGroup +} + +func (s *Spin) Stop() { + s.ticker.Stop() + s.reset() + s.textChan <- fmt.Sprintf("%s...%s", s.DoingText, s.DoneText) + <-time.After(time.Millisecond * 100) + close(s.textChan) +} + +func (s *Spin) Timeout() { + s.ticker.Stop() + s.reset() + s.textChan <- fmt.Sprintf("%s...%s", s.DoingText, s.TimeoutText) + <-time.After(time.Millisecond * 100) + close(s.textChan) +} + +func (s *Spin) reset() { + if s.output == "" { + return + } + fmt.Fprint(s.out, ansiCursorLeft+ansiCursorUp(1)+ansiEraseDown) + s.output = "" +} + +func (s *Spin) renderToString() chan string { + nextFrame := s.newFrameFactory() + go func() { + send := false + for range s.ticker.C { + if runtime.GOOS == windows { + if !send { + s.textChan <- fmt.Sprintf("%s...", s.DoingText) + send = true + } + continue + } + s.textChan <- fmt.Sprintf("%s...%c", s.DoingText, nextFrame()) + } + }() + return s.textChan +} + +func (s *Spin) newFrameFactory() func() rune { + index := 0 + size := len(s.frames) + return func() rune { + char := s.frames[index%size] + index++ + return char + } +} + +func NewDotSpin(out io.Writer, doingText string) *Spin { + s := &Spin{ + out: out, + frames: spinnerFrames, + framesPerSecond: 12, + DoingText: doingText, + DoneText: "done", + TimeoutText: "timeout", + textChan: make(chan string), + } + s.ticker = time.NewTicker(time.Second / time.Duration(s.framesPerSecond)) + return s +} diff --git a/pkg/ui/tty.go b/pkg/ui/tty.go new file mode 100644 index 0000000000..bc737e1659 --- /dev/null +++ b/pkg/ui/tty.go @@ -0,0 +1,27 @@ +package ui + +import ( + "io" + "os" + + "github.com/mattn/go-isatty" +) + +// IsTTY reports whether w is a terminal. +// Returns false for any writer that is not an *os.File backed by a real TTY. +func IsTTY(w io.Writer) bool { + f, ok := w.(*os.File) + return ok && isatty.IsTerminal(f.Fd()) +} + +// IsReaderTTY reports whether r is an interactive terminal. Only *os.File can +// be a TTY; anything else (bytes.Buffer in tests, pipes) is non-interactive. +// Mirrors base.IsStdinTTY's Cygwin/mintty handling. +func IsReaderTTY(r io.Reader) bool { + f, ok := r.(*os.File) + if !ok { + return false + } + fd := f.Fd() + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) +} diff --git a/products/cloudwatch/internal/cloudwatch/cmd.go b/products/cloudwatch/internal/cloudwatch/cmd.go new file mode 100644 index 0000000000..593d277731 --- /dev/null +++ b/products/cloudwatch/internal/cloudwatch/cmd.go @@ -0,0 +1,20 @@ +package cloudwatch + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `cloudwatch` root command and mounts its public verbs. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "cloudwatch", + Short: "Discover and query CloudWatch metrics", + Long: "List monitored products and metrics, then query metric data.", + } + cmd.AddCommand(newListProducts(ctx)) + cmd.AddCommand(newListMetrics(ctx)) + cmd.AddCommand(newQueryMetricData(ctx)) + return cmd +} diff --git a/products/cloudwatch/internal/cloudwatch/completion.go b/products/cloudwatch/internal/cloudwatch/completion.go new file mode 100644 index 0000000000..a4b1665bb4 --- /dev/null +++ b/products/cloudwatch/internal/cloudwatch/completion.go @@ -0,0 +1,66 @@ +package cloudwatch + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +var ( + calcMethodValues = []string{"raw", "max", "min", "avg", "sum"} + periodValues = []string{"60", "300", "3600", "21600", "86400"} +) + +// productKeyCandidates returns the live product-key list for --product +// completion by calling ListMonitorProduct — the authoritative, dynamic +// source (products are added/retired over time; a hardcoded list would go +// stale). No request fields are required (empty Filter matches everything). +func productKeyCandidates(ctx *cli.Context) func() []string { + return func() []string { + client := newGenericClient(ctx) + req := client.NewGenericRequest() + out, err := invoke(client, req, map[string]interface{}{ + "Action": "ListMonitorProduct", + }) + if err != nil { + return nil + } + var resp listMonitorProductResp + if err := decodeData(out, &resp); err != nil { + return nil + } + keys := make([]string, 0, len(resp.List)) + for _, p := range resp.List { + keys = append(keys, p.ProductKey) + } + return keys + } +} + +func registerQueryMetricDataCompletions(cmd *cobra.Command) { + command.SetFlagValues(cmd, "calc-method", calcMethodValues...) + command.SetFlagValues(cmd, "period", periodValues...) +} + +func validateEnum(name, value string, allowed []string) error { + for _, candidate := range allowed { + if value == candidate { + return nil + } + } + return fmt.Errorf("%s must be one of: %s", name, joinEnumValues(allowed)) +} + +func joinEnumValues(values []string) string { + result := "" + for i, value := range values { + if i > 0 { + result += ", " + } + result += value + } + return result +} diff --git a/products/cloudwatch/internal/cloudwatch/invoke.go b/products/cloudwatch/internal/cloudwatch/invoke.go new file mode 100644 index 0000000000..31a65ee890 --- /dev/null +++ b/products/cloudwatch/internal/cloudwatch/invoke.go @@ -0,0 +1,59 @@ +package cloudwatch + +import ( + "encoding/json" + "fmt" + + sdkcloudwatch "github.com/ucloud/ucloud-sdk-go/services/cloudwatch" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newGenericClient returns the authed CloudWatch service client used purely as +// the carrier for GenericInvoke. The SDK does provide a strongly-typed +// CloudWatchClient (services/cloudwatch), but this product intentionally does +// NOT call its typed methods — it only borrows the client (and the platform +// credential/signature/handler chain cli.NewServiceClient wires up) to send +// generic Action requests, exactly like `ucloud api` and the mysql product do +// for actions with no typed SDK method. Using the cloudwatch client (rather +// than uaccount) makes the call site read as "this is a CloudWatch request" +// without coupling to the SDK's generated request/response types. +func newGenericClient(ctx *cli.Context) *sdkcloudwatch.CloudWatchClient { + return cli.NewServiceClient(ctx, sdkcloudwatch.NewClient) +} + +// invoke sends req with the given payload and returns the decoded SkymFlameAPI +// envelope payload (map: {Action, TraceId, RetCode, Message, Data, TotalCount?}). +// +// Business errors (envelope RetCode != 0) do NOT need to be checked here: the +// SDK's built-in errorHandler (ucloud/handlers.go, registered by default on +// every *ucloud.Client) already inspects resp.GetRetCode() after every +// InvokeAction call and converts a non-zero RetCode into a uerr.Error, which +// GenericInvoke returns as err. So `err != nil` from GenericInvoke already +// covers both transport errors (network/timeout/signature) and business +// errors — callers only need ctx.HandleError(err); there is nothing left for +// product code to inspect on the payload for error purposes. +func invoke(client *sdkcloudwatch.CloudWatchClient, req request.GenericRequest, payload map[string]interface{}) (map[string]interface{}, error) { + if err := req.SetPayload(payload); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, err + } + return resp.GetPayload(), nil +} + +// decodeData decodes the envelope's Data field into out (a pointer to the +// caller's local response struct) by re-marshaling the interface{} value. +func decodeData(payload map[string]interface{}, out interface{}) error { + raw, err := json.Marshal(payload["Data"]) + if err != nil { + return fmt.Errorf("marshal Data: %w", err) + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("unmarshal Data: %w", err) + } + return nil +} diff --git a/products/cloudwatch/internal/cloudwatch/list_metrics.go b/products/cloudwatch/internal/cloudwatch/list_metrics.go new file mode 100644 index 0000000000..da3cd25bb9 --- /dev/null +++ b/products/cloudwatch/internal/cloudwatch/list_metrics.go @@ -0,0 +1,97 @@ +package cloudwatch + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// getProductMetricResp is the local decode target for the GetProductMetrics +// envelope Data. Fields mirror SkymFlameAPI dto.GetProductMetricListResp +// (release branch) — only what the CLI renders is declared. +type getProductMetricResp struct { + Total int64 `json:"Total"` + List []metricItem `json:"List"` +} + +type metricItem struct { + Metric string `json:"Metric"` + MetricName string `json:"MetricName"` + MetricChName string `json:"MetricChName"` + FrequencyMs int32 `json:"FrequencyMs"` + Unit *unitItem `json:"Unit"` +} + +type unitItem struct { + UnitChName string `json:"UnitChName"` + UnitName string `json:"UnitName"` +} + +func newListMetrics(ctx *cli.Context) *cobra.Command { + var product, monitorType string + client := newGenericClient(ctx) + req := client.NewGenericRequest() + + cmd := &cobra.Command{ + Use: "list-metrics", + Short: "List metrics for a product", + Long: "List the metrics available for one monitored product.", + Example: ` # List all UHost metrics + ucloud cloudwatch list-metrics --product uhost + + # List only basic UHost metrics + ucloud cloudwatch list-metrics --product uhost --monitor-type basic`, + Args: cobra.NoArgs, + Run: func(c *cobra.Command, args []string) { + payload := map[string]interface{}{ + "Action": "GetProductMetrics", + "ProductKey": product, + } + if monitorType != "" { + payload["MonitorType"] = monitorType + } + out, err := invoke(client, req, payload) + if err != nil { + ctx.HandleError(err) + return + } + var resp getProductMetricResp + if err := decodeData(out, &resp); err != nil { + ctx.HandleError(err) + return + } + rows := make([]MetricRow, 0, len(resp.List)) + for _, m := range resp.List { + name := m.MetricChName + if name == "" { + name = m.MetricName + } + unit := "" + if m.Unit != nil { + unit = m.Unit.UnitChName + if unit == "" { + unit = m.Unit.UnitName + } + } + rows = append(rows, MetricRow{ + Metric: m.Metric, + MetricName: name, + Unit: unit, + FrequencyMs: m.FrequencyMs, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + flags.StringVar(&product, "product", "", "Required. Product key returned by list-products, for example uhost") + flags.StringVar(&monitorType, "monitor-type", "", "Optional. Metric type filter; omit to list all types") + cmd.MarkFlagRequired("product") + + command.SetCompletion(cmd, "product", productKeyCandidates(ctx)) + + return cmd +} diff --git a/products/cloudwatch/internal/cloudwatch/list_products.go b/products/cloudwatch/internal/cloudwatch/list_products.go new file mode 100644 index 0000000000..c0f7366b92 --- /dev/null +++ b/products/cloudwatch/internal/cloudwatch/list_products.go @@ -0,0 +1,63 @@ +package cloudwatch + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +type monitorProductItem struct { + ProductKey string `json:"ProductKey"` + ProductName string `json:"ProductName"` + ProductChName string `json:"ProductChName"` + IsSupportHighPrecision bool `json:"IsSupportHighPrecision"` +} + +type listMonitorProductResp struct { + Total int `json:"Total"` + List []monitorProductItem `json:"List"` +} + +func newListProducts(ctx *cli.Context) *cobra.Command { + client := newGenericClient(ctx) + req := client.NewGenericRequest() + + cmd := &cobra.Command{ + Use: "list-products", + Short: "List monitored products", + Long: "List products that can be queried with CloudWatch.", + Example: ` # List monitored products + ucloud cloudwatch list-products + + # Print only product keys as JSON + ucloud cloudwatch list-products --output json | jq -r '.[].Product'`, + Args: cobra.NoArgs, + Run: func(c *cobra.Command, args []string) { + out, err := invoke(client, req, map[string]interface{}{ + "Action": "ListMonitorProduct", + }) + if err != nil { + ctx.HandleError(err) + return + } + + var resp listMonitorProductResp + if err := decodeData(out, &resp); err != nil { + ctx.HandleError(err) + return + } + + rows := make([]MonitorProductRow, 0, len(resp.List)) + for _, p := range resp.List { + rows = append(rows, MonitorProductRow{ + Product: p.ProductKey, + ProductName: p.ProductName, + ProductChName: p.ProductChName, + }) + } + ctx.PrintList(rows) + }, + } + + return cmd +} diff --git a/products/cloudwatch/internal/cloudwatch/query_metric_data.go b/products/cloudwatch/internal/cloudwatch/query_metric_data.go new file mode 100644 index 0000000000..74bdb5aaea --- /dev/null +++ b/products/cloudwatch/internal/cloudwatch/query_metric_data.go @@ -0,0 +1,252 @@ +package cloudwatch + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// queryMetricDataResp is the local decode target for the QueryMetricDataSet +// envelope Data. Fields mirror SkymFlameAPI dto.QueryMetricDataResp (release). +type queryMetricDataResp struct { + List []metricInfo `json:"List"` + InvalidResourceIds []string `json:"InvalidResourceIds"` +} + +type metricInfo struct { + ErrCode int `json:"ErrCode"` + ErrMsg string `json:"ErrMsg"` + Metric string `json:"Metric"` + Results []metricValues `json:"Results"` +} + +type metricValues struct { + ResourceID string `json:"ResourceId"` + ResourceName string `json:"ResourceName"` + TagMap map[string]string `json:"TagMap"` + Values []metricPoint `json:"Values"` +} + +type metricPoint struct { + Timestamp int64 `json:"Timestamp"` + Value float64 `json:"Value"` +} + +// Note on request encoding: the SDK's generic form encoder expands maps and +// slices but rejects nested structs. MetricInfos is therefore built as a +// []map[string]interface{} (with each TagList entry also a map), not structs. + +// newQueryMetricData ucloud cloudwatch query-metric-data +func newQueryMetricData(ctx *cli.Context) *cobra.Command { + var product, calcMethod string + var resourceIDs, metrics []string + var startTime, endTime, period int64 + var tags []string + client := newGenericClient(ctx) + req := client.NewGenericRequest() + + cmd := &cobra.Command{ + Use: "query-metric-data", + Short: "Query metric data", + Long: "Query time-series data for one or more resources and metrics. Every resource is paired with every metric.", + Example: ` # Query one metric on one resource for the default last-hour window + ucloud cloudwatch query-metric-data --product uhost --resource-id uhost-xxx --metric uhost_cpu_used + + # Repeat --resource-id and --metric for multiple resources and metrics + ucloud cloudwatch query-metric-data --product uhost \ + --resource-id uhost-a --resource-id uhost-b \ + --metric uhost_cpu_used --metric uhost_mem_used \ + --tag env=prod --tag role=web --calc-method avg --period 300 + + # Values for the same tag key are OR-ed; different keys are AND-ed + ucloud cloudwatch query-metric-data --product uhost --resource-id uhost-a \ + --metric uhost_cpu_used --tag env=prod --tag env=staging --tag role=web`, + Args: cobra.NoArgs, + Run: func(c *cobra.Command, args []string) { + if req.GetProjectId() == "" { + ctx.HandleError(fmt.Errorf("project-id is required for query-metric-data; pass --project-id or configure a default project")) + return + } + // default time window: the last hour + now := time.Now().Unix() + if endTime == 0 { + endTime = now + } + if startTime == 0 { + startTime = endTime - 3600 + } + if startTime >= endTime { + ctx.HandleError(fmt.Errorf("start-time must be earlier than end-time")) + return + } + if err := validateEnum("calc-method", calcMethod, calcMethodValues); err != nil { + ctx.HandleError(err) + return + } + if period != 0 { + if err := validateEnum("period", fmt.Sprint(period), periodValues); err != nil { + ctx.HandleError(err) + return + } + } + + tagList, err := parseTags(tags) + if err != nil { + ctx.HandleError(err) + return + } + + // Cartesian product of --resource-id x --metric: one MetricInfos + // entry per (resource, metric) combination, all queried in the + // same request. The backend enforces its own cap on the total + // number of combinations (config.VM.ReqBatchMaxNum, not fixed at + // compile time) — CLI does not pre-validate a count, an + // over-limit request surfaces as a normal backend error via + // ctx.HandleError. + metricInfos := buildMetricInfos(ctx, resourceIDs, metrics, tagList) + + payload := map[string]interface{}{ + "Action": "QueryMetricDataSet", + "ProductKey": product, + "StartTime": startTime, + "EndTime": endTime, + "CalcMethod": calcMethod, + "MetricInfos": metricInfos, + } + if period != 0 { + payload["Period"] = period + } + out, err := invoke(client, req, payload) + if err != nil { + ctx.HandleError(err) + return + } + var resp queryMetricDataResp + if err := decodeData(out, &resp); err != nil { + ctx.HandleError(err) + return + } + + rows := make([]DataPointRow, 0) + for _, mi := range resp.List { + if mi.ErrCode != 0 { + ctx.LogWarn(fmt.Sprintf("metric %s error: %s", mi.Metric, mi.ErrMsg)) + continue + } + for _, mv := range mi.Results { + for _, pt := range mv.Values { + rows = append(rows, DataPointRow{ + ResourceID: mv.ResourceID, + ResourceName: mv.ResourceName, + Metric: mi.Metric, + Timestamp: common.FormatDateTime(int(pt.Timestamp)), + Value: pt.Value, + Tags: flattenTagMap(mv.TagMap), + }) + } + } + } + if len(resp.InvalidResourceIds) > 0 { + ctx.LogWarn(fmt.Sprintf("invalid resource ids: %v", resp.InvalidResourceIds)) + } + if len(rows) == 0 { + ctx.LogWarn("no data points in the given time range") + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + flags.StringVar(&product, "product", "", "Required. Product key returned by list-products, for example uhost") + flags.StringArrayVar(&resourceIDs, "resource-id", nil, "Required. Resource ID; repeat --resource-id to query multiple resources (values are not comma-split)") + flags.StringArrayVar(&metrics, "metric", nil, "Required. Metric key returned by list-metrics; repeat --metric to query multiple metrics (values are not comma-split)") + flags.Int64Var(&startTime, "start-time", 0, "Optional. Start time as Unix seconds; defaults to one hour before end-time") + flags.Int64Var(&endTime, "end-time", 0, "Optional. End time as Unix seconds; defaults to the current time") + flags.StringVar(&calcMethod, "calc-method", "raw", "Optional. Calculation method: raw, max, min, avg, or sum") + flags.Int64Var(&period, "period", 0, "Optional. Data interval in seconds: 60, 300, 3600, 21600, or 86400; omit to choose automatically") + flags.StringArrayVar(&tags, "tag", nil, "Optional. Tag filter as key=value; repeat --tag for multiple values or keys. Same-key values are OR-ed, different keys are AND-ed; commas in values are preserved") + cmd.MarkFlagRequired("product") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("metric") + + ctx.BindProjectID(cmd, req) + cmd.Flags().Lookup("project-id").Usage = "Required. Project ID" + cmd.Flags().Lookup("project-id").DefValue = "" + ctx.BindRegion(cmd, req) + cmd.Flags().Lookup("region").Usage = "Optional. Region" + cmd.Flags().Lookup("region").DefValue = "" + command.SetCompletion(cmd, "product", productKeyCandidates(ctx)) + registerQueryMetricDataCompletions(cmd) + + return cmd +} + +func buildMetricInfos(ctx *cli.Context, resourceIDs, metrics []string, tagList []map[string]interface{}) []map[string]interface{} { + metricInfos := make([]map[string]interface{}, 0, len(resourceIDs)*len(metrics)) + for _, rid := range resourceIDs { + for _, metric := range metrics { + info := map[string]interface{}{ + "Metric": metric, + "ResourceId": ctx.PickResourceID(rid), + } + if len(tagList) > 0 { + info["TagList"] = tagList + } + metricInfos = append(metricInfos, info) + } + } + return metricInfos +} + +// parseTags converts repeated --tag "key=value" flags into TagList entries. +// Each entry is a map (not a struct) so the SDK generic form encoder can expand +// it into TagList.N.TagKey / TagList.N.TagValues.M form. Values for the same +// key are grouped into one entry (OR); separate keys remain separate entries +// (AND). +func parseTags(tags []string) ([]map[string]interface{}, error) { + if len(tags) == 0 { + return nil, nil + } + valuesByKey := make(map[string][]string, len(tags)) + keys := make([]string, 0, len(tags)) + for _, t := range tags { + kv := strings.SplitN(t, "=", 2) + if len(kv) != 2 || kv[0] == "" || kv[1] == "" { + return nil, fmt.Errorf("invalid --tag %q, want key=value; repeat --tag for multiple values", t) + } + if _, exists := valuesByKey[kv[0]]; !exists { + keys = append(keys, kv[0]) + } + valuesByKey[kv[0]] = append(valuesByKey[kv[0]], kv[1]) + } + list := make([]map[string]interface{}, 0, len(keys)) + for _, key := range keys { + list = append(list, map[string]interface{}{"TagKey": key, "TagValues": valuesByKey[key]}) + } + return list, nil +} + +// flattenTagMap renders a tag map as a stable "k=v, k2=v2" string. +func flattenTagMap(m map[string]string) string { + if len(m) == 0 { + return "" + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+m[k]) + } + return strings.Join(parts, ", ") +} diff --git a/products/cloudwatch/internal/cloudwatch/rows.go b/products/cloudwatch/internal/cloudwatch/rows.go new file mode 100644 index 0000000000..5eafa96d31 --- /dev/null +++ b/products/cloudwatch/internal/cloudwatch/rows.go @@ -0,0 +1,29 @@ +package cloudwatch + +// MonitorProductRow is one product definition returned by ListMonitorProduct. +type MonitorProductRow struct { + Product string + ProductName string + ProductChName string +} + +// MetricRow is one table row for `cloudwatch list-metrics`. +// One row per metric of the queried product. +type MetricRow struct { + Metric string + MetricName string // MetricChName — Chinese display name, closer to console habit + Unit string // Unit.UnitChName (empty when Unit is nil) + FrequencyMs int32 +} + +// DataPointRow is one table row for `cloudwatch query-metric-data`. +// The nested response (metric → resource → point[]) is flattened so each row +// is a single (resource, metric, timestamp) sample. +type DataPointRow struct { + ResourceID string + ResourceName string + Metric string + Timestamp string // common.FormatDateTime(point.Timestamp) + Value float64 + Tags string // TagMap flattened to "k=v, k2=v2"; empty when no tags +} diff --git a/products/cloudwatch/product.go b/products/cloudwatch/product.go new file mode 100644 index 0000000000..3f6cf1049f --- /dev/null +++ b/products/cloudwatch/product.go @@ -0,0 +1,22 @@ +package cloudwatch + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalcloudwatch "github.com/ucloud/ucloud-cli/products/cloudwatch/internal/cloudwatch" +) + +type Product struct{} + +func New() cli.Product { return &Product{} } + +func (*Product) Metadata() cli.Metadata { + return cli.Metadata{Name: "cloudwatch", Commands: []string{"cloudwatch"}} +} + +func (*Product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalcloudwatch.NewCommand(ctx)} +} + +var _ cli.Product = (*Product)(nil) diff --git a/products/cloudwatch/product.yaml b/products/cloudwatch/product.yaml new file mode 100644 index 0000000000..0e3303704d --- /dev/null +++ b/products/cloudwatch/product.yaml @@ -0,0 +1,7 @@ +# products/cloudwatch/product.yaml — cloudwatch 产品元数据(归属真源,owner 自治维护) +name: cloudwatch +owners: + - YAYALE-WA +commands: + - cloudwatch +enabled: true diff --git a/products/cloudwatch/testdata/cmdtree.golden b/products/cloudwatch/testdata/cmdtree.golden new file mode 100644 index 0000000000..497daa25f7 --- /dev/null +++ b/products/cloudwatch/testdata/cmdtree.golden @@ -0,0 +1,16 @@ +ucloud cloudwatch use=cloudwatch short=Discover and query CloudWatch metrics +ucloud cloudwatch list-metrics use=list-metrics short=List metrics for a product + flag=monitor-type short= default= required= + flag=product short= default= required=true +ucloud cloudwatch list-products use=list-products short=List monitored products +ucloud cloudwatch query-metric-data use=query-metric-data short=Query metric data + flag=calc-method short= default=raw required= + flag=end-time short= default=0 required= + flag=metric short= default=[] required=true + flag=period short= default=0 required= + flag=product short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=resource-id short= default=[] required=true + flag=start-time short= default=0 required= + flag=tag short= default=[] required= diff --git a/products/cloudwatch/testdata/completion.golden b/products/cloudwatch/testdata/completion.golden new file mode 100644 index 0000000000..9e5f2a2ffa --- /dev/null +++ b/products/cloudwatch/testdata/completion.golden @@ -0,0 +1,6 @@ +ucloud cloudwatch list-metrics product dynamic +ucloud cloudwatch query-metric-data calc-method static avg,max,min,raw,sum +ucloud cloudwatch query-metric-data period static 21600,300,3600,60,86400 +ucloud cloudwatch query-metric-data product dynamic +ucloud cloudwatch query-metric-data project-id dynamic +ucloud cloudwatch query-metric-data region dynamic diff --git a/products/css/internal/css/appversion.go b/products/css/internal/css/appversion.go new file mode 100644 index 0000000000..30cf9469a1 --- /dev/null +++ b/products/css/internal/css/appversion.go @@ -0,0 +1,44 @@ +package css + +import ( + "fmt" + + "github.com/spf13/cobra" + + uessdk "github.com/ucloud/ucloud-sdk-go/services/ues" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newAppVersion ucloud css app-version +func newAppVersion(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewGetUESAppVersionRequest() + cmd := &cobra.Command{ + Use: "app-version", + Short: "List available UES application versions", + Long: "List available UES application versions", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.GetUESAppVersion(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []AppVersionRow{} + for _, v := range resp.AppVersionList { + list = append(list, AppVersionRow{ + AppName: v.AppName, + AppVersion: v.AppVersion, + IsMultiZone: fmt.Sprintf("%t", v.IsMultiZone), + }) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + return cmd +} diff --git a/products/css/internal/css/cmd.go b/products/css/internal/css/cmd.go new file mode 100644 index 0000000000..43c9113942 --- /dev/null +++ b/products/css/internal/css/cmd.go @@ -0,0 +1,27 @@ +package css + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `css` root command and mounts the subcommands. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "css", + Short: "Manage UES (Elasticsearch/OpenSearch) instances", + Long: "Manage UES (Elasticsearch/OpenSearch) instances", + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newExpand(ctx)) + cmd.AddCommand(newResize(ctx)) + cmd.AddCommand(newRestart(ctx)) + cmd.AddCommand(newDiskLimit(ctx)) + cmd.AddCommand(newNodeConf(ctx)) + cmd.AddCommand(newAppVersion(ctx)) + return cmd +} diff --git a/products/css/internal/css/completion.go b/products/css/internal/css/completion.go new file mode 100644 index 0000000000..ac2b1db755 --- /dev/null +++ b/products/css/internal/css/completion.go @@ -0,0 +1,44 @@ +package css + +import ( + "strings" + + uessdk "github.com/ucloud/ucloud-sdk-go/services/ues" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// getInstanceList returns "InstanceId/Name" completion candidates for instance-id flags. +func getInstanceList(ctx *cli.Context, states []string, project, region, zone string) []string { + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewListUESInstanceRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + if zone != "" { + req.Zone = sdk.String(zone) + } + req.Limit = sdk.Int(50) + resp, err := client.ListUESInstance(req) + if err != nil { + // silent fail for completion + return nil + } + list := []string{} + for _, ins := range resp.ClusterSet { + if states != nil { + matched := false + for _, s := range states { + if ins.State == s { + matched = true + break + } + } + if !matched { + continue + } + } + list = append(list, ins.InstanceId+"/"+strings.Replace(ins.InstanceName, " ", "-", -1)) + } + return list +} diff --git a/products/css/internal/css/create.go b/products/css/internal/css/create.go new file mode 100644 index 0000000000..95fff18e84 --- /dev/null +++ b/products/css/internal/css/create.go @@ -0,0 +1,84 @@ +package css + +import ( + "encoding/base64" + "fmt" + + "github.com/spf13/cobra" + + uessdk "github.com/ucloud/ucloud-sdk-go/services/ues" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud css create +func newCreate(ctx *cli.Context) *cobra.Command { + var async *bool + var servicePasswd *string + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewCreateUESInstanceRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create UES instance", + Long: "Create UES instance", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + // 服务密码需 base64 编码后提交;未指定时使用默认密码 changeme + passwd := *servicePasswd + if passwd == "" { + passwd = "changeme" + } + req.ServicePasswd = sdk.String(base64.StdEncoding.EncodeToString([]byte(passwd))) + resp, err := client.CreateUESInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("ues[%s] is creating", resp.InstanceId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUESInstanceByID(ctx)).Spoll(resp.InstanceId, text, []string{STATE_RUNNING, STATE_ABNORMAL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.InstanceId, Action: "create", Status: "Creating"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.InstanceName = flags.String("name", "", "Required. Instance name") + req.AppVersion = flags.String("app-version", "", "Required. Application version, e.g. 7.10.2") + req.AppName = flags.String("app-name", "elasticsearch", "Optional. Application name, default elasticsearch") + req.NodeConf = flags.String("node-conf", "", "Required. Node configuration identifier") + req.NodeDiskConf = flags.String("node-disk-conf", "CLOUD_RSSD", "Required. Node disk type") + req.NodeDiskSize = flags.Int("node-disk-size-gb", 100, "Optional. Node disk size in GB, default 100") + req.NodeSize = flags.Int("node-count", 3, "Optional. Node count, default 3") + req.KibanaNodeConf = flags.String("kibana-node-conf", "", "Required. Kibana node configuration") + req.KibanaNodeDiskConf = flags.String("kibana-disk-conf", "CLOUD_RSSD", "Required. Kibana disk type") + req.VPCId = flags.String("vpc-id", "", "Required. VPC ID") + req.SubnetId = flags.String("subnet-id", "", "Required. Subnet ID") + req.ServiceUserName = flags.String("service-username", "", "Optional. Service username. elasticsearch default 'elastic'; OpenSearch fixed 'admin'") + servicePasswd = flags.String("service-passwd", "", "Optional. Service password, default 'changeme'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + req.ChargeType = flags.String("charge-type", "Month", "Optional. 'Year', 'Month', or 'Dynamic', default Month") + req.Quantity = flags.Int("quantity", 1, "Optional. Purchase duration, default 1") + req.BusinessId = flags.String("business-id", "", "Optional. Business group ID") + req.Remark = flags.String("remark", "", "Optional. Remark") + async = flags.Bool("async", false, "Optional. Do not wait for creation to finish") + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("app-version") + cmd.MarkFlagRequired("node-conf") + cmd.MarkFlagRequired("node-disk-conf") + cmd.MarkFlagRequired("kibana-node-conf") + cmd.MarkFlagRequired("kibana-disk-conf") + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("subnet-id") + + return cmd +} diff --git a/products/css/internal/css/delete.go b/products/css/internal/css/delete.go new file mode 100644 index 0000000000..802eb65872 --- /dev/null +++ b/products/css/internal/css/delete.go @@ -0,0 +1,64 @@ +package css + +import ( + "fmt" + + "github.com/spf13/cobra" + + uessdk "github.com/ucloud/ucloud-sdk-go/services/ues" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud css delete +func newDelete(ctx *cli.Context) *cobra.Command { + var yes *bool + var instanceIDs *[]string + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewDeleteUESInstanceRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete UES instances", + Long: "Delete UES instances", + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(*yes, "Are you sure to delete UES instance(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range *instanceIDs { + id := ctx.PickResourceID(idName) + req.InstanceId = &id + _, err := client.DeleteUESInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "ues[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + instanceIDs = flags.StringSlice("instance-id", nil, "Required. Instance ID(s) to delete") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + yes = flags.BoolP("yes", "y", false, "Optional. Skip confirmation prompt") + + command.SetCompletion(cmd, "instance-id", func() []string { + return getInstanceList(ctx, []string{STATE_RUNNING, STATE_STOPPED, STATE_ABNORMAL}, *req.ProjectId, *req.Region, "") + }) + + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/css/internal/css/describe.go b/products/css/internal/css/describe.go new file mode 100644 index 0000000000..82740e9e3f --- /dev/null +++ b/products/css/internal/css/describe.go @@ -0,0 +1,99 @@ +package css + +import ( + "fmt" + + "github.com/spf13/cobra" + + uessdk "github.com/ucloud/ucloud-sdk-go/services/ues" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDescribe ucloud css describe +func newDescribe(ctx *cli.Context) *cobra.Command { + var instanceID *string + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewDescribeUESInstanceV2Request() + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe UES instance details", + Long: "Describe UES instance details", + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*instanceID) + req.InstanceId = sdk.String(id) + resp, err := client.DescribeUESInstanceV2(req) + if err != nil { + ctx.HandleError(err) + return + } + cluster := resp.Result.ClusterInfo + rows := []cli.DescribeRow{ + {Attribute: "InstanceID", Content: cluster.UESInstanceId}, + {Attribute: "InstanceName", Content: cluster.UESInstanceName}, + {Attribute: "Region", Content: cluster.Region}, + {Attribute: "Zone", Content: cluster.Zone}, + {Attribute: "State", Content: cluster.State}, + {Attribute: "ServiceVersion", Content: cluster.ServiceVersion}, + {Attribute: "VPCId", Content: cluster.VPCId}, + {Attribute: "SubnetId", Content: cluster.SubnetId}, + {Attribute: "VIP", Content: cluster.Vip}, + {Attribute: "BusinessId", Content: cluster.BusinessId}, + } + // Add node information + if len(resp.Result.NodeInfoList) > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "--- Nodes ---", Content: fmt.Sprintf("%d nodes", len(resp.Result.NodeInfoList))}) + for i, node := range resp.Result.NodeInfoList { + prefix := fmt.Sprintf("Node[%d]", i) + rows = append(rows, + cli.DescribeRow{Attribute: prefix + ".NodeID", Content: node.NodeId}, + cli.DescribeRow{Attribute: prefix + ".NodeName", Content: node.NodeName}, + cli.DescribeRow{Attribute: prefix + ".NodeRole", Content: node.NodeRole}, + cli.DescribeRow{Attribute: prefix + ".NodeState", Content: node.NodeState}, + cli.DescribeRow{Attribute: prefix + ".NodeIP", Content: node.NodeIP}, + cli.DescribeRow{Attribute: prefix + ".NodeConf", Content: node.NodeConf}, + cli.DescribeRow{Attribute: prefix + ".CPU", Content: fmt.Sprintf("%d", node.CPU)}, + cli.DescribeRow{Attribute: prefix + ".Memory", Content: fmt.Sprintf("%dGB", node.Memory)}, + cli.DescribeRow{Attribute: prefix + ".DiskSize", Content: fmt.Sprintf("%dGB", node.DiskSize)}, + cli.DescribeRow{Attribute: prefix + ".DiskType", Content: node.DiskType}, + ) + } + } + ctx.PrintList(rows) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("instance-id", "", "Required. Instance ID to describe") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + command.SetCompletion(cmd, "instance-id", func() []string { + return getInstanceList(ctx, nil, *req.ProjectId, *req.Region, "") + }) + + cmd.MarkFlagRequired("instance-id") + + return cmd +} + +// describeUESInstanceByID returns the poller's describe func +func describeUESInstanceByID(ctx *cli.Context) func(instanceID string, commonBase *request.CommonBase) (interface{}, error) { + return func(instanceID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewDescribeUESInstanceV2Request() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.InstanceId = sdk.String(instanceID) + resp, err := client.DescribeUESInstanceV2(req) + if err != nil { + return nil, err + } + return &resp.Result.ClusterInfo, nil + } +} diff --git a/products/css/internal/css/disklimit.go b/products/css/internal/css/disklimit.go new file mode 100644 index 0000000000..e56a2f18e5 --- /dev/null +++ b/products/css/internal/css/disklimit.go @@ -0,0 +1,44 @@ +package css + +import ( + "fmt" + + "github.com/spf13/cobra" + + uessdk "github.com/ucloud/ucloud-sdk-go/services/ues" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDiskLimit ucloud css disk-limit +func newDiskLimit(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewGetUESDiskSizeLimitationRequest() + cmd := &cobra.Command{ + Use: "disk-limit", + Short: "List UES disk size limitations by disk type", + Long: "List UES disk size limitations by disk type", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.GetUESDiskSizeLimitation(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []DiskLimitRow{} + for _, d := range resp.DiskSizeLimitationSet { + list = append(list, DiskLimitRow{ + DiskType: d.DiskType, + MinSizeGB: fmt.Sprintf("%d", d.MinSize), + MaxSizeGB: fmt.Sprintf("%d", d.MaxSize), + }) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + return cmd +} diff --git a/products/css/internal/css/expand.go b/products/css/internal/css/expand.go new file mode 100644 index 0000000000..f825db4085 --- /dev/null +++ b/products/css/internal/css/expand.go @@ -0,0 +1,62 @@ +package css + +import ( + "fmt" + + "github.com/spf13/cobra" + + uessdk "github.com/ucloud/ucloud-sdk-go/services/ues" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newExpand ucloud css expand +func newExpand(ctx *cli.Context) *cobra.Command { + var async *bool + var instanceID *string + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewExpandUESInstanceRequest() + cmd := &cobra.Command{ + Use: "expand", + Short: "Expand UES instance node count", + Long: "Expand UES instance node count", + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*instanceID) + req.InstanceId = &id + w := ctx.ProgressWriter() + _, err := client.ExpandUESInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("ues[%s] is expanding", id) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUESInstanceByID(ctx)).Spoll(id, text, []string{STATE_RUNNING, STATE_ABNORMAL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "expand", Status: "Expanding"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("instance-id", "", "Required. Instance ID to expand") + req.NodeRole = flags.String("node-role", "", "Required. Node role to expand ('compute', 'coordinating')") + req.NodeCount = flags.Int("node-count", 0, "Required. Node count after expansion") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.Bool("async", false, "Optional. Do not wait for expansion to finish") + + command.SetFlagValues(cmd, "node-role", "compute", "coordinating") + command.SetCompletion(cmd, "instance-id", func() []string { + return getInstanceList(ctx, []string{STATE_RUNNING}, *req.ProjectId, *req.Region, "") + }) + + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("node-role") + cmd.MarkFlagRequired("node-count") + + return cmd +} diff --git a/products/css/internal/css/list.go b/products/css/internal/css/list.go new file mode 100644 index 0000000000..075d459e11 --- /dev/null +++ b/products/css/internal/css/list.go @@ -0,0 +1,57 @@ +package css + +import ( + "fmt" + + "github.com/spf13/cobra" + + uessdk "github.com/ucloud/ucloud-sdk-go/services/ues" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList ucloud css list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewListUESInstanceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List UES instances", + Long: "List UES instances", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.ListUESInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []InstanceRow{} + for _, ins := range resp.ClusterSet { + row := InstanceRow{ + InstanceID: ins.InstanceId, + InstanceName: ins.InstanceName, + AppName: ins.AppName, + AppVersion: ins.AppVersion, + Zone: ins.Zone, + State: ins.State, + NodeCount: fmt.Sprintf("%d", ins.NodeCount), + VPCId: ins.VPCId, + SubnetId: ins.SubnetId, + ChargeType: ins.ChargeType, + CreateTime: common.FormatDate(ins.CreateTime), + ExpireTime: common.FormatDate(ins.ExpireTime), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", "", "Optional. Assign availability zone") + req.Offset = flags.Int("offset", 0, "Optional. Offset") + req.Limit = flags.Int("limit", 30, "Optional. Limit, default 30") + return cmd +} diff --git a/products/css/internal/css/nodeconf.go b/products/css/internal/css/nodeconf.go new file mode 100644 index 0000000000..127fb047b9 --- /dev/null +++ b/products/css/internal/css/nodeconf.go @@ -0,0 +1,51 @@ +package css + +import ( + "fmt" + + "github.com/spf13/cobra" + + uessdk "github.com/ucloud/ucloud-sdk-go/services/ues" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newNodeConf ucloud css node-conf +func newNodeConf(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewGetUESNodeConfRequest() + cmd := &cobra.Command{ + Use: "node-conf", + Short: "List available UES node configurations", + Long: "List available UES node configurations", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.GetUESNodeConf(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []NodeConfRow{} + for _, n := range resp.NodeConfList { + list = append(list, NodeConfRow{ + NodeConf: n.NodeConf, + CPU: fmt.Sprintf("%d", n.CPU), + MemoryGB: fmt.Sprintf("%d", n.Memory), + DiskSizeGB: fmt.Sprintf("%d", n.DiskSize), + DiskType: n.DiskType, + SecGroup: fmt.Sprintf("%t", n.IsSecGroup), + }) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.AppVersion = flags.String("app-version", "", "Required. Application version, e.g. 7.10.2") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + cmd.MarkFlagRequired("app-version") + + return cmd +} diff --git a/products/css/internal/css/resize.go b/products/css/internal/css/resize.go new file mode 100644 index 0000000000..f726f132d6 --- /dev/null +++ b/products/css/internal/css/resize.go @@ -0,0 +1,63 @@ +package css + +import ( + "fmt" + + "github.com/spf13/cobra" + + uessdk "github.com/ucloud/ucloud-sdk-go/services/ues" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResize ucloud css resize +func newResize(ctx *cli.Context) *cobra.Command { + var async *bool + var instanceID *string + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewResizeUESInstanceRequest() + cmd := &cobra.Command{ + Use: "resize", + Short: "Resize UES instance node configuration", + Long: "Resize UES instance node configuration. Set node-conf to change spec, or node-disk-size-gb to change disk (leave the other at zero/empty).", + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*instanceID) + req.InstanceId = &id + w := ctx.ProgressWriter() + _, err := client.ResizeUESInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("ues[%s] is resizing", id) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUESInstanceByID(ctx)).Spoll(id, text, []string{STATE_RUNNING, STATE_ABNORMAL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "resize", Status: "Resizing"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("instance-id", "", "Required. Instance ID to resize") + req.NodeRole = flags.String("node-role", "", "Required. Node role ('compute', 'master', 'coordinating', 'kibana', 'dashboard')") + req.NodeConf = flags.String("node-conf", "", "Optional. Target node configuration. When empty, resize by node-disk-size-gb") + req.NodeDiskSize = flags.Int("node-disk-size-gb", 0, "Optional. Target node disk size in GB. When 0, resize by node-conf") + req.ForceResizing = flags.Bool("force", false, "Optional. Force resize without cluster health check, default false") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.Bool("async", false, "Optional. Do not wait for resize to finish") + + command.SetFlagValues(cmd, "node-role", "compute", "master", "coordinating", "kibana", "dashboard") + command.SetCompletion(cmd, "instance-id", func() []string { + return getInstanceList(ctx, []string{STATE_RUNNING}, *req.ProjectId, *req.Region, "") + }) + + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("node-role") + + return cmd +} diff --git a/products/css/internal/css/restart.go b/products/css/internal/css/restart.go new file mode 100644 index 0000000000..4339eedd2d --- /dev/null +++ b/products/css/internal/css/restart.go @@ -0,0 +1,67 @@ +package css + +import ( + "fmt" + + "github.com/spf13/cobra" + + uessdk "github.com/ucloud/ucloud-sdk-go/services/ues" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestart ucloud css restart +func newRestart(ctx *cli.Context) *cobra.Command { + var async *bool + var yes *bool + var instanceID *string + client := cli.NewServiceClient(ctx, uessdk.NewClient) + req := client.NewRestartUESInstanceRequest() + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart UES instance", + Long: "Restart UES instance", + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*instanceID) + ok, err := ctx.Confirm(*yes, fmt.Sprintf("Are you sure to restart UES instance[%s]?", id)) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.InstanceId = &id + w := ctx.ProgressWriter() + _, err = client.RestartUESInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("ues[%s] is restarting", id) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUESInstanceByID(ctx)).Spoll(id, text, []string{STATE_RUNNING, STATE_ABNORMAL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "restart", Status: "Restarting"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("instance-id", "", "Required. Instance ID to restart") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + yes = flags.BoolP("yes", "y", false, "Optional. Skip confirmation prompt") + async = flags.Bool("async", false, "Optional. Do not wait for restart to finish") + + command.SetCompletion(cmd, "instance-id", func() []string { + return getInstanceList(ctx, []string{STATE_RUNNING, STATE_ABNORMAL}, *req.ProjectId, *req.Region, "") + }) + + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/css/internal/css/rows.go b/products/css/internal/css/rows.go new file mode 100644 index 0000000000..ec18026934 --- /dev/null +++ b/products/css/internal/css/rows.go @@ -0,0 +1,41 @@ +package css + +// InstanceRow represents a UES instance in list output +type InstanceRow struct { + InstanceID string + InstanceName string + AppName string + AppVersion string + Zone string + State string + NodeCount string + VPCId string + SubnetId string + ChargeType string + CreateTime string + ExpireTime string +} + +// DiskLimitRow represents a disk size limitation in disk-limit output +type DiskLimitRow struct { + DiskType string + MinSizeGB string + MaxSizeGB string +} + +// NodeConfRow represents a node configuration in node-conf output +type NodeConfRow struct { + NodeConf string + CPU string + MemoryGB string + DiskSizeGB string + DiskType string + SecGroup string +} + +// AppVersionRow represents an application version in app-version output +type AppVersionRow struct { + AppName string + AppVersion string + IsMultiZone string +} diff --git a/products/css/internal/css/status.go b/products/css/internal/css/status.go new file mode 100644 index 0000000000..d5ea2f45e7 --- /dev/null +++ b/products/css/internal/css/status.go @@ -0,0 +1,11 @@ +package css + +// UES instance state constants +const ( + STATE_RUNNING = "Running" + STATE_STOPPED = "Stopped" + STATE_CREATING = "Creating" + STATE_DELETING = "Deleting" + STATE_ABNORMAL = "Abnormal" + STATE_RESTARTING = "Restarting" +) diff --git a/products/css/product.go b/products/css/product.go new file mode 100644 index 0000000000..d5b92eee0a --- /dev/null +++ b/products/css/product.go @@ -0,0 +1,21 @@ +package css + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalcss "github.com/ucloud/ucloud-cli/products/css/internal/css" +) + +type product struct{} + +// New returns the css product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "css", Commands: []string{"css"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalcss.NewCommand(ctx)} +} diff --git a/products/css/product.yaml b/products/css/product.yaml new file mode 100644 index 0000000000..b9689ae2f3 --- /dev/null +++ b/products/css/product.yaml @@ -0,0 +1,7 @@ +# products/css/product.yaml — css 产品元数据 +name: css +owners: + - rocky-ucloud +commands: + - css +enabled: true diff --git a/products/css/testdata/cmdtree.golden b/products/css/testdata/cmdtree.golden new file mode 100644 index 0000000000..e347cce693 --- /dev/null +++ b/products/css/testdata/cmdtree.golden @@ -0,0 +1,78 @@ +ucloud css use=css short=Manage UES (Elasticsearch/OpenSearch) instances +ucloud css app-version use=app-version short=List available UES application versions + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud css create use=create short=Create UES instance + flag=app-name short= default=elasticsearch required= + flag=app-version short= default= required=true + flag=async short= default=false required= + flag=business-id short= default= required= + flag=charge-type short= default=Month required= + flag=kibana-disk-conf short= default=CLOUD_RSSD required=true + flag=kibana-node-conf short= default= required=true + flag=name short= default= required=true + flag=node-conf short= default= required=true + flag=node-count short= default=3 required= + flag=node-disk-conf short= default=CLOUD_RSSD required=true + flag=node-disk-size-gb short= default=100 required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=remark short= default= required= + flag=service-passwd short= default= required= + flag=service-username short= default= required= + flag=subnet-id short= default= required=true + flag=vpc-id short= default= required=true + flag=zone short= default= required= +ucloud css delete use=delete short=Delete UES instances + flag=instance-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud css describe use=describe short=Describe UES instance details + flag=instance-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud css disk-limit use=disk-limit short=List UES disk size limitations by disk type + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud css expand use=expand short=Expand UES instance node count + flag=async short= default=false required= + flag=instance-id short= default= required=true + flag=node-count short= default=0 required=true + flag=node-role short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud css list use=list short=List UES instances + flag=limit short= default=30 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud css node-conf use=node-conf short=List available UES node configurations + flag=app-version short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud css resize use=resize short=Resize UES instance node configuration + flag=async short= default=false required= + flag=force short= default=false required= + flag=instance-id short= default= required=true + flag=node-conf short= default= required= + flag=node-disk-size-gb short= default=0 required= + flag=node-role short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud css restart use=restart short=Restart UES instance + flag=async short= default=false required= + flag=instance-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= + flag=zone short= default= required= diff --git a/products/css/testdata/completion.golden b/products/css/testdata/completion.golden new file mode 100644 index 0000000000..2b7df5a0cf --- /dev/null +++ b/products/css/testdata/completion.golden @@ -0,0 +1,8 @@ +ucloud css create charge-type static Dynamic,Month,Year +ucloud css delete instance-id dynamic +ucloud css describe instance-id dynamic +ucloud css expand instance-id dynamic +ucloud css expand node-role static compute,coordinating +ucloud css resize instance-id dynamic +ucloud css resize node-role static compute,coordinating,dashboard,kibana,master +ucloud css restart instance-id dynamic diff --git a/products/eip/internal/eip/allocate.go b/products/eip/internal/eip/allocate.go new file mode 100644 index 0000000000..5c07e4aafb --- /dev/null +++ b/products/eip/internal/eip/allocate.go @@ -0,0 +1,78 @@ +package eip + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newAllocate ucloud eip allocate +func newAllocate(ctx *cli.Context) *cobra.Command { + var count *int + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewAllocateEIPRequest() + cmd := &cobra.Command{ + Use: "allocate", + Short: "Allocate EIP", + Long: "Allocate EIP", + Example: "ucloud eip allocate --line BGP --bandwidth-mb 2", + Run: func(cmd *cobra.Command, args []string) { + if *req.OperatorName == "" { + *req.OperatorName = getEIPLine(*req.Region) + } + results := []cli.OpResultRow{} + for i := 0; i < *count; i++ { + resp, err := client.AllocateEIP(req) + if err != nil { + ctx.HandleError(err) + continue + } + for _, eip := range resp.EIPSet { + fmt.Fprintf(ctx.ProgressWriter(), "allocate EIP[%s] ", eip.EIPId) + for _, ip := range eip.EIPAddr { + fmt.Fprintf(ctx.ProgressWriter(), "IP:%s Line:%s \n", ip.IP, ip.OperatorName) + } + results = append(results, cli.OpResultRow{ResourceID: eip.EIPId, Action: "allocate", Status: "Allocated"}) + } + } + ctx.EmitResult(results...) + }, + } + cmd.Flags().SortFlags = false + req.Bandwidth = cmd.Flags().Int("bandwidth-mb", 0, "Required. Bandwidth(Unit:Mbps).The range of value related to network charge mode. By traffic [1, 200]; by bandwidth [1,800] (Unit: Mbps); it could be 0 if the eip belong to the shared bandwidth") + req.OperatorName = cmd.Flags().String("line", "", "Optional. 'BGP' or 'International'. 'BGP' could be set in China mainland regions, such as cn-bj2 etc. 'International' could be set in the regions beyond mainland, such as hk, tw-kh, us-ws etc.") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.PayMode = cmd.Flags().String("traffic-mode", "Bandwidth", "Optional. traffic-mode is an enumeration value. 'Traffic','Bandwidth' or 'ShareBandwidth'") + req.ShareBandwidthId = cmd.Flags().String("share-bandwidth-id", "", "Optional. ShareBandwidthId, required only when traffic-mode is 'ShareBandwidth'") + req.Quantity = cmd.Flags().Int("quantity", 1, "Optional. The duration of the instance. N years/months.") + req.ChargeType = cmd.Flags().String("charge-type", "Month", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly(requires permission),'Trial', free trial(need permission)") + req.Tag = cmd.Flags().String("group", "Default", "Optional. Group of your EIP.") + req.Name = cmd.Flags().String("name", "EIP", "Optional. Name of your EIP.") + req.Remark = cmd.Flags().String("remark", "", "Optional. Remark of your EIP.") + count = cmd.Flags().Int("count", 1, "Optional. Count of EIP to allocate") + + command.SetFlagValues(cmd, "line", "BGP", "International") + command.SetFlagValues(cmd, "traffic-mode", "Bandwidth", "Traffic", "ShareBandwidth") + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic", "Trial") + cmd.MarkFlagRequired("bandwidth-mb") + return cmd +} + +// getEIPLine returns the default EIP line for a region. Product-local copy of +// cmd/util.go getEIPLine (domain logic, D-D: COPIED into the product, never +// promoted to platform). "cn" regions default to BGP, others to International. +func getEIPLine(region string) (line string) { + if strings.HasPrefix(region, "cn") { + line = "BGP" + } else { + line = "International" + } + return +} diff --git a/products/eip/internal/eip/bind.go b/products/eip/internal/eip/bind.go new file mode 100644 index 0000000000..a3206c2d4c --- /dev/null +++ b/products/eip/internal/eip/bind.go @@ -0,0 +1,117 @@ +package eip + +import ( + "fmt" + "net" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBind ucloud eip bind +func newBind(ctx *cli.Context) *cobra.Command { + var projectID, region, resourceID, resourceType *string + var eipIDs []string + cmd := &cobra.Command{ + Use: "bind", + Short: "Bind EIP with uhost", + Long: "Bind EIP with uhost", + Example: "ucloud eip bind --eip-id eip-xxx --resource-id uhost-xxx", + Run: func(cmd *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, eipID := range eipIDs { + if err := bindEIP(ctx, resourceID, resourceType, &eipID, projectID, region); err == nil { + results = append(results, cli.OpResultRow{ResourceID: ctx.PickResourceID(eipID), Action: "bind", Status: "Bound"}) + } + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + cmd.Flags().StringSliceVar(&eipIDs, "eip-id", nil, "Required. EIPId to bind") + resourceID = cmd.Flags().String("resource-id", "", "Required. ResourceID , which is the UHostId of uhost") + resourceType = cmd.Flags().String("resource-type", "uhost", "Requried. ResourceType, type of resource to bind with eip. 'uhost','vrouter','ulb','upm','hadoophost'.eg..") + projectID = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + + command.SetFlagValues(cmd, "resource-type", "uhost", "vrouter", "ulb", "upm", "hadoophost", "fortresshost", "udockhost", "udhost", "natgw", "udb", "vpngw", "ucdr", "dbaudit") + command.SetCompletion(cmd, "eip-id", func() []string { + return getAllEip(ctx, *projectID, *region, []string{EIP_FREE}, nil) + }) + + cmd.MarkFlagRequired("eip-id") + cmd.MarkFlagRequired("resource-id") + + return cmd +} + +// bindEIP binds an EIP to a resource. Ported from cmd/eip.go +// (base.BizClient → cli.NewServiceClient; progress→ProgressWriter, +// errors→ctx.HandleError). Returns a non-nil error when the bind fails so the +// caller only emits a structured "Bound" result on success (machine output must +// not report success for a failed operation). +func bindEIP(ctx *cli.Context, resourceID, resourceType, eipID, projectID, region *string) error { + ip := net.ParseIP(*eipID) + if ip != nil { + id, err := getEIPIDbyIP(ctx, ip, *projectID, *region) + if err != nil { + ctx.HandleError(err) + } else { + *eipID = id + } + } + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewBindEIPRequest() + req.ResourceId = resourceID + req.ResourceType = resourceType + req.EIPId = sdk.String(ctx.PickResourceID(*eipID)) + req.ProjectId = sdk.String(ctx.PickResourceID(*projectID)) + req.Region = region + _, err := client.BindEIP(req) + if err != nil { + ctx.HandleError(err) + return err + } + fmt.Fprintf(ctx.ProgressWriter(), "bind EIP[%s] with %s[%s]\n", *req.EIPId, *req.ResourceType, *req.ResourceId) + return nil +} + +// sbindEIP binds an EIP to a resource, returning a log trail instead of +// printing (used for concurrent flows). Ported from cmd/eip.go; the +// base.ToQueryMap request-log line is dropped (platform SDK handler logs +// requests now, D-C). +// Retained as the canonical product-local copy for uhost Part 6 (see batch-1 +// plan); not yet called within the eip product. +func sbindEIP(ctx *cli.Context, resourceID, resourceType, eipID, projectID, region *string) ([]string, error) { + logs := make([]string, 0) + ip := net.ParseIP(*eipID) + if ip != nil { + id, err := getEIPIDbyIP(ctx, ip, *projectID, *region) + if err != nil { + ctx.HandleError(err) + } else { + *eipID = id + } + } + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewBindEIPRequest() + req.ResourceId = resourceID + req.ResourceType = resourceType + req.EIPId = sdk.String(ctx.PickResourceID(*eipID)) + req.ProjectId = sdk.String(ctx.PickResourceID(*projectID)) + req.Region = region + _, err := client.BindEIP(req) + if err != nil { + logs = append(logs, fmt.Sprintf("bind eip failed: %v", err)) + return logs, err + } + logs = append(logs, fmt.Sprintf("bind eip[%s] with %s[%s] successfully", *req.EIPId, *req.ResourceType, *req.ResourceId)) + return logs, nil +} diff --git a/products/eip/internal/eip/cmd.go b/products/eip/internal/eip/cmd.go new file mode 100644 index 0000000000..d3ac8be224 --- /dev/null +++ b/products/eip/internal/eip/cmd.go @@ -0,0 +1,28 @@ +package eip + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `eip` root command and mounts the 9 subcommands. +// Mirrors cmd/eip.go NewCmdEIP (same AddCommand order). +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "eip", + Short: "List,allocate and release EIP", + Long: `Manipulate EIP, such as list,allocate and release`, + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newAllocate(ctx)) + cmd.AddCommand(newRelease(ctx)) + cmd.AddCommand(newBind(ctx)) + cmd.AddCommand(newUnbind(ctx)) + cmd.AddCommand(newModifyBandwidth(ctx)) + cmd.AddCommand(newSetChargeMode(ctx)) + cmd.AddCommand(newJoinSharedBW(ctx)) + cmd.AddCommand(newLeaveSharedBW(ctx)) + return cmd +} diff --git a/products/eip/internal/eip/completion.go b/products/eip/internal/eip/completion.go new file mode 100644 index 0000000000..dcf7f2a04c --- /dev/null +++ b/products/eip/internal/eip/completion.go @@ -0,0 +1,73 @@ +package eip + +import ( + "strings" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// getAllEip returns "EIPId/ip1,ip2" completion candidates filtered by states and +// paymodes (nil filter = no filter). Ported from cmd/eip.go; uses the +// package-local fetchAllEip. +func getAllEip(ctx *cli.Context, projectID, region string, states, paymodes []string) []string { + list, err := fetchAllEip(ctx, projectID, region) + if err != nil { + return nil + } + strs := []string{} + for _, item := range list { + rightState := false + if states == nil { + rightState = true + } else { + for _, s := range states { + if item.Status == s { + rightState = true + } + } + } + + rightPayMode := false + if paymodes == nil { + rightPayMode = true + } else { + for _, m := range paymodes { + if item.PayMode == m { + rightPayMode = true + } + } + } + if !rightPayMode || !rightState { + continue + } + + ips := []string{} + for _, ip := range item.EIPAddr { + ips = append(ips, ip.IP) + } + strs = append(strs, item.EIPId+"/"+strings.Join(ips, ",")) + } + return strs +} + +// getAllSharedBW returns "ShareBandwidthId/Name" completion candidates for +// shared bandwidth instances in project/region. Self-contained SDK call COPIED +// from cmd/bandwidth.go getAllSharedBW (base.BizClient → cli.NewServiceClient), +// for the join-shared-bw / leave-shared-bw --shared-bw-id completion. +func getAllSharedBW(ctx *cli.Context, project, region string) ([]string, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeShareBandwidthRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeShareBandwidth(req) + if err != nil { + return nil, err + } + list := []string{} + for _, item := range resp.DataSet { + list = append(list, item.ShareBandwidthId+"/"+item.Name) + } + return list, nil +} diff --git a/products/eip/internal/eip/describe.go b/products/eip/internal/eip/describe.go new file mode 100644 index 0000000000..3343f261b1 --- /dev/null +++ b/products/eip/internal/eip/describe.go @@ -0,0 +1,69 @@ +package eip + +import ( + "fmt" + "net" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// getEIPIDbyIP resolves an EIP id from an IP address within project/region. +// Ported from cmd/eip.go (base.BizClient → cli.NewServiceClient). +func getEIPIDbyIP(ctx *cli.Context, ip net.IP, projectID, region string) (string, error) { + eipList, err := fetchAllEip(ctx, projectID, region) + if err != nil { + return "", err + } + for _, eip := range eipList { + for _, addr := range eip.EIPAddr { + if addr.IP == ip.String() { + return eip.EIPId, nil + } + } + } + return "", fmt.Errorf("IP[%s] not exist", ip.String()) +} + +// fetchAllEip lists all EIPs in project/region, paging by 100. Ported from +// cmd/eip.go (base.BizClient → cli.NewServiceClient). +func fetchAllEip(ctx *cli.Context, projectID, region string) ([]unet.UnetEIPSet, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeEIPRequest() + list := []unet.UnetEIPSet{} + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + for offset, step := 0, 100; ; offset += step { + req.Offset = &offset + req.Limit = &step + resp, err := client.DescribeEIP(req) + if err != nil { + return nil, err + } + for i, size := 0, len(resp.EIPSet); i < size; i++ { + list = append(list, resp.EIPSet[i]) + } + if resp.TotalCount <= offset+step { + break + } + } + return list, nil +} + +// getEIP fetches a single EIP by id. Ported from cmd/eip.go +// (base.BizClient → cli.NewServiceClient). +func getEIP(ctx *cli.Context, eipID string) (*unet.UnetEIPSet, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeEIPRequest() + req.EIPIds = append(req.EIPIds, eipID) + resp, err := client.DescribeEIP(req) + if err != nil { + return nil, err + } + if len(resp.EIPSet) == 1 { + return &resp.EIPSet[0], nil + } + return nil, fmt.Errorf("eip[%s] may not exist", eipID) +} diff --git a/products/eip/internal/eip/join_shared_bw.go b/products/eip/internal/eip/join_shared_bw.go new file mode 100644 index 0000000000..b1e6facc56 --- /dev/null +++ b/products/eip/internal/eip/join_shared_bw.go @@ -0,0 +1,60 @@ +package eip + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newJoinSharedBW ucloud eip join-shared-bw +func newJoinSharedBW(ctx *cli.Context) *cobra.Command { + eipIDs := []string{} + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewAssociateEIPWithShareBandwidthRequest() + cmd := &cobra.Command{ + Use: "join-shared-bw", + Short: "Join shared bandwidth", + Long: "Join shared bandwidth", + Example: "ucloud eip join-shared-bw --eip-id eip-xxx --shared-bw-id bwshare-xxx", + Run: func(c *cobra.Command, args []string) { + for _, eip := range eipIDs { + req.EIPIds = append(req.EIPIds, ctx.PickResourceID(eip)) + } + req.ShareBandwidthId = sdk.String(ctx.PickResourceID(*req.ShareBandwidthId)) + _, err := client.AssociateEIPWithShareBandwidth(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "eip%v joined shared bandwidth[%s]\n", req.EIPIds, *req.ShareBandwidthId) + results := []cli.OpResultRow{} + for _, eipID := range req.EIPIds { + results = append(results, cli.OpResultRow{ResourceID: eipID, Action: "join-shared-bw", Status: "Joined"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&eipIDs, "eip-id", nil, "Required. Resource ID of EIPs to join shared bandwdith") + req.ShareBandwidthId = flags.String("shared-bw-id", "", "Required. Resource ID of shared bandwidth to be joined") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Region, see 'ucloud region'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + command.SetCompletion(cmd, "eip-id", func() []string { + return getAllEip(ctx, *req.ProjectId, *req.Region, nil, []string{EIP_CHARGE_BANDWIDTH, EIP_CHARGE_TRAFFIC}) + }) + command.SetCompletion(cmd, "shared-bw-id", func() []string { + list, _ := getAllSharedBW(ctx, *req.ProjectId, *req.Region) + return list + }) + cmd.MarkFlagRequired("eip-id") + cmd.MarkFlagRequired("shared-bw-id") + + return cmd +} diff --git a/products/eip/internal/eip/leave_shared_bw.go b/products/eip/internal/eip/leave_shared_bw.go new file mode 100644 index 0000000000..aab98cf82b --- /dev/null +++ b/products/eip/internal/eip/leave_shared_bw.go @@ -0,0 +1,90 @@ +package eip + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newLeaveSharedBW ucloud eip leave-shared-bw +func newLeaveSharedBW(ctx *cli.Context) *cobra.Command { + eipIDs := []string{} + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDisassociateEIPWithShareBandwidthRequest() + cmd := &cobra.Command{ + Use: "leave-shared-bw", + Short: "Leave shared bandwidth", + Long: "Leave shared bandwidth", + Example: "ucloud eip leave-shared-bw --eip-id eip-b2gvu3", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + if *req.ShareBandwidthId == "" { + for _, eipID := range eipIDs { + eipIns, err := getEIP(ctx, ctx.PickResourceID(eipID)) + if err != nil { + ctx.HandleError(err) + continue + } + sharedBWID := eipIns.ShareBandwidthSet.ShareBandwidthId + if sharedBWID == "" { + fmt.Fprintf(ctx.ProgressWriter(), "eip[%s] doesn't join any shared bandwidth\n", eipID) + continue + } + req.ShareBandwidthId = sdk.String(sharedBWID) + req.EIPIds = []string{ctx.PickResourceID(eipID)} + _, err = client.DisassociateEIPWithShareBandwidth(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "eip[%s] left shared bandwidth[%s]\n", eipID, sharedBWID) + results = append(results, cli.OpResultRow{ResourceID: ctx.PickResourceID(eipID), Action: "leave-shared-bw", Status: "Left"}) + } + } else { + for _, id := range eipIDs { + req.EIPIds = append(req.EIPIds, ctx.PickResourceID(id)) + } + *req.ShareBandwidthId = ctx.PickResourceID(*req.ShareBandwidthId) + _, err := client.DisassociateEIPWithShareBandwidth(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "eip%v left shared bandwidth[%s]\n", eipIDs, *req.ShareBandwidthId) + for _, eipID := range req.EIPIds { + results = append(results, cli.OpResultRow{ResourceID: eipID, Action: "leave-shared-bw", Status: "Left"}) + } + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&eipIDs, "eip-id", nil, "Required. Resource ID of EIPs to leave shared bandwidth") + req.Bandwidth = flags.Int("bandwidth-mb", 1, "Required. Bandwidth of EIP after leaving shared bandwidth, ranging [1,300] for 'Traffic' charge mode, ranging [1,800] for 'Bandwidth' charge mode. Unit:Mb") + req.PayMode = flags.String("traffic-mode", "Bandwidth", "Optional. Charge mode of the EIP after leaving shared bandwidth, 'Bandwidth' or 'Traffic'") + req.ShareBandwidthId = flags.String("shared-bw-id", "", "Optional. Resource ID of shared bandwidth instance, assign this flag to make the operation faster") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Region, see 'ucloud region'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + command.SetFlagValues(cmd, "traffic-mode", "Bandwidth", "Traffic") + command.SetCompletion(cmd, "eip-id", func() []string { + return getAllEip(ctx, *req.ProjectId, *req.Region, nil, []string{EIP_CHARGE_SHARE}) + }) + command.SetCompletion(cmd, "shared-bw-id", func() []string { + list, _ := getAllSharedBW(ctx, *req.ProjectId, *req.Region) + return list + }) + + // L2 prebug preserved verbatim: the flag is named "bandwidth-mb" (above), so + // MarkFlagRequired("bandwidth") is a silent no-op. Matches cmd/eip.go ~:649. + cmd.MarkFlagRequired("bandwidth") + cmd.MarkFlagRequired("eip-id") + return cmd +} diff --git a/products/eip/internal/eip/list.go b/products/eip/internal/eip/list.go new file mode 100644 index 0000000000..d388b230e3 --- /dev/null +++ b/products/eip/internal/eip/list.go @@ -0,0 +1,78 @@ +package eip + +import ( + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList ucloud eip list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeEIPRequest() + fetchAll := false + pageOff := false + cmd := &cobra.Command{ + Use: "list", + Short: "List all EIP instances", + Long: `List all EIP instances`, + Example: "ucloud eip list", + Run: func(cmd *cobra.Command, args []string) { + var eipList []unet.UnetEIPSet + if fetchAll || pageOff { + list, err := fetchAllEip(ctx, *req.ProjectId, *req.Region) + if err != nil { + ctx.HandleError(err) + return + } + eipList = list + } else { + resp, err := client.DescribeEIP(req) + if err != nil { + ctx.HandleError(err) + return + } + eipList = resp.EIPSet + } + + list := make([]EIPRow, 0) + for _, eip := range eipList { + row := EIPRow{} + row.Name = eip.Name + for _, ip := range eip.EIPAddr { + row.IP += ip.IP + " " + ip.OperatorName + " " + } + row.ResourceID = eip.EIPId + row.Group = eip.Tag + row.ChargeMode = eip.PayMode + row.Bandwidth = strconv.Itoa(eip.Bandwidth) + "Mb" + if eip.Resource.ResourceID != "" { + row.BindResource = fmt.Sprintf("%s|%s(%s)", eip.Resource.ResourceName, eip.Resource.ResourceID, eip.Resource.ResourceType) + } + row.Status = eip.Status + row.ExpirationTime = time.Unix(int64(eip.ExpireTime), 0).Format("2006-01-02") + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.Offset = flags.Int("offset", 0, "Optional. Offset default 0") + req.Limit = flags.Int("limit", 50, "Optional. Limit default 50, max value 100") + flags.BoolVar(&fetchAll, "list-all", false, "List all eip") + flags.BoolVar(&pageOff, "page-off", false, "Optional. Paging or not. Accept values: true or false") + command.SetFlagValues(cmd, "list-all", "true", "false") + flags.MarkDeprecated("list-all", "please use '--page-off' instead") + + return cmd +} diff --git a/products/eip/internal/eip/modify_bw.go b/products/eip/internal/eip/modify_bw.go new file mode 100644 index 0000000000..9a6d7ac8cc --- /dev/null +++ b/products/eip/internal/eip/modify_bw.go @@ -0,0 +1,52 @@ +package eip + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newModifyBandwidth ucloud eip modify-bw +func newModifyBandwidth(ctx *cli.Context) *cobra.Command { + ids := []string{} + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewModifyEIPBandwidthRequest() + cmd := &cobra.Command{ + Use: "modify-bw", + Short: "Modify bandwith of EIP instances", + Long: "Modify bandwith of EIP instances", + Example: "ucloud eip modify-bw --eip-id eip-xx1,eip-xx2 --bandwidth-mb 20", + // Deprecated: "use 'ucloud eip modiy'", + Run: func(cmd *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, id := range ids { + id = ctx.PickResourceID(id) + req.EIPId = &id + _, err := client.ModifyEIPBandwidth(req) + if err != nil { + ctx.HandleError(err) + } else { + fmt.Fprintf(ctx.ProgressWriter(), "eip[%s]'s bandwidth modified\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "modify-bw", Status: "Modified"}) + } + } + ctx.EmitResult(results...) + }, + } + cmd.Flags().SortFlags = false + cmd.Flags().StringSliceVarP(&ids, "eip-id", "", nil, "Required, Resource ID of EIPs to modify bandwidth") + req.Bandwidth = cmd.Flags().Int("bandwidth-mb", 0, "Required. Bandwidth of EIP after modifed. Charge by traffic, range [1,300]; charge by bandwidth, range [1,800]") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + command.SetCompletion(cmd, "eip-id", func() []string { + return getAllEip(ctx, *req.ProjectId, *req.Region, nil, nil) + }) + cmd.MarkFlagRequired("eip-id") + cmd.MarkFlagRequired("bandwidth-mb") + return cmd +} diff --git a/products/eip/internal/eip/modify_traffic_mode.go b/products/eip/internal/eip/modify_traffic_mode.go new file mode 100644 index 0000000000..40c17dccc4 --- /dev/null +++ b/products/eip/internal/eip/modify_traffic_mode.go @@ -0,0 +1,60 @@ +package eip + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newSetChargeMode ucloud eip modify-traffic-mode +func newSetChargeMode(ctx *cli.Context) *cobra.Command { + ids := []string{} + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewSetEIPPayModeRequest() + cmd := &cobra.Command{ + Use: "modify-traffic-mode", + Short: "Modify charge mode of EIP instances", + Long: "Modify charge mode of EIP instances", + Example: "ucloud eip modify-traffic-mode --eip-id eip-xx1,eip-xx2 --traffic-mode Traffic", + Run: func(cmd *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, id := range ids { + id = ctx.PickResourceID(id) + req.EIPId = &id + eipIns, err := getEIP(ctx, id) + if err != nil { + ctx.HandleError(err) + return + } + req.Bandwidth = sdk.Int(eipIns.Bandwidth) + _, err = client.SetEIPPayMode(req) + if err != nil { + ctx.HandleError(err) + } else { + fmt.Fprintf(ctx.ProgressWriter(), "eip[%s]'s charge mode was modified to %s\n", id, *req.PayMode) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "modify-traffic-mode", Status: "Modified"}) + } + } + ctx.EmitResult(results...) + }, + } + + cmd.Flags().SortFlags = false + cmd.Flags().StringSliceVarP(&ids, "eip-id", "", nil, "Required, Resource ID of EIPs to modify charge mode") + req.PayMode = cmd.Flags().String("traffic-mode", "", "Required, Charge mode of eip, 'Traffic','Bandwidth' or 'PostAccurateBandwidth'") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + command.SetFlagValues(cmd, "traffic-mode", "Bandwidth", "Traffic", "PostAccurateBandwidth") + command.SetCompletion(cmd, "eip-id", func() []string { + return getAllEip(ctx, *req.ProjectId, *req.Region, nil, nil) + }) + cmd.MarkFlagRequired("eip-id") + cmd.MarkFlagRequired("traffic-mode") + return cmd +} diff --git a/products/eip/internal/eip/release.go b/products/eip/internal/eip/release.go new file mode 100644 index 0000000000..90598d1e34 --- /dev/null +++ b/products/eip/internal/eip/release.go @@ -0,0 +1,52 @@ +package eip + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRelease ucloud eip release +func newRelease(ctx *cli.Context) *cobra.Command { + var ids []string + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewReleaseEIPRequest() + cmd := &cobra.Command{ + Use: "release", + Short: "Release EIP", + Long: "Release EIP", + Example: "ucloud eip release --eip-id eip-xx1,eip-xx2", + Run: func(cmd *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, id := range ids { + req.EIPId = sdk.String(ctx.PickResourceID(id)) + _, err := client.ReleaseEIP(req) + if err != nil { + ctx.HandleError(err) + } else { + fmt.Fprintf(ctx.ProgressWriter(), "eip[%s] released\n", *req.EIPId) + results = append(results, cli.OpResultRow{ResourceID: *req.EIPId, Action: "release", Status: "Released"}) + } + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVarP(&ids, "eip-id", "", nil, "Required. Resource ID of the EIPs you want to release") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + cmd.MarkFlagRequired("eip-id") + command.SetCompletion(cmd, "eip-id", func() []string { + return getAllEip(ctx, *req.ProjectId, *req.Region, []string{EIP_FREE}, nil) + }) + + return cmd +} diff --git a/products/eip/internal/eip/rows.go b/products/eip/internal/eip/rows.go new file mode 100644 index 0000000000..341c0615e5 --- /dev/null +++ b/products/eip/internal/eip/rows.go @@ -0,0 +1,14 @@ +package eip + +// EIPRow 表格行 +type EIPRow struct { + Name string + IP string + ResourceID string + Group string + ChargeMode string + Bandwidth string + BindResource string + Status string + ExpirationTime string +} diff --git a/products/eip/internal/eip/status.go b/products/eip/internal/eip/status.go new file mode 100644 index 0000000000..7fc5c937ca --- /dev/null +++ b/products/eip/internal/eip/status.go @@ -0,0 +1,12 @@ +package eip + +// EIP-domain state/charge-mode constants, product-owned copies (formerly +// model/status). +const ( + EIP_FREE = "free" + EIP_USED = "used" + + EIP_CHARGE_BANDWIDTH = "Bandwidth" + EIP_CHARGE_TRAFFIC = "Traffic" + EIP_CHARGE_SHARE = "ShareBandwidth" +) diff --git a/products/eip/internal/eip/unbind.go b/products/eip/internal/eip/unbind.go new file mode 100644 index 0000000000..272900ec84 --- /dev/null +++ b/products/eip/internal/eip/unbind.go @@ -0,0 +1,95 @@ +package eip + +import ( + "fmt" + "net" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUnbind ucloud eip unbind +func newUnbind(ctx *cli.Context) *cobra.Command { + eipIDs := []string{} + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewUnBindEIPRequest() + cmd := &cobra.Command{ + Use: "unbind", + Short: "Unbind EIP with uhost", + Long: "Unbind EIP with uhost", + Example: "ucloud eip unbind --eip-id eip-xxx", + Run: func(cmd *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, eip := range eipIDs { + eipIns, err := getEIP(ctx, ctx.PickResourceID(eip)) + if err != nil { + ctx.HandleError(err) + return + } + req.EIPId = sdk.String(ctx.PickResourceID(eip)) + req.ResourceId = sdk.String(eipIns.Resource.ResourceID) + req.ResourceType = sdk.String(eipIns.Resource.ResourceType) + _, err = client.UnBindEIP(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "unbind EIP[%s] with %s[%s]\n", *req.EIPId, *req.ResourceType, *req.ResourceId) + results = append(results, cli.OpResultRow{ResourceID: *req.EIPId, Action: "unbind", Status: "Unbound"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&eipIDs, "eip-id", nil, "Required. Resource ID of eips to unbind with some resource") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("eip-id") + command.SetCompletion(cmd, "eip-id", func() []string { + return getAllEip(ctx, *req.ProjectId, *req.Region, []string{EIP_USED}, nil) + }) + + return cmd +} + +// unbindEIP unbinds an EIP from a resource, returning a log trail. Ported from +// cmd/eip.go; the base.ToQueryMap request-log line is dropped (platform SDK +// handler logs requests now, D-C). +// Retained as the canonical product-local copy for uhost Part 6 (see batch-1 +// plan); not yet called within the eip product. +func unbindEIP(ctx *cli.Context, resourceID, resourceType, eipID, projectID, region string) ([]string, error) { + logs := make([]string, 0) + eipID = ctx.PickResourceID(eipID) + ip := net.ParseIP(eipID) + if ip != nil { + id, err := getEIPIDbyIP(ctx, ip, projectID, region) + if err != nil { + ctx.HandleError(err) + } else { + eipID = id + } + } + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewUnBindEIPRequest() + req.ResourceId = &resourceID + req.ResourceType = &resourceType + req.EIPId = &eipID + req.ProjectId = sdk.String(ctx.PickResourceID(projectID)) + req.Region = ®ion + _, err := client.UnBindEIP(req) + if err != nil { + logs = append(logs, fmt.Sprintf("unbind eip failed: %v", err)) + return logs, err + } + logs = append(logs, fmt.Sprintf("unbind eip[%s] with %s[%s] successfully", *req.EIPId, *req.ResourceType, *req.ResourceId)) + return logs, nil +} diff --git a/products/eip/internal/ext/cmd.go b/products/eip/internal/ext/cmd.go new file mode 100644 index 0000000000..9240f10f14 --- /dev/null +++ b/products/eip/internal/ext/cmd.go @@ -0,0 +1,19 @@ +package ext + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `ext` root command owned by products/eip. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ext", + Short: "extended commands of UCloud CLI", + Long: "extended commands of UCloud CLI", + Args: cobra.NoArgs, + } + cmd.AddCommand(newUHost(ctx)) + return cmd +} diff --git a/products/eip/internal/ext/completion.go b/products/eip/internal/ext/completion.go new file mode 100644 index 0000000000..eb6dab0892 --- /dev/null +++ b/products/eip/internal/ext/completion.go @@ -0,0 +1,43 @@ +package ext + +import ( + "strings" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func listUHostIDs(ctx *cli.Context, states []string, project, region, zone string) []string { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeUHostInstanceRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + req.Limit = sdk.Int(50) + resp, err := client.DescribeUHostInstance(req) + if err != nil { + return nil + } + list := []string{} + for _, host := range resp.UHostSet { + if !hostStateAllowed(host.State, states) { + continue + } + list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) + } + return list +} + +func hostStateAllowed(state string, states []string) bool { + if states == nil { + return true + } + for _, s := range states { + if state == s { + return true + } + } + return false +} diff --git a/products/eip/internal/ext/describe.go b/products/eip/internal/ext/describe.go new file mode 100644 index 0000000000..c74a9ba6ad --- /dev/null +++ b/products/eip/internal/ext/describe.go @@ -0,0 +1,27 @@ +package ext + +import ( + "fmt" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func describeUHostByID(ctx *cli.Context, uhostID, projectID, region, zone string) (*uhostsdk.UHostInstanceSet, error) { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeUHostInstanceRequest() + req.UHostIds = []string{uhostID} + req.ProjectId = &projectID + req.Region = ®ion + req.Zone = &zone + + resp, err := client.DescribeUHostInstance(req) + if err != nil { + return nil, err + } + if len(resp.UHostSet) < 1 { + return nil, fmt.Errorf("uhost [%s] does not exist", uhostID) + } + return &resp.UHostSet[0], nil +} diff --git a/products/eip/internal/ext/status.go b/products/eip/internal/ext/status.go new file mode 100644 index 0000000000..e4ea6bbe4e --- /dev/null +++ b/products/eip/internal/ext/status.go @@ -0,0 +1,7 @@ +package ext + +const ( + hostRunning = "Running" + hostStopped = "Stopped" + hostFail = "Install Fail" +) diff --git a/products/eip/internal/ext/uhost.go b/products/eip/internal/ext/uhost.go new file mode 100644 index 0000000000..ba06661cd0 --- /dev/null +++ b/products/eip/internal/ext/uhost.go @@ -0,0 +1,19 @@ +package ext + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUHost builds `ucloud ext uhost`. +func newUHost(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "uhost", + Short: "extended uhost commands", + Long: "extended uhost commands", + Args: cobra.NoArgs, + } + cmd.AddCommand(newUHostSwitchEIP(ctx)) + return cmd +} diff --git a/products/eip/internal/ext/uhost_switch_eip.go b/products/eip/internal/ext/uhost_switch_eip.go new file mode 100644 index 0000000000..06e269e185 --- /dev/null +++ b/products/eip/internal/ext/uhost_switch_eip.go @@ -0,0 +1,274 @@ +package ext + +import ( + "fmt" + "net" + "strings" + + "github.com/spf13/cobra" + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUHostSwitchEIP builds `ucloud ext uhost switch-eip`. +func newUHostSwitchEIP(ctx *cli.Context) *cobra.Command { + var eipAddrs []string + var eipBandwidth, quantity int + var chargeType, trafficMode, shareBandwidthID string + var uhostIDs []string + var unbind, release bool + + uhostClient := cli.NewServiceClient(ctx, uhostsdk.NewClient) + describeReq := uhostClient.NewDescribeUHostInstanceRequest() + + cmd := &cobra.Command{ + Use: "switch-eip", + Short: "Switch EIP for UHost instances", + Long: "Switch EIP for UHost instances", + Example: "ucloud ext uhost switch-eip --uhost-id uhost-1n1sxx2,uhost-li4jxx1 --create-eip-bandwidth-mb 2", + Run: func(c *cobra.Command, args []string) { + project := ctx.PickResourceID(*describeReq.ProjectId) + region := *describeReq.Region + zone := *describeReq.Zone + unetClient := cli.NewServiceClient(ctx, unet.NewClient) + eipAddrMap := make(map[string]bool) + for _, addr := range eipAddrs { + eipAddrMap[addr] = true + } + results := []cli.OpResultRow{} + + for _, idName := range uhostIDs { + uhostID := ctx.PickResourceID(idName) + logs := []string{fmt.Sprintf("describe uhost instance by uhostID %s", uhostID)} + uhostIns, err := describeUHostByID(ctx, uhostID, project, region, zone) + if err != nil { + errStr := fmt.Sprintf("describe uhost %s failed: %v", uhostID, err) + ctx.HandleError(fmt.Errorf("%s", errStr)) + ctx.LogInfo(append(logs, errStr)...) + continue + } + + for _, ip := range uhostIns.IPSet { + if ip.IPId == "" { + continue + } + if len(eipAddrs) > 0 && !eipAddrMap[ip.IP] { + continue + } + + req := unetClient.NewAllocateEIPRequest() + req.Region = ®ion + req.ProjectId = &project + req.OperatorName = sdk.String(defaultEIPLine(region)) + req.Bandwidth = &eipBandwidth + req.ChargeType = &chargeType + req.Quantity = &quantity + req.PayMode = &trafficMode + if trafficMode == "ShareBandwidth" { + if shareBandwidthID == "" { + errStr := "create-eip-share-bandwidth-id should not be empty when create-eip-traffic-mode is assigned 'ShareBandwidth'" + ctx.HandleError(fmt.Errorf("%s", errStr)) + ctx.LogInfo(append(logs, errStr)...) + return + } + req.ShareBandwidthId = &shareBandwidthID + } + + resp, err := unetClient.AllocateEIP(req) + if err != nil { + errStr := fmt.Sprintf("allocate EIP failed: %v", err) + ctx.HandleError(fmt.Errorf("%s", errStr)) + ctx.LogInfo(append(logs, errStr)...) + continue + } + if len(resp.EIPSet) != 1 { + errStr := "allocate EIP failed, length of eip set is not 1" + ctx.HandleError(fmt.Errorf("%s", errStr)) + ctx.LogInfo(append(logs, errStr)...) + continue + } + + eipID := resp.EIPSet[0].EIPId + eipIP := "" + if len(resp.EIPSet[0].EIPAddr) > 0 { + eipIP = resp.EIPSet[0].EIPAddr[0].IP + } + allocRet := fmt.Sprintf("allocated new eip %s|%s", eipID, eipIP) + logs = append(logs, allocRet) + fmt.Fprintln(ctx.ProgressWriter(), allocRet) + results = append(results, cli.OpResultRow{ResourceID: eipID, Action: "allocate", Status: "Allocated"}) + + bindLogs, bindErr := bindEIPWithLogs(ctx, &uhostID, sdk.String("uhost"), &eipID, &project, ®ion) + logs = append(logs, bindLogs...) + if bindErr != nil { + ctx.HandleError(fmt.Errorf("bind new eip %s failed: %v", eipID, bindErr)) + ctx.LogInfo(logs...) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "bound eip %s with uhost %s\n", eipID, uhostID) + results = append(results, cli.OpResultRow{ResourceID: eipID, Action: "bind", Status: "Bound"}) + + if unbind { + unbindLogs, err := unbindEIPWithLogs(ctx, uhostID, "uhost", ip.IPId, project, region) + logs = append(logs, unbindLogs...) + if err != nil { + ctx.HandleError(fmt.Errorf("unbind eip %s failed: %v", ip.IPId, err)) + ctx.LogInfo(logs...) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "unbound eip %s|%s with uhost %s\n", ip.IPId, ip.IP, uhostID) + results = append(results, cli.OpResultRow{ResourceID: ip.IPId, Action: "unbind", Status: "Unbound"}) + } + + if release { + req := unetClient.NewReleaseEIPRequest() + req.ProjectId = &project + req.Region = ®ion + req.EIPId = sdk.String(ip.IPId) + _, err := unetClient.ReleaseEIP(req) + if err != nil { + errStr := fmt.Sprintf("release eip %s failed: %v", ip.IPId, err) + ctx.HandleError(fmt.Errorf("%s", errStr)) + ctx.LogInfo(append(logs, errStr)...) + continue + } + releaseRet := fmt.Sprintf("released eip %s|%s", ip.IPId, ip.IP) + logs = append(logs, releaseRet) + fmt.Fprintln(ctx.ProgressWriter(), releaseRet) + results = append(results, cli.OpResultRow{ResourceID: ip.IPId, Action: "release", Status: "Released"}) + } + ctx.LogInfo(logs...) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&uhostIDs, "uhost-id", nil, "Required. Resource ID of uhost instances to switch EIP") + flags.StringSliceVar(&eipAddrs, "eip-addr", nil, "Optional. Address of EIP instances to be replaced. if eip-id is empty, replace all of the EIPs bound with the uhost ") + flags.BoolVar(&unbind, "unbind-all", true, "Optional. Unbind all EIP instances that has been replaced. Accept values:true or false") + flags.BoolVar(&release, "release-all", true, "Optional. Release all EIP instances that has been replaced. Accept values:true or false") + flags.IntVar(&eipBandwidth, "create-eip-bandwidth-mb", 1, "Optional. Bandwidth of EIP instance to be create with. Unit:Mb") + flags.StringVar(&trafficMode, "create-eip-traffic-mode", "Bandwidth", "Optional. traffic-mode is an enumeration value. 'Traffic','Bandwidth' or 'ShareBandwidth'") + flags.StringVar(&shareBandwidthID, "create-eip-share-bandwidth-id", "", "Optional. ShareBandwidthId, required only when traffic-mode is 'ShareBandwidth'") + flags.StringVar(&chargeType, "create-eip-charge-type", "Month", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") + flags.IntVar(&quantity, "create-eip-quantity", 1, "Optional. The duration of the instance. N years/months.") + + command.SetFlagValues(cmd, "create-eip-traffic-mode", "Bandwidth", "Traffic", "ShareBandwidth") + command.SetFlagValues(cmd, "create-eip-charge-type", "Month", "Year", "Dynamic", "Trial") + ctx.BindProjectID(cmd, describeReq) + ctx.BindRegion(cmd, describeReq) + ctx.BindZoneEmpty(cmd, describeReq) + command.SetCompletion(cmd, "uhost-id", func() []string { + return listUHostIDs(ctx, []string{hostRunning, hostStopped, hostFail}, *describeReq.ProjectId, *describeReq.Region, *describeReq.Zone) + }) + cmd.MarkFlagRequired("uhost-id") + + return cmd +} + +func defaultEIPLine(region string) string { + if strings.HasPrefix(region, "cn") { + return "BGP" + } + return "International" +} + +func getEIPIDByIP(ctx *cli.Context, ip net.IP, projectID, region string) (string, error) { + eipList, err := fetchAllEIP(ctx, projectID, region) + if err != nil { + return "", err + } + for _, eip := range eipList { + for _, addr := range eip.EIPAddr { + if addr.IP == ip.String() { + return eip.EIPId, nil + } + } + } + return "", fmt.Errorf("IP[%s] not exist", ip.String()) +} + +func fetchAllEIP(ctx *cli.Context, projectID, region string) ([]unet.UnetEIPSet, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeEIPRequest() + list := []unet.UnetEIPSet{} + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + for offset, step := 0, 100; ; offset += step { + req.Offset = &offset + req.Limit = &step + resp, err := client.DescribeEIP(req) + if err != nil { + return nil, err + } + for i, size := 0, len(resp.EIPSet); i < size; i++ { + list = append(list, resp.EIPSet[i]) + } + if resp.TotalCount <= offset+step { + break + } + } + return list, nil +} + +func bindEIPWithLogs(ctx *cli.Context, resourceID, resourceType, eipID, projectID, region *string) ([]string, error) { + logs := make([]string, 0) + ip := net.ParseIP(*eipID) + if ip != nil { + id, err := getEIPIDByIP(ctx, ip, *projectID, *region) + if err != nil { + ctx.HandleError(err) + } else { + *eipID = id + } + } + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewBindEIPRequest() + req.ResourceId = resourceID + req.ResourceType = resourceType + req.EIPId = sdk.String(ctx.PickResourceID(*eipID)) + req.ProjectId = sdk.String(ctx.PickResourceID(*projectID)) + req.Region = region + _, err := client.BindEIP(req) + if err != nil { + logs = append(logs, fmt.Sprintf("bind eip failed: %v", err)) + return logs, err + } + logs = append(logs, fmt.Sprintf("bind eip[%s] with %s[%s] successfully", *req.EIPId, *req.ResourceType, *req.ResourceId)) + return logs, nil +} + +func unbindEIPWithLogs(ctx *cli.Context, resourceID, resourceType, eipID, projectID, region string) ([]string, error) { + logs := make([]string, 0) + eipID = ctx.PickResourceID(eipID) + ip := net.ParseIP(eipID) + if ip != nil { + id, err := getEIPIDByIP(ctx, ip, projectID, region) + if err != nil { + ctx.HandleError(err) + } else { + eipID = id + } + } + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewUnBindEIPRequest() + req.ResourceId = &resourceID + req.ResourceType = &resourceType + req.EIPId = &eipID + req.ProjectId = sdk.String(ctx.PickResourceID(projectID)) + req.Region = ®ion + _, err := client.UnBindEIP(req) + if err != nil { + logs = append(logs, fmt.Sprintf("unbind eip failed: %v", err)) + return logs, err + } + logs = append(logs, fmt.Sprintf("unbind eip[%s] with %s[%s] successfully", *req.EIPId, *req.ResourceType, *req.ResourceId)) + return logs, nil +} diff --git a/products/eip/product.go b/products/eip/product.go new file mode 100644 index 0000000000..66d1d5ab38 --- /dev/null +++ b/products/eip/product.go @@ -0,0 +1,21 @@ +package eip + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internaleip "github.com/ucloud/ucloud-cli/products/eip/internal/eip" + internalext "github.com/ucloud/ucloud-cli/products/eip/internal/ext" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "eip", Commands: []string{"eip", "ext"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internaleip.NewCommand(ctx), internalext.NewCommand(ctx)} +} diff --git a/products/eip/product.yaml b/products/eip/product.yaml new file mode 100644 index 0000000000..d9cd4e358a --- /dev/null +++ b/products/eip/product.yaml @@ -0,0 +1,7 @@ +name: eip +owners: + - Episkey-G +commands: + - eip + - ext +enabled: true diff --git a/products/eip/testdata/cmdtree.golden b/products/eip/testdata/cmdtree.golden new file mode 100644 index 0000000000..3068ac38f2 --- /dev/null +++ b/products/eip/testdata/cmdtree.golden @@ -0,0 +1,72 @@ +ucloud eip use=eip short=List,allocate and release EIP +ucloud eip allocate use=allocate short=Allocate EIP + flag=bandwidth-mb short= default=0 required=true + flag=charge-type short= default=Month required= + flag=count short= default=1 required= + flag=group short= default=Default required= + flag=line short= default= required= + flag=name short= default=EIP required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=remark short= default= required= + flag=share-bandwidth-id short= default= required= + flag=traffic-mode short= default=Bandwidth required= +ucloud eip bind use=bind short=Bind EIP with uhost + flag=eip-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=resource-id short= default= required=true + flag=resource-type short= default=uhost required= +ucloud eip join-shared-bw use=join-shared-bw short=Join shared bandwidth + flag=eip-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=shared-bw-id short= default= required=true +ucloud eip leave-shared-bw use=leave-shared-bw short=Leave shared bandwidth + flag=bandwidth-mb short= default=1 required= + flag=eip-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=shared-bw-id short= default= required= + flag=traffic-mode short= default=Bandwidth required= +ucloud eip list use=list short=List all EIP instances + flag=limit short= default=50 required= + flag=list-all short= default=false required= + flag=offset short= default=0 required= + flag=page-off short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= +ucloud eip modify-bw use=modify-bw short=Modify bandwith of EIP instances + flag=bandwidth-mb short= default=0 required=true + flag=eip-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud eip modify-traffic-mode use=modify-traffic-mode short=Modify charge mode of EIP instances + flag=eip-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=traffic-mode short= default= required=true +ucloud eip release use=release short=Release EIP + flag=eip-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud eip unbind use=unbind short=Unbind EIP with uhost + flag=eip-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud ext use=ext short=extended commands of UCloud CLI +ucloud ext uhost use=uhost short=extended uhost commands +ucloud ext uhost switch-eip use=switch-eip short=Switch EIP for UHost instances + flag=create-eip-bandwidth-mb short= default=1 required= + flag=create-eip-charge-type short= default=Month required= + flag=create-eip-quantity short= default=1 required= + flag=create-eip-share-bandwidth-id short= default= required= + flag=create-eip-traffic-mode short= default=Bandwidth required= + flag=eip-addr short= default=[] required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=release-all short= default=true required= + flag=uhost-id short= default=[] required=true + flag=unbind-all short= default=true required= + flag=zone short= default= required= diff --git a/products/eip/testdata/completion.golden b/products/eip/testdata/completion.golden new file mode 100644 index 0000000000..78fe9631d0 --- /dev/null +++ b/products/eip/testdata/completion.golden @@ -0,0 +1,30 @@ +ucloud eip allocate charge-type static Dynamic,Month,Trial,Year +ucloud eip allocate line static BGP,International +ucloud eip allocate project-id dynamic +ucloud eip allocate region dynamic +ucloud eip allocate traffic-mode static Bandwidth,ShareBandwidth,Traffic +ucloud eip bind eip-id dynamic +ucloud eip bind resource-type static dbaudit,fortresshost,hadoophost,natgw,ucdr,udb,udhost,udockhost,uhost,ulb,upm,vpngw,vrouter +ucloud eip join-shared-bw eip-id dynamic +ucloud eip join-shared-bw shared-bw-id dynamic +ucloud eip leave-shared-bw eip-id dynamic +ucloud eip leave-shared-bw shared-bw-id dynamic +ucloud eip leave-shared-bw traffic-mode static Bandwidth,Traffic +ucloud eip list list-all static false,true +ucloud eip list project-id dynamic +ucloud eip list region dynamic +ucloud eip modify-bw eip-id dynamic +ucloud eip modify-traffic-mode eip-id dynamic +ucloud eip modify-traffic-mode traffic-mode static Bandwidth,PostAccurateBandwidth,Traffic +ucloud eip release eip-id dynamic +ucloud eip release project-id dynamic +ucloud eip release region dynamic +ucloud eip unbind eip-id dynamic +ucloud eip unbind project-id dynamic +ucloud eip unbind region dynamic +ucloud ext uhost switch-eip create-eip-charge-type static Dynamic,Month,Trial,Year +ucloud ext uhost switch-eip create-eip-traffic-mode static Bandwidth,ShareBandwidth,Traffic +ucloud ext uhost switch-eip project-id dynamic +ucloud ext uhost switch-eip region dynamic +ucloud ext uhost switch-eip uhost-id dynamic +ucloud ext uhost switch-eip zone dynamic diff --git a/products/firewall/internal/firewall/add_rule.go b/products/firewall/internal/firewall/add_rule.go new file mode 100644 index 0000000000..0f20723135 --- /dev/null +++ b/products/firewall/internal/firewall/add_rule.go @@ -0,0 +1,91 @@ +package firewall + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newAddRule ucloud firewall add-rule +func newAddRule(ctx *cli.Context) *cobra.Command { + var rulesFilePath string + var fwIDs []string + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewUpdateFirewallRequest() + cmd := &cobra.Command{ + Use: "add-rule", + Short: "Add rule to firewall instance", + Long: "Add rule to firewall instance", + Example: `ucloud firewall add-rule --fw-id firewall-2xxxxz/test.lxj2 --rules "TCP|24|0.0.0.0/0|ACCEPT|HIGH" --rules-file firewall_rules.txt`, + Run: func(c *cobra.Command, args []string) { + if req.Rule == nil && rulesFilePath == "" { + ctx.HandleError(fmt.Errorf("flags rules and rules-file can't be both empty")) + return + } + results := []cli.OpResultRow{} + for _, fwID := range fwIDs { + id := ctx.PickResourceID(fwID) + req.FWId = &id + firewall, err := getFirewall(ctx, *req.FWId, *req.ProjectId, *req.Region) + if err != nil { + ctx.HandleError(err) + return + } + ruleMap := map[string]bool{} + for _, r := range firewall.Rule { + ruleStr := fmt.Sprintf("%s|%s|%s|%s|%s", r.ProtocolType, r.DstPort, r.SrcIP, r.RuleAction, r.Priority) + ruleMap[ruleStr] = true + } + if rulesFilePath != "" { + rules, err := parseRulesFromFile(rulesFilePath) + if err != nil { + ctx.HandleError(err) + return + } + req.Rule = append(req.Rule, rules...) + } + for _, r := range req.Rule { + ruleMap[r] = true + } + req.Rule = []string{} + for r := range ruleMap { + r = strings.TrimSpace(r) + req.Rule = append(req.Rule, r) + } + _, err = client.UpdateFirewall(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "firewall[%s] updated\n", fwID) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "add-rule", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&fwIDs, "fw-id", nil, "Required. Resource ID of firewalls to update") + flags.StringSliceVar(&req.Rule, "rules", nil, "Required if rules-file is empay. Rules to add to firewall. Schema:'Protocol|Port|IP|Action|Level'. See 'ucloud firewall create --help' for detail.") + flags.StringVar(&rulesFilePath, "rules-file", "", "Required if rules is empty. Path of rules file, in which each rule occupies one line. Schema: Protocol|Port|IP|Action|Level.") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Region, see 'ucloud region'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + command.SetCompletion(cmd, "fw-id", func() []string { + return getFirewallIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "rules-file", func() []string { + return common.GetFileList("") + }) + + cmd.MarkFlagRequired("fw-id") + return cmd +} diff --git a/products/firewall/internal/firewall/apply.go b/products/firewall/internal/firewall/apply.go new file mode 100644 index 0000000000..5ab7522f76 --- /dev/null +++ b/products/firewall/internal/firewall/apply.go @@ -0,0 +1,61 @@ +package firewall + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newApply ucloud firewall apply +func newApply(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewGrantFirewallRequest() + resourceIDs := []string{} + fwID := "" + cmd := &cobra.Command{ + Use: "apply", + Short: "Applay firewall to ucloud service", + Long: "Applay firewall to ucloud service", + Example: "ucloud firewall apply --fw-id firewall-xxx --resource-id uhost-xxx --resource-type uhost", + Run: func(c *cobra.Command, args []string) { + req.FWId = sdk.String(ctx.PickResourceID(fwID)) + results := []cli.OpResultRow{} + for _, id := range resourceIDs { + req.ResourceId = sdk.String(id) + _, err := client.GrantFirewall(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "firewall[%s] applied to %s[%s]\n", fwID, *req.ResourceType, id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "apply", Status: "Applied"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&fwID, "fw-id", "", "Required. Resource ID of firewall to apply to some ucloud resource") + req.ResourceType = flags.String("resource-type", "", "Required. Resource type of resource to be applied firewall. Range 'uhost','unatgw','upm','hadoophost','fortresshost','udhost','udockhost','dbaudit'.") + flags.StringSliceVar(&resourceIDs, "resource-id", nil, "Resource ID of resources to be applied firewall") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Region, see 'ucloud region'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + command.SetFlagValues(cmd, "resource-type", "uhost", "unatgw", "upm", "hadoophost", "fortresshost", "udhost", "udockhost", "dbaudit") + command.SetCompletion(cmd, "fw-id", func() []string { + return getFirewallIDNames(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("fw-id") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("resource-type") + + return cmd +} diff --git a/products/firewall/internal/firewall/cmd.go b/products/firewall/internal/firewall/cmd.go new file mode 100644 index 0000000000..eccc50bd0d --- /dev/null +++ b/products/firewall/internal/firewall/cmd.go @@ -0,0 +1,29 @@ +package firewall + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `firewall` root command and mounts the 9 subcommands. +// Mirrors cmd/firewall.go NewCmdFirewall (same AddCommand order). +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "firewall", + Short: "List and manipulate extranet firewall", + Long: `List and manipulate extranet firewall`, + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newAddRule(ctx)) + cmd.AddCommand(newDeleteRule(ctx)) + cmd.AddCommand(newApply(ctx)) + cmd.AddCommand(newCopy(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newResource(ctx)) + cmd.AddCommand(newUpdate(ctx)) + + return cmd +} diff --git a/products/firewall/internal/firewall/completion.go b/products/firewall/internal/firewall/completion.go new file mode 100644 index 0000000000..69a704346b --- /dev/null +++ b/products/firewall/internal/firewall/completion.go @@ -0,0 +1,19 @@ +package firewall + +import ( + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// getFirewallIDNames returns "FWId/Name" completion candidates for firewalls in +// project/region. Self-contained SDK call (ported from cmd/firewall.go, which +// shared getAllFirewallIns; here it calls the package-local getAllFirewallIns). +func getFirewallIDNames(ctx *cli.Context, project, region string) (idNames []string) { + list, err := getAllFirewallIns(ctx, project, region) + if err != nil { + return + } + for _, f := range list { + idNames = append(idNames, f.FWId+"/"+f.Name) + } + return +} diff --git a/products/firewall/internal/firewall/copy.go b/products/firewall/internal/firewall/copy.go new file mode 100644 index 0000000000..3d7b8da825 --- /dev/null +++ b/products/firewall/internal/firewall/copy.go @@ -0,0 +1,67 @@ +package firewall + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCopy ucloud firewall copy +func newCopy(ctx *cli.Context) *cobra.Command { + srcFirewall := "" + srcRegion := "" + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewCreateFirewallRequest() + cmd := &cobra.Command{ + Use: "copy", + Short: "Copy firewall", + Long: "Copy firewall", + Example: "ucloud firewall copy --src-fw firewall-xxx --target-region cn-bj2 --name test", + Run: func(c *cobra.Command, args []string) { + fwID := ctx.PickResourceID(srcFirewall) + firewall, err := getFirewall(ctx, fwID, *req.ProjectId, srcRegion) + + if err != nil { + ctx.HandleError(err) + return + } + req.Tag = sdk.String(firewall.Tag) + req.Remark = sdk.String(firewall.Remark) + for _, r := range firewall.Rule { + rstr := fmt.Sprintf("%s|%s|%s|%s|%s", r.ProtocolType, r.DstPort, r.SrcIP, r.RuleAction, r.Priority) + req.Rule = append(req.Rule, rstr) + } + resp, err := client.CreateFirewall(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "firewall[%s] created from %s\n", resp.FWId, srcFirewall) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.FWId, Action: "copy", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.StringVar(&srcFirewall, "src-fw", "", "Required. ResourceID or name of source firewall") + req.Name = flags.String("name", "", "Required. Name of new firewall") + flags.StringVar(&srcRegion, "region", ctx.DefaultRegion(), "Optional. Current region, used to fetch source firewall") + req.Region = flags.String("target-region", ctx.DefaultRegion(), "Optional. Copy firewall to target region") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + command.SetCompletion(cmd, "src-fw", func() []string { + return getFirewallIDNames(ctx, *req.ProjectId, srcRegion) + }) + command.SetCompletion(cmd, "target-region", ctx.RegionList) + command.SetCompletion(cmd, "region", ctx.RegionList) + + cmd.MarkFlagRequired("src-fw") + cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/products/firewall/internal/firewall/create.go b/products/firewall/internal/firewall/create.go new file mode 100644 index 0000000000..40fcffa3cc --- /dev/null +++ b/products/firewall/internal/firewall/create.go @@ -0,0 +1,64 @@ +package firewall + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud firewall create +func newCreate(ctx *cli.Context) *cobra.Command { + var rulesFilePath string + var rules []string + + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewCreateFirewallRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create firewall", + Long: "Create firewall", + Example: `ucloud firewall create --name test3 --rules "TCP|22|0.0.0.0/0|ACCEPT|HIGH" --rules-file firewall_rules.txt`, + Run: func(c *cobra.Command, args []string) { + if rules == nil && rulesFilePath == "" { + ctx.HandleError(fmt.Errorf("flags rules and rules-file can't be both empty")) + return + } + if rulesFilePath != "" { + lines, err := parseRulesFromFile(rulesFilePath) + if err != nil { + ctx.HandleError(err) + return + } + rules = append(rules, lines...) + } + req.Rule = rules + resp, err := client.CreateFirewall(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "firewall[%s] created\n", resp.FWId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.FWId, Action: "create", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&rules, "rules", nil, "Required if rules-file doesn't exist. Schema: Protocol|Port|IP|Action|Level. Prototol range 'TCP','UDP','ICMP' and 'GRE'; Port is a local port accessed by source address, port range [0-65535]; IP is the source address of the network packet that requests ucloud host resource, supporting IP address and network segment, such as '120.132.69.216' or '0.0.0.0/0'; Action is the processing behavior of the packet when the firewall is in effect, including 'ACCEPT' AND 'DROP'; Level, when a rule is added to a firewall, the rules take effect in order of level, which range 'HIGH','MEDIUM' and 'LOW'. For example, 'TCP|22|192.168.1.1/22|DROP|LOW'") + flags.StringVar(&rulesFilePath, "rules-file", "", "Required if rules doesn't exist. Path of rules file, in which each rule occupies one line. Schema: Protocol|Port|IP|Action|Level.") + req.Name = flags.String("name", "", "Required. Name of firewall to create") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Region, see 'ucloud region'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + req.Tag = flags.String("group", "", "Optional. Group of the firewall to create") + req.Remark = flags.String("remark", "", "Optional. Remark of the firewall to create") + cmd.MarkFlagRequired("name") + command.SetCompletion(cmd, "rules-file", func() []string { + return common.GetFileList("") + }) + return cmd +} diff --git a/products/firewall/internal/firewall/delete.go b/products/firewall/internal/firewall/delete.go new file mode 100644 index 0000000000..3de0286685 --- /dev/null +++ b/products/firewall/internal/firewall/delete.go @@ -0,0 +1,53 @@ +package firewall + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud firewall delete +func newDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDeleteFirewallRequest() + ids := []string{} + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete firewall by resource ids or names", + Long: "Delete firewall by resource ids or names", + Example: "ucloud firewall delete --fw-id firewall-xxx", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, id := range ids { + rid := ctx.PickResourceID(id) + req.FWId = sdk.String(rid) + _, err := client.DeleteFirewall(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "firewall[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: rid, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&ids, "fw-id", nil, "Required. Resource IDs of firewall to delete") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Region, see 'ucloud region'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + cmd.MarkFlagRequired("fw-id") + command.SetCompletion(cmd, "fw-id", func() []string { + return getFirewallIDNames(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/firewall/internal/firewall/describe.go b/products/firewall/internal/firewall/describe.go new file mode 100644 index 0000000000..61dc0b3848 --- /dev/null +++ b/products/firewall/internal/firewall/describe.go @@ -0,0 +1,54 @@ +package firewall + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// getFirewall finds a firewall by resource id or name. Ported from +// cmd/firewall.go (base.BizClient → cli.NewServiceClient). +func getFirewall(ctx *cli.Context, fwNameID, project, region string) (*unet.FirewallDataSet, error) { + var firewall *unet.FirewallDataSet + list, err := getAllFirewallIns(ctx, project, region) + if err != nil { + return nil, err + } + for i, fw := range list { + if fw.FWId == fwNameID || fw.Name == fwNameID { + firewall = &list[i] + } + } + if firewall == nil { + return nil, fmt.Errorf("firwall[%s] does not exist", fwNameID) + } + return firewall, nil +} + +// getAllFirewallIns lists all firewalls in project/region, paging by 100. +// Ported from cmd/firewall.go (base.BizClient → cli.NewServiceClient). +func getAllFirewallIns(ctx *cli.Context, project, region string) ([]unet.FirewallDataSet, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeFirewallRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + list := []unet.FirewallDataSet{} + for offset, limit := 0, 100; ; offset += limit { + req.Offset = sdk.Int(offset) + req.Limit = sdk.Int(limit) + resp, err := client.DescribeFirewall(req) + if err != nil { + return nil, err + } + for _, fw := range resp.DataSet { + list = append(list, fw) + } + if resp.TotalCount < offset+limit { + break + } + } + return list, nil +} diff --git a/products/firewall/internal/firewall/list.go b/products/firewall/internal/firewall/list.go new file mode 100644 index 0000000000..c151610984 --- /dev/null +++ b/products/firewall/internal/firewall/list.go @@ -0,0 +1,59 @@ +package firewall + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList ucloud firewall list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeFirewallRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List extranet firewall", + Long: `List extranet firewall`, + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.DescribeFirewall(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []FirewallRow{} + for _, fw := range resp.DataSet { + row := FirewallRow{} + row.ResourceID = fw.FWId + row.FirewallName = fw.Name + row.Group = fw.Tag + row.RuleAmount = len(fw.Rule) + row.BoundResourceAmount = fw.ResourceCount + row.CreationTime = common.FormatDate(fw.CreateTime) + if fw.Remark != "" { + row.FirewallName += "\nremark:" + fw.Remark + "\n" + } + for _, r := range fw.Rule { + rule := fmt.Sprintf("%s|%s|%s|%s|%s", r.ProtocolType, r.DstPort, r.SrcIP, r.RuleAction, r.Priority) + row.Rule += rule + "\n" + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Region, see 'ucloud region'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + req.FWId = flags.String("firewall-id", "", "Optional. The Rsource ID of firewall. Return all firewalls by default.") + req.ResourceType = flags.String("bound-resource-type", "", "Optional. The type of resource bound on the firewall") + req.ResourceId = flags.String("bound-resource-id", "", "Optional. The resource ID of resource bound on the firewall") + req.Offset = flags.Int("offset", 0, "Optional. Offset") + req.Limit = flags.Int("limit", 50, "Optional. Limit") + return cmd +} diff --git a/products/firewall/internal/firewall/remove_rule.go b/products/firewall/internal/firewall/remove_rule.go new file mode 100644 index 0000000000..3e20b61836 --- /dev/null +++ b/products/firewall/internal/firewall/remove_rule.go @@ -0,0 +1,91 @@ +package firewall + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDeleteRule ucloud firewall remove-rule +func newDeleteRule(ctx *cli.Context) *cobra.Command { + var rulesFilePath string + var fwIDs []string + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewUpdateFirewallRequest() + cmd := &cobra.Command{ + Use: "remove-rule", + Short: "Remove rule from firewall instance", + Long: "Remove rule from firewall instance", + Example: `ucloud firewall remove-rule --fw-id firewall-2cxxxz/test.lxj2 --rules "TCP|24|0.0.0.0/0|ACCEPT|HIGH" --rules-file firewall_rules.txt`, + Run: func(c *cobra.Command, args []string) { + if req.Rule == nil && rulesFilePath == "" { + ctx.HandleError(fmt.Errorf("flags rules and rules-file can't be both empty")) + return + } + results := []cli.OpResultRow{} + for _, fwID := range fwIDs { + id := ctx.PickResourceID(fwID) + req.FWId = &id + firewall, err := getFirewall(ctx, *req.FWId, *req.ProjectId, *req.Region) + if err != nil { + ctx.HandleError(err) + return + } + ruleMap := map[string]bool{} + for _, r := range firewall.Rule { + ruleStr := fmt.Sprintf("%s|%s|%s|%s|%s", r.ProtocolType, r.DstPort, r.SrcIP, r.RuleAction, r.Priority) + ruleMap[ruleStr] = true + } + if rulesFilePath != "" { + rules, err := parseRulesFromFile(rulesFilePath) + if err != nil { + ctx.HandleError(err) + return + } + req.Rule = append(req.Rule, rules...) + } + for _, r := range req.Rule { + r = strings.TrimSpace(r) + delete(ruleMap, r) + } + req.Rule = []string{} + for r := range ruleMap { + req.Rule = append(req.Rule, r) + } + if len(req.Rule) == 0 { + ctx.HandleError(fmt.Errorf("rules can't be all deleted")) + return + } + _, err = client.UpdateFirewall(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "firewall[%s] updated\n", fwID) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "remove-rule", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&fwIDs, "fw-id", nil, "Required. Resource ID of firewalls to update") + flags.StringSliceVar(&req.Rule, "rules", nil, "Required if rules-file is empay. Rules to add to firewall. Schema:'Protocol|Port|IP|Action|Level'. See 'ucloud firewall create --help' for detail.") + flags.StringVar(&rulesFilePath, "rules-file", "", "Required if rules is empty. Path of rules file, in which each rule occupies one line. Schema: Protocol|Port|IP|Action|Level.") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Region, see 'ucloud region'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + command.SetCompletion(cmd, "fw-id", func() []string { + return getFirewallIDNames(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("fw-id") + return cmd +} diff --git a/products/firewall/internal/firewall/resource.go b/products/firewall/internal/firewall/resource.go new file mode 100644 index 0000000000..c65a790875 --- /dev/null +++ b/products/firewall/internal/firewall/resource.go @@ -0,0 +1,59 @@ +package firewall + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResource ucloud firewall resource +func newResource(ctx *cli.Context) *cobra.Command { + fwID := "" + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeFirewallResourceRequest() + cmd := &cobra.Command{ + Use: "resource", + Short: "List resources that has been applied the firewall", + Long: "List resources that has been applied the firewall", + Run: func(c *cobra.Command, args []string) { + req.FWId = sdk.String(ctx.PickResourceID(fwID)) + resp, err := client.DescribeFirewallResource(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []FirewallResourceRow{} + for _, rs := range resp.ResourceSet { + row := FirewallResourceRow{} + row.ResourceName = rs.Name + row.ResourceID = rs.ResourceID + row.ResourceType = rs.ResourceType + row.IntranetIP = rs.PrivateIP + row.Group = rs.Tag + row.Remark = rs.Remark + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&fwID, "fw-id", "", "Required. Resource ID of firewall") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Region, see 'ucloud region'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + req.Offset = flags.Int("offset", 0, "Optional. Offset") + req.Limit = flags.Int("limit", 50, "Optional. Limit") + + command.SetCompletion(cmd, "fw-id", func() []string { + return getFirewallIDNames(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("fw-id") + + return cmd +} diff --git a/products/firewall/internal/firewall/rows.go b/products/firewall/internal/firewall/rows.go new file mode 100644 index 0000000000..7173b96133 --- /dev/null +++ b/products/firewall/internal/firewall/rows.go @@ -0,0 +1,22 @@ +package firewall + +// FirewallRow 表格行 +type FirewallRow struct { + ResourceID string + FirewallName string + Rule string + Group string + RuleAmount int + BoundResourceAmount int + CreationTime string +} + +// FirewallResourceRow 表格行 +type FirewallResourceRow struct { + ResourceName string + ResourceID string + ResourceType string + IntranetIP string + Group string + Remark string +} diff --git a/products/firewall/internal/firewall/rules.go b/products/firewall/internal/firewall/rules.go new file mode 100644 index 0000000000..f30e55d199 --- /dev/null +++ b/products/firewall/internal/firewall/rules.go @@ -0,0 +1,25 @@ +package firewall + +import ( + "bufio" + "os" +) + +// parseRulesFromFile reads a rules file, one rule per line. Verbatim from +// cmd/firewall.go. +func parseRulesFromFile(filePath string) ([]string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + lines := []string{} + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return lines, nil +} diff --git a/products/firewall/internal/firewall/update.go b/products/firewall/internal/firewall/update.go new file mode 100644 index 0000000000..936611fe41 --- /dev/null +++ b/products/firewall/internal/firewall/update.go @@ -0,0 +1,71 @@ +package firewall + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUpdate ucloud firewall update +func newUpdate(ctx *cli.Context) *cobra.Command { + fwIDs := []string{} + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewUpdateFirewallAttributeRequest() + cmd := &cobra.Command{ + Use: "update", + Short: "Update firewall attribute, such as name,group and remark.", + Long: "Update firewall attribute, such as name,group and remark.", + Example: `ucloud firewall update --fw-id firewall-2xxxx/test2 --name test_update.1 --remark "this is a remark"`, + Run: func(c *cobra.Command, args []string) { + if *req.Name == "" && *req.Tag == "" && *req.Remark == "" { + ctx.HandleError(fmt.Errorf("name, group and remark can't be all empty")) + return + } + if *req.Name == "" { + req.Name = nil + } + if *req.Tag == "" { + req.Tag = nil + } + if *req.Remark == "" { + req.Remark = nil + } + results := []cli.OpResultRow{} + for _, id := range fwIDs { + rid := ctx.PickResourceID(id) + req.FWId = sdk.String(rid) + _, err := client.UpdateFirewallAttribute(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "firewall[%s] updated\n", id) + results = append(results, cli.OpResultRow{ResourceID: rid, Action: "update", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&fwIDs, "fw-id", nil, "Required. Resource ID of firewalls") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Region, see 'ucloud region'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + req.Name = flags.String("name", "", "Name of firewall") + req.Tag = flags.String("group", "", "Group of firewall") + req.Remark = flags.String("remark", "", "Remark of firewall") + + command.SetCompletion(cmd, "fw-id", func() []string { + return getFirewallIDNames(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("fw-id") + + return cmd +} diff --git a/products/firewall/product.go b/products/firewall/product.go new file mode 100644 index 0000000000..0bdfcafade --- /dev/null +++ b/products/firewall/product.go @@ -0,0 +1,20 @@ +package firewall + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalfirewall "github.com/ucloud/ucloud-cli/products/firewall/internal/firewall" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "firewall", Commands: []string{"firewall"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalfirewall.NewCommand(ctx)} +} diff --git a/products/firewall/product.yaml b/products/firewall/product.yaml new file mode 100644 index 0000000000..921c40ceeb --- /dev/null +++ b/products/firewall/product.yaml @@ -0,0 +1,6 @@ +name: firewall +owners: + - Episkey-G +commands: + - firewall +enabled: true diff --git a/products/firewall/testdata/cmdtree.golden b/products/firewall/testdata/cmdtree.golden new file mode 100644 index 0000000000..0bf9fd86dd --- /dev/null +++ b/products/firewall/testdata/cmdtree.golden @@ -0,0 +1,58 @@ +ucloud firewall use=firewall short=List and manipulate extranet firewall +ucloud firewall add-rule use=add-rule short=Add rule to firewall instance + flag=fw-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=rules short= default=[] required= + flag=rules-file short= default= required= +ucloud firewall apply use=apply short=Applay firewall to ucloud service + flag=fw-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=resource-id short= default=[] required=true + flag=resource-type short= default= required=true +ucloud firewall copy use=copy short=Copy firewall + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=src-fw short= default= required=true + flag=target-region short= default= required= +ucloud firewall create use=create short=Create firewall + flag=group short= default= required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=rules short= default=[] required= + flag=rules-file short= default= required= +ucloud firewall delete use=delete short=Delete firewall by resource ids or names + flag=fw-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud firewall list use=list short=List extranet firewall + flag=bound-resource-id short= default= required= + flag=bound-resource-type short= default= required= + flag=firewall-id short= default= required= + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= +ucloud firewall remove-rule use=remove-rule short=Remove rule from firewall instance + flag=fw-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=rules short= default=[] required= + flag=rules-file short= default= required= +ucloud firewall resource use=resource short=List resources that has been applied the firewall + flag=fw-id short= default= required=true + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= +ucloud firewall update use=update short=Update firewall attribute, such as name,group and remark. + flag=fw-id short= default=[] required=true + flag=group short= default= required= + flag=name short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= diff --git a/products/firewall/testdata/completion.golden b/products/firewall/testdata/completion.golden new file mode 100644 index 0000000000..7113e91d9a --- /dev/null +++ b/products/firewall/testdata/completion.golden @@ -0,0 +1,12 @@ +ucloud firewall add-rule fw-id dynamic +ucloud firewall add-rule rules-file static +ucloud firewall apply fw-id dynamic +ucloud firewall apply resource-type static dbaudit,fortresshost,hadoophost,udhost,udockhost,uhost,unatgw,upm +ucloud firewall copy region dynamic +ucloud firewall copy src-fw dynamic +ucloud firewall copy target-region dynamic +ucloud firewall create rules-file static +ucloud firewall delete fw-id dynamic +ucloud firewall remove-rule fw-id dynamic +ucloud firewall resource fw-id dynamic +ucloud firewall update fw-id dynamic diff --git a/products/globalssh/internal/gssh/area.go b/products/globalssh/internal/gssh/area.go new file mode 100644 index 0000000000..df48d5d4ea --- /dev/null +++ b/products/globalssh/internal/gssh/area.go @@ -0,0 +1,79 @@ +package gssh + +import ( + "strings" + + "github.com/spf13/cobra" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +var areaCodeMap = map[string]string{ + "LAX": "LosAngeles", + "SIN": "Singapore", + "HKG": "HongKong", + "HND": "Tokyo", + "IAD": "Washington", + "FRA": "Frankfurt", + "LOS": "Lagos", +} + +var regionLabel = map[string]string{ + "cn-bj1": "Beijing1", + "cn-bj2": "Beijing2", + "cn-sh2": "Shanghai2", + "cn-gd": "Guangzhou", + "cn-qz": "Quanzhou", + "hk": "Hongkong", + "us-ca": "LosAngeles", + "us-ws": "Washington", + "ge-fra": "Frankfurt", + "th-bkk": "Bangkok", + "kr-seoul": "Seoul", + "sg": "Singapore", + "tw-kh": "Kaohsiung", + "rus-mosc": "Moscow", + "jpn-tky": "Tokyo", + "tw-tp": "TaiPei", + "uae-dubai": "Dubai", + "idn-jakarta": "Jakarta", + "ind-mumbai": "Bombay", + "bra-saopaulo": "SaoPaulo", + "uk-london": "London", + "afr-nigeria": "Lagos", +} + +// newArea ucloud gssh location +func newArea(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + req := client.NewDescribeGlobalSSHAreaRequest() + cmd := &cobra.Command{ + Use: "location", + Short: "List SSH server locations and covered areas", + Long: "List SSH server locations and covered areas", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.DescribeGlobalSSHArea(req) + if err != nil { + ctx.HandleError(err) + return + } + list := make([]GsshLocation, 0) + for _, item := range resp.AreaSet { + row := GsshLocation{ + AirportCode: item.AreaCode, + SSHServerLocation: areaCodeMap[item.AreaCode], + } + regionLabels := make([]string, 0) + for _, region := range item.RegionSet { + regionLabels = append(regionLabels, regionLabel[region]) + } + row.CoveredArea = strings.Join(regionLabels, ",") + list = append(list, row) + } + ctx.PrintList(list) + }, + } + return cmd +} diff --git a/products/globalssh/internal/gssh/cmd.go b/products/globalssh/internal/gssh/cmd.go new file mode 100644 index 0000000000..dc9f7320cd --- /dev/null +++ b/products/globalssh/internal/gssh/cmd.go @@ -0,0 +1,22 @@ +package gssh + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `gssh` root command. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "gssh", + Short: "Create,list,update and delete globalssh instance", + Long: "Create,list,update and delete globalssh instance", + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newModify(ctx)) + cmd.AddCommand(newArea(ctx)) + return cmd +} diff --git a/products/globalssh/internal/gssh/completion.go b/products/globalssh/internal/gssh/completion.go new file mode 100644 index 0000000000..98ac74c2b4 --- /dev/null +++ b/products/globalssh/internal/gssh/completion.go @@ -0,0 +1,88 @@ +package gssh + +import ( + "fmt" + "strings" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func getAllGssh(ctx *cli.Context, project string) ([]pathxsdk.GlobalSSHInfo, error) { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + req := client.NewDescribeGlobalSSHInstanceRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + resp, err := client.DescribeGlobalSSHInstance(req) + if err != nil { + return nil, err + } + return resp.InstanceSet, nil +} + +func getAllGsshIDNames(ctx *cli.Context, project string) []string { + gsshs, err := getAllGssh(ctx, project) + if err != nil { + return nil + } + list := []string{} + for _, gssh := range gsshs { + list = append(list, fmt.Sprintf("%s/%s", gssh.InstanceId, gssh.TargetIP)) + } + return list +} + +func getAllEip(ctx *cli.Context, projectID, region string, states, paymodes []string) []string { + list, err := fetchAllEip(ctx, projectID, region) + if err != nil { + return nil + } + strs := []string{} + for _, item := range list { + rightState := states == nil + for _, s := range states { + if item.Status == s { + rightState = true + } + } + rightPayMode := paymodes == nil + for _, m := range paymodes { + if item.PayMode == m { + rightPayMode = true + } + } + if !rightPayMode || !rightState { + continue + } + + ips := []string{} + for _, ip := range item.EIPAddr { + ips = append(ips, ip.IP) + } + strs = append(strs, item.EIPId+"/"+strings.Join(ips, ",")) + } + return strs +} + +func fetchAllEip(ctx *cli.Context, projectID, region string) ([]unet.UnetEIPSet, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeEIPRequest() + list := []unet.UnetEIPSet{} + req.ProjectId = sdk.String(cli.PickResourceID(projectID)) + req.Region = sdk.String(region) + for offset, step := 0, 100; ; offset += step { + req.Offset = sdk.Int(offset) + req.Limit = sdk.Int(step) + resp, err := client.DescribeEIP(req) + if err != nil { + return nil, err + } + list = append(list, resp.EIPSet...) + if resp.TotalCount <= offset+step { + break + } + } + return list, nil +} diff --git a/products/globalssh/internal/gssh/create.go b/products/globalssh/internal/gssh/create.go new file mode 100644 index 0000000000..532372d8a2 --- /dev/null +++ b/products/globalssh/internal/gssh/create.go @@ -0,0 +1,77 @@ +package gssh + +import ( + "fmt" + "net" + "strings" + + "github.com/spf13/cobra" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud gssh create +func newCreate(ctx *cli.Context) *cobra.Command { + var targetIP *net.IP + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + req := client.NewCreateGlobalSSHInstanceRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create GlobalSSH instance", + Long: "Create GlobalSSH instance", + Example: "ucloud gssh create --location Washington --target-ip 8.8.8.8", + Run: func(cmd *cobra.Command, args []string) { + port := *req.Port + for code, area := range areaCodeMap { + if area == *req.AreaCode { + *req.AreaCode = code + } + } + if port < 1 || port > 65535 || port == 80 || port == 443 || port == 65123 { + fmt.Fprintln(ctx.ProgressWriter(), "The port number should be between 1 and 65535, and cannot be 80, 443 or 65123") + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.TargetIP = sdk.String(targetIP.String()) + resp, err := client.CreateGlobalSSHInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "gssh[%s] created\n", resp.InstanceId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.InstanceId, Action: "create", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.AreaCode = flags.String("location", "", "Required. Location of the source server. See 'ucloud gssh location'") + targetIP = flags.IP("target-ip", nil, "Required. IP of the source server. Required") + ctx.BindProjectID(cmd, req) + req.Port = flags.Int("port", 22, "Optional. Port of The SSH service between 1 and 65535. Do not use ports such as 80, 443 or 65123.") + req.Remark = flags.String("remark", "", "Optional. Remark of your GlobalSSH.") + req.ChargeType = flags.String("charge-type", "Month", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly(requires access)") + req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") + req.InstanceType = flags.String("instance-type", "", "Optional. Possible values: 'Ultimate','Enterprise', 'Basic', 'Free'(Default value)") + req.ForwardRegion = flags.String("forward-region", "", "Optional. You can select one of 'cn-bj2','cn-sh2','cn-gd' When instance-type is 'Basic'") + req.BandwidthPackage = flags.Int("bandwidth-package", 0, "Optional. You can set one of 0, 20, 40 When instance-type is 'Ultimate'") + cmd.MarkFlagRequired("location") + cmd.MarkFlagRequired("target-ip") + command.SetFlagValues(cmd, "location", "LosAngeles", "Singapore", "Lagos", "HongKong", "Tokyo", "Washington", "Frankfurt") + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic", "Trial") + command.SetFlagValues(cmd, "bandwidth-package", "0", "20", "40") + command.SetFlagValues(cmd, "forward-region", "cn-bj2", "cn-sh2", "cn-gd") + command.SetFlagValues(cmd, "instance-type", "Free", "Basic", "Enterprise", "Ultimate") + ctx.SetCompletion(cmd, "target-ip", func() []string { + eips := getAllEip(ctx, *req.ProjectId, ctx.DefaultRegion(), nil, nil) + for idx, eip := range eips { + eips[idx] = strings.SplitN(eip, "/", 2)[1] + } + return eips + }) + return cmd +} diff --git a/products/globalssh/internal/gssh/delete.go b/products/globalssh/internal/gssh/delete.go new file mode 100644 index 0000000000..5f7ac35a92 --- /dev/null +++ b/products/globalssh/internal/gssh/delete.go @@ -0,0 +1,50 @@ +package gssh + +import ( + "fmt" + + "github.com/spf13/cobra" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete ucloud gssh delete +func newDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + req := client.NewDeleteGlobalSSHInstanceRequest() + gsshIds := []string{} + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete GlobalSSH instance", + Long: "Delete GlobalSSH instance", + Example: "ucloud gssh delete --gssh-id uga-xx1 --id uga-xx2", + Run: func(cmd *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, idname := range gsshIds { + id := ctx.PickResourceID(idname) + req.InstanceId = sdk.String(id) + _, err := client.DeleteGlobalSSHInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "gssh[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&gsshIds, "gssh-id", make([]string, 0), "Required. ID of the GlobalSSH instances you want to delete. Multiple values specified by multiple commas") + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("gssh-id") + ctx.SetCompletion(cmd, "gssh-id", func() []string { + return getAllGsshIDNames(ctx, *req.ProjectId) + }) + return cmd +} diff --git a/products/globalssh/internal/gssh/list.go b/products/globalssh/internal/gssh/list.go new file mode 100644 index 0000000000..e8f55961aa --- /dev/null +++ b/products/globalssh/internal/gssh/list.go @@ -0,0 +1,60 @@ +package gssh + +import ( + "github.com/spf13/cobra" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList ucloud gssh list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + req := client.NewDescribeGlobalSSHInstanceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List all GlobalSSH instances", + Long: "List all GlobalSSH instances", + Example: "ucloud gssh list", + Run: func(cmd *cobra.Command, args []string) { + areaMap := map[string]string{ + "洛杉矶": "LosAngeles", + "新加坡": "Singapore", + "香港": "HongKong", + "东京": "Tokyo", + "华盛顿": "Washington", + "法兰克福": "Frankfurt", + "拉各斯": "Lagos", + } + resp, err := client.DescribeGlobalSSHInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + list := make([]GSSHRow, 0) + for _, gssh := range resp.InstanceSet { + row := GSSHRow{ + ResourceID: gssh.InstanceId, + SSHServerIP: gssh.TargetIP, + AcceleratingDomain: gssh.AcceleratingDomain, + SSHPort: gssh.Port, + GlobalSSHPort: gssh.GlobalSSHPort, + Remark: gssh.Remark, + InstanceType: gssh.InstanceType, + } + if val, ok := areaMap[gssh.Area]; ok { + row.SSHServerLocation = val + } else { + row.SSHServerLocation = gssh.Area + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + cmd.Flags().SortFlags = false + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + return cmd +} diff --git a/products/globalssh/internal/gssh/modify.go b/products/globalssh/internal/gssh/modify.go new file mode 100644 index 0000000000..8a075a3eb2 --- /dev/null +++ b/products/globalssh/internal/gssh/modify.go @@ -0,0 +1,80 @@ +package gssh + +import ( + "fmt" + + "github.com/spf13/cobra" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newModify ucloud gssh update +func newModify(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + gsshModifyPortReq := client.NewModifyGlobalSSHPortRequest() + gsshModifyRemarkReq := client.NewModifyGlobalSSHRemarkRequest() + project := ctx.DefaultProjectID() + gsshIDs := []string{} + cmd := &cobra.Command{ + Use: "update", + Short: "Update GlobalSSH instance", + Long: "Update GlobalSSH instance, including port and remark attribute", + Example: "ucloud gssh update --gssh-id uga-xxx --port 22", + Run: func(cmd *cobra.Command, args []string) { + gsshModifyPortReq.ProjectId = sdk.String(ctx.PickResourceID(project)) + gsshModifyRemarkReq.ProjectId = sdk.String(ctx.PickResourceID(project)) + if *gsshModifyPortReq.Port == 0 && *gsshModifyRemarkReq.Remark == "" { + fmt.Fprintln(ctx.ProgressWriter(), "Error, port or remark required") + } + results := []cli.OpResultRow{} + if *gsshModifyPortReq.Port != 0 { + port := *gsshModifyPortReq.Port + if port <= 1 || port >= 65535 || port == 80 || port == 443 || port == 65123 { + fmt.Fprintln(ctx.ProgressWriter(), "The port number should be between 1 and 65535, and cannot be equal to 80, 443 or 65123") + return + } + for _, idname := range gsshIDs { + id := ctx.PickResourceID(idname) + gsshModifyPortReq.InstanceId = sdk.String(id) + _, err := client.ModifyGlobalSSHPort(gsshModifyPortReq) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "gssh[%s]'s port updated\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "update-port", Status: "Updated"}) + } + } + if *gsshModifyRemarkReq.Remark != "" { + for _, idname := range gsshIDs { + id := ctx.PickResourceID(idname) + gsshModifyRemarkReq.InstanceId = sdk.String(id) + _, err := client.ModifyGlobalSSHRemark(gsshModifyRemarkReq) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "gssh[%s]'s remark updated\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "update-remark", Status: "Updated"}) + } + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&gsshIDs, "gssh-id", nil, "Required. ResourceID of your GlobalSSH instances") + flags.StringVar(&project, "project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + gsshModifyPortReq.Port = flags.Int("port", 0, "Optional. Port of SSH service.") + gsshModifyRemarkReq.Remark = flags.String("remark", "", "Optional. Remark of your GlobalSSH.") + cmd.MarkFlagRequired("gssh-id") + ctx.SetCompletion(cmd, "project-id", ctx.ProjectList) + ctx.SetCompletion(cmd, "gssh-id", func() []string { + return getAllGsshIDNames(ctx, project) + }) + return cmd +} diff --git a/products/globalssh/internal/gssh/rows.go b/products/globalssh/internal/gssh/rows.go new file mode 100644 index 0000000000..94091ff560 --- /dev/null +++ b/products/globalssh/internal/gssh/rows.go @@ -0,0 +1,18 @@ +package gssh + +type GSSHRow struct { + ResourceID string + SSHServerIP string + AcceleratingDomain string + SSHServerLocation string + SSHPort int + GlobalSSHPort int + Remark string + InstanceType string +} + +type GsshLocation struct { + AirportCode string + SSHServerLocation string + CoveredArea string +} diff --git a/products/globalssh/product.go b/products/globalssh/product.go new file mode 100644 index 0000000000..2391c060fe --- /dev/null +++ b/products/globalssh/product.go @@ -0,0 +1,20 @@ +package globalssh + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalgssh "github.com/ucloud/ucloud-cli/products/globalssh/internal/gssh" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "globalssh", Commands: []string{"gssh"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalgssh.NewCommand(ctx)} +} diff --git a/products/globalssh/product.yaml b/products/globalssh/product.yaml new file mode 100644 index 0000000000..d40338599a --- /dev/null +++ b/products/globalssh/product.yaml @@ -0,0 +1,6 @@ +name: globalssh +owners: + - Episkey-G +commands: + - gssh +enabled: true diff --git a/products/globalssh/testdata/cmdtree.golden b/products/globalssh/testdata/cmdtree.golden new file mode 100644 index 0000000000..5c0ac287d8 --- /dev/null +++ b/products/globalssh/testdata/cmdtree.golden @@ -0,0 +1,24 @@ +ucloud gssh use=gssh short=Create,list,update and delete globalssh instance +ucloud gssh create use=create short=Create GlobalSSH instance + flag=bandwidth-package short= default=0 required= + flag=charge-type short= default=Month required= + flag=forward-region short= default= required= + flag=instance-type short= default= required= + flag=location short= default= required=true + flag=port short= default=22 required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=remark short= default= required= + flag=target-ip short= default= required=true +ucloud gssh delete use=delete short=Delete GlobalSSH instance + flag=gssh-id short= default=[] required=true + flag=project-id short= default= required= +ucloud gssh list use=list short=List all GlobalSSH instances + flag=project-id short= default= required= + flag=region short= default= required= +ucloud gssh location use=location short=List SSH server locations and covered areas +ucloud gssh update use=update short=Update GlobalSSH instance + flag=gssh-id short= default=[] required=true + flag=port short= default=0 required= + flag=project-id short= default= required= + flag=remark short= default= required= diff --git a/products/globalssh/testdata/completion.golden b/products/globalssh/testdata/completion.golden new file mode 100644 index 0000000000..dbb2df71e3 --- /dev/null +++ b/products/globalssh/testdata/completion.golden @@ -0,0 +1,11 @@ +ucloud gssh create bandwidth-package static 0,20,40 +ucloud gssh create charge-type static Dynamic,Month,Trial,Year +ucloud gssh create forward-region static cn-bj2,cn-gd,cn-sh2 +ucloud gssh create instance-type static Basic,Enterprise,Free,Ultimate +ucloud gssh create location static Frankfurt,HongKong,Lagos,LosAngeles,Singapore,Tokyo,Washington +ucloud gssh create project-id dynamic +ucloud gssh create target-ip dynamic +ucloud gssh delete gssh-id dynamic +ucloud gssh delete project-id dynamic +ucloud gssh update gssh-id dynamic +ucloud gssh update project-id dynamic diff --git a/products/image/internal/image/cmd.go b/products/image/internal/image/cmd.go new file mode 100644 index 0000000000..42e36ff67e --- /dev/null +++ b/products/image/internal/image/cmd.go @@ -0,0 +1,26 @@ +package image + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `image` root command and mounts the 4 subcommands. +// Mirrors cmd/image.go NewCmdUImage (same AddCommand order: list, copy, delete, +// create). The create subcommand is image's OWN copy of uhost's create-image +// (newCreateImage) — image no longer borrows NewCmdUhostCreateImage from cmd. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "image", + Short: "List and manipulate images", + Long: `List and manipulate images`, + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCopy(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newCreateImage(ctx)) + + return cmd +} diff --git a/products/image/internal/image/completion.go b/products/image/internal/image/completion.go new file mode 100644 index 0000000000..a636963014 --- /dev/null +++ b/products/image/internal/image/completion.go @@ -0,0 +1,69 @@ +package image + +import ( + "strings" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// getImageList returns "ImageId/ImageName" completion candidates filtered by +// states and image type. Self-contained SDK call COPIED from cmd/image.go +// (base.BizClient → cli.NewServiceClient on the public uhost SDK). +func getImageList(ctx *cli.Context, states []string, imageType, project, region, zone string) []string { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeImageRequest() + req.ProjectId = &project + req.Region = ®ion + req.Zone = &zone + req.Limit = sdk.Int(1000) + if imageType != IMAGE_ALL { + req.ImageType = sdk.String(imageType) + } + resp, err := client.DescribeImage(req) + if err != nil { + return nil + } + list := []string{} + for _, image := range resp.ImageSet { + for _, s := range states { + if image.State == s { + list = append(list, image.ImageId+"/"+image.ImageName) + } + } + } + return list +} + +// getUhostList returns "UHostId/Name" completion candidates for the create +// command's --uhost-id flag. Copied self-contained from cmd/uhost.go +// (base.BizClient → cli.NewServiceClient on the public uhost SDK); image's +// create-image is its own copy and must not import the uhost product or cmd. +func getUhostList(ctx *cli.Context, states []string, project, region, zone string) []string { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeUHostInstanceRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + req.Limit = sdk.Int(50) + resp, err := client.DescribeUHostInstance(req) + if err != nil { + //todo runtime log + return nil + } + list := []string{} + for _, host := range resp.UHostSet { + if states != nil { + for _, s := range states { + if host.State == s { + list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) + } + } + } else { + list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) + } + } + return list +} diff --git a/products/image/internal/image/copy.go b/products/image/internal/image/copy.go new file mode 100644 index 0000000000..17f0bf22aa --- /dev/null +++ b/products/image/internal/image/copy.go @@ -0,0 +1,77 @@ +package image + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCopy ucloud image copy +func newCopy(ctx *cli.Context) *cobra.Command { + var imageIDs *[]string + var async *bool + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewCopyCustomImageRequest() + cmd := &cobra.Command{ + Use: "copy", + Short: "Copy custom images", + Long: "Copy custom images", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + *req.ProjectId = ctx.PickResourceID(*req.ProjectId) + *req.TargetProjectId = ctx.PickResourceID(*req.TargetProjectId) + for _, id := range *imageIDs { + id = ctx.PickResourceID(id) + req.SourceImageId = &id + resp, err := client.CopyCustomImage(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("image[%s] is coping", resp.TargetImageId) + if *async { + fmt.Fprintln(w, text) + } else { + // M2: poll the TARGET project/region (not the source request + // defaults) so cross-region copy converges. Mirrors cmd/image.go, + // whose base.Poller.Poll bound *req.TargetProjectId/*req.TargetRegion. + ctx.PollerTo(w, describeImageByID(ctx, *req.TargetProjectId, *req.TargetRegion, "")).Spoll(resp.TargetImageId, text, []string{IMAGE_AVAILABLE, IMAGE_UNAVAILABLE}) + } + results = append(results, cli.OpResultRow{ResourceID: resp.TargetImageId, Action: "copy", Status: "Copying"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + imageIDs = cmd.Flags().StringSlice("source-image-id", nil, "Required. Resource ID of source image") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = cmd.Flags().String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + req.TargetRegion = flags.String("target-region", ctx.DefaultRegion(), "Optional. Target region. See 'ucloud region'") + req.TargetProjectId = flags.String("target-project", ctx.DefaultProjectID(), "Optional. Target Project ID. See 'ucloud project list'") + req.TargetImageName = flags.String("target-image-name", "", "Optional. Name of target image") + req.TargetImageDescription = flags.String("target-image-desc", "", "Optional. Description of target image") + async = flags.Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + + command.SetCompletion(cmd, "source-image-id", func() []string { + return getImageList(ctx, []string{IMAGE_AVAILABLE}, IAMGE_CUSTOM, *req.ProjectId, *req.Region, *req.Zone) + }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { + return ctx.ZoneList(*req.Region) + }) + command.SetCompletion(cmd, "target-region", ctx.RegionList) + command.SetCompletion(cmd, "target-project", ctx.ProjectList) + + cmd.MarkFlagRequired("source-image-id") + + return cmd +} diff --git a/products/image/internal/image/create.go b/products/image/internal/image/create.go new file mode 100644 index 0000000000..57bdff0d27 --- /dev/null +++ b/products/image/internal/image/create.go @@ -0,0 +1,64 @@ +package image + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreateImage ucloud image create — image's OWN copy of uhost's create-image. +// Copied verbatim from cmd/uhost.go NewCmdUhostCreateImage (CreateCustomImage + +// poll); Use is "create" to match the original `image create` (which borrowed +// uhost's command and renamed its Use). uhost (Part 6) keeps its own +// create-image — duplication across products is allowed (product autonomy). +func newCreateImage(ctx *cli.Context) *cobra.Command { + var async *bool + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewCreateCustomImageRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create image from an uhost instance", + Long: "Create image from an uhost instance", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + req.UHostId = sdk.String(ctx.PickResourceID(*req.UHostId)) + resp, err := client.CreateCustomImage(req) + if err != nil { + ctx.HandleError(err) + return + } + // M2: typo "iamge[%s] is making" preserved verbatim from cmd/uhost.go. + text := fmt.Sprintf("iamge[%s] is making", resp.ImageId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeImageByID(ctx, *req.ProjectId, *req.Region, *req.Zone)).Spoll(resp.ImageId, text, []string{IMAGE_AVAILABLE, IMAGE_UNAVAILABLE}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ImageId, Action: "create", Status: "Making"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.UHostId = flags.String("uhost-id", "", "Resource ID of uhost to create image from") + req.ImageName = flags.String("image-name", "", "Required. Name of the image to create") + req.ImageDescription = flags.String("image-desc", "", "Optional. Description of the image to create") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) + }) + + cmd.MarkFlagRequired("uhost-id") + cmd.MarkFlagRequired("image-name") + return cmd +} diff --git a/products/image/internal/image/delete.go b/products/image/internal/image/delete.go new file mode 100644 index 0000000000..ed60f105db --- /dev/null +++ b/products/image/internal/image/delete.go @@ -0,0 +1,52 @@ +package image + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud image delete +func newDelete(ctx *cli.Context) *cobra.Command { + var imageIDs *[]string + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewTerminateCustomImageRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete custom images", + Long: "Delete custom images", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *imageIDs { + req.ImageId = sdk.String(ctx.PickResourceID(id)) + resp, err := client.TerminateCustomImage(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(w, "image[%s] deleted\n", resp.ImageId) + results = append(results, cli.OpResultRow{ResourceID: resp.ImageId, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + imageIDs = cmd.Flags().StringSlice("image-id", nil, "Required. Resource ID of images") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") + cmd.MarkFlagRequired("image-id") + command.SetCompletion(cmd, "image-id", func() []string { + return getImageList(ctx, []string{IMAGE_AVAILABLE, IMAGE_COPYING, IMAGE_MAKING}, IAMGE_CUSTOM, *req.ProjectId, *req.Region, "") + }) + return cmd +} diff --git a/products/image/internal/image/describe.go b/products/image/internal/image/describe.go new file mode 100644 index 0000000000..69cb6ec72b --- /dev/null +++ b/products/image/internal/image/describe.go @@ -0,0 +1,38 @@ +package image + +import ( + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// describeImageByID returns the poller's describe func, closing over ctx (for an +// authed uhost client) and the project/region/zone the image lives in. The +// Spoll loop calls this with a nil commonBase, so the project/region/zone MUST +// be bound here — this is what makes cross-region copy (TargetRegion/ +// TargetProjectId) converge. Ported from cmd/image.go's describeImageByID, whose +// (project, region, zone) args the legacy base.Poller.Poll passed through. +func describeImageByID(ctx *cli.Context, project, region, zone string) func(imageID string, commonBase *request.CommonBase) (interface{}, error) { + return func(imageID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeImageRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.ImageId = sdk.String(imageID) + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + req.Limit = sdk.Int(50) + resp, err := client.DescribeImage(req) + if err != nil { + return nil, err + } + if len(resp.ImageSet) < 1 { + return nil, nil + } + return &resp.ImageSet[0], nil + } +} diff --git a/products/image/internal/image/list.go b/products/image/internal/image/list.go new file mode 100644 index 0000000000..110d43af91 --- /dev/null +++ b/products/image/internal/image/list.go @@ -0,0 +1,57 @@ +package image + +import ( + "strings" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList ucloud image list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeImageRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List image", + Long: "List image", + Example: "ucloud image list --image-type Base", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.DescribeImage(req) + if err != nil { + ctx.HandleError(err) + return + } + list := make([]ImageRow, 0) + for _, image := range resp.ImageSet { + row := ImageRow{} + row.ImageName = image.ImageName + row.ImageID = image.ImageId + row.ImageType = image.ImageType + row.BasicImage = image.OsName + row.ExtensibleFeature = strings.Join(image.Features, ",") + row.CreationTime = common.FormatDate(image.CreateTime) + row.State = image.State + if row.State == "Available" { + list = append(list, row) + } + } + ctx.PrintList(list) + }, + } + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") + req.ImageType = cmd.Flags().String("image-type", "Base", "Optional. 'Base',Standard image; 'Business',image market; 'Custom',custom image") + req.OsType = cmd.Flags().String("os-type", "", "Optional. Linux or Windows. Return all types by default") + req.ImageId = cmd.Flags().String("image-id", "", "Optional. Resource ID of image") + req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0") + req.Limit = cmd.Flags().Int("limit", 500, "Optional. Max count") + command.SetFlagValues(cmd, "image-type", "Base", "Business", "Custom") + return cmd +} diff --git a/products/image/internal/image/rows.go b/products/image/internal/image/rows.go new file mode 100644 index 0000000000..a152462fa0 --- /dev/null +++ b/products/image/internal/image/rows.go @@ -0,0 +1,12 @@ +package image + +// ImageRow 表格行 — byte-identical to cmd/image.go's ImageRow. +type ImageRow struct { + ImageName string + ImageID string + ImageType string + BasicImage string + ExtensibleFeature string + CreationTime string + State string +} diff --git a/products/image/internal/image/status.go b/products/image/internal/image/status.go new file mode 100644 index 0000000000..2387df48c6 --- /dev/null +++ b/products/image/internal/image/status.go @@ -0,0 +1,18 @@ +package image + +// Image-domain state/type constants plus constants this product depends on, +// product-owned copies (formerly model/status + model/cli; the IAMGE_CUSTOM +// typo is preserved verbatim — renaming it is behavior-adjacent cleanup, out +// of scope for the pure copy). +const ( + HOST_RUNNING = "Running" + HOST_STOPPED = "Stopped" + + IMAGE_MAKING = "Making" + IMAGE_AVAILABLE = "Available" + IMAGE_UNAVAILABLE = "Unavailable" + IMAGE_COPYING = "Copying" + + IAMGE_CUSTOM = "Custom" + IMAGE_ALL = "*" +) diff --git a/products/image/product.go b/products/image/product.go new file mode 100644 index 0000000000..4ce019731d --- /dev/null +++ b/products/image/product.go @@ -0,0 +1,20 @@ +package image + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalimage "github.com/ucloud/ucloud-cli/products/image/internal/image" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "image", Commands: []string{"image"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalimage.NewCommand(ctx)} +} diff --git a/products/image/product.yaml b/products/image/product.yaml new file mode 100644 index 0000000000..34e47ee80e --- /dev/null +++ b/products/image/product.yaml @@ -0,0 +1,6 @@ +name: image +owners: + - Episkey-G +commands: + - image +enabled: true diff --git a/products/image/testdata/cmdtree.golden b/products/image/testdata/cmdtree.golden new file mode 100644 index 0000000000..2cc9bf4c2c --- /dev/null +++ b/products/image/testdata/cmdtree.golden @@ -0,0 +1,33 @@ +ucloud image use=image short=List and manipulate images +ucloud image copy use=copy short=Copy custom images + flag=async short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=source-image-id short= default=[] required=true + flag=target-image-desc short= default= required= + flag=target-image-name short= default= required= + flag=target-project short= default= required= + flag=target-region short= default= required= + flag=zone short= default= required= +ucloud image create use=create short=Create image from an uhost instance + flag=async short=a default=false required= + flag=image-desc short= default= required= + flag=image-name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=uhost-id short= default= required=true + flag=zone short= default= required= +ucloud image delete use=delete short=Delete custom images + flag=image-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud image list use=list short=List image + flag=image-id short= default= required= + flag=image-type short= default=Base required= + flag=limit short= default=500 required= + flag=offset short= default=0 required= + flag=os-type short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= diff --git a/products/image/testdata/completion.golden b/products/image/testdata/completion.golden new file mode 100644 index 0000000000..0f1a6d1c21 --- /dev/null +++ b/products/image/testdata/completion.golden @@ -0,0 +1,9 @@ +ucloud image copy project-id dynamic +ucloud image copy region dynamic +ucloud image copy source-image-id dynamic +ucloud image copy target-project dynamic +ucloud image copy target-region dynamic +ucloud image copy zone dynamic +ucloud image create uhost-id dynamic +ucloud image delete image-id dynamic +ucloud image list image-type static Base,Business,Custom diff --git a/products/memcache/internal/memcache/cmd.go b/products/memcache/internal/memcache/cmd.go new file mode 100644 index 0000000000..569a0edfe5 --- /dev/null +++ b/products/memcache/internal/memcache/cmd.go @@ -0,0 +1,22 @@ +package memcache + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand returns the ucloud memcache command tree. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "memcache", + Short: "List and manipulate memcache instances", + Long: "List and manipulate memcache instances", + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newRestart(ctx)) + cmd.AddCommand(newResize(ctx)) + return cmd +} diff --git a/products/memcache/internal/memcache/completion.go b/products/memcache/internal/memcache/completion.go new file mode 100644 index 0000000000..bbf2a67926 --- /dev/null +++ b/products/memcache/internal/memcache/completion.go @@ -0,0 +1,35 @@ +package memcache + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func getIDList(ctx *cli.Context, project, region string) []string { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeUMemcacheGroupRequest() + req.ProjectId = &project + req.Region = ®ion + list := []string{} + + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.DescribeUMemcacheGroup(req) + if err != nil { + fmt.Fprintln(ctx.ProgressWriter(), err) + return nil + } + for _, ins := range resp.DataSet { + list = append(list, fmt.Sprintf("%s/%s", ins.GroupId, ins.Name)) + } + if offset+limit >= resp.TotalCount { + break + } + } + return list +} diff --git a/products/memcache/internal/memcache/create.go b/products/memcache/internal/memcache/create.go new file mode 100644 index 0000000000..c8f9de7b24 --- /dev/null +++ b/products/memcache/internal/memcache/create.go @@ -0,0 +1,75 @@ +package memcache + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate returns ucloud memcache create. +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewCreateUMemcacheGroupRequest() + var region, zone, projectID string + cmd := &cobra.Command{ + Use: "create", + Short: "Create memcache instance", + Long: "Create memcache instance", + Run: func(c *cobra.Command, args []string) { + if *req.Size > 32 || *req.Size < 1 { + fmt.Fprintln(ctx.ProgressWriter(), "size-gb should be between 1 and 32") + return + } + if err := fillDefaultVPCAndSubnet(ctx, req.VPCId, req.SubnetId, *req.ProjectId, *req.Region, *req.Zone); err != nil { + fmt.Fprintln(ctx.ProgressWriter(), err) + return + } + resp, err := client.CreateUMemcacheGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "memcache[%s] created\n", resp.GroupId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.GroupId, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.Name = flags.String("name", "", "Required. Name of memcache instance to create") + req.Size = flags.Int("size-gb", 1, "Optional. Memory size of memcache instance. Unit GB. Accpet values:1,2,4,8,16,32") + req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID. See 'ucloud vpc list'") + req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID. See 'ucloud subnet list'") + flags.StringVar(®ion, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + req.ChargeType = flags.String("charge-type", "Month", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly; 'Dynamic', pay hourly; 'Trial', free trial(need permission)") + req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") + req.Tag = flags.String("group", "", "Optional. Business group") + + req.Region = ®ion + req.Zone = &zone + req.ProjectId = &projectID + + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetFlagValues(cmd, "size-gb", "1", "2", "4", "8", "16", "32") + command.SetFlagValues(cmd, "charge-type", "Month", "Dynamic", "Year") + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, projectID, region) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, *req.VPCId, projectID, region) + }) + + cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/products/memcache/internal/memcache/delete.go b/products/memcache/internal/memcache/delete.go new file mode 100644 index 0000000000..45d2d7a30a --- /dev/null +++ b/products/memcache/internal/memcache/delete.go @@ -0,0 +1,56 @@ +package memcache + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete returns ucloud memcache delete. +func newDelete(ctx *cli.Context) *cobra.Command { + var idNames []string + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDeleteUMemcacheGroupRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete memcache instances", + Long: "Delete memcache instances", + Example: "ucloud memcache delete --umem-id umemcache-rl5xuxx/testcli1,umemcache-xsdfa/testcli2", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.GroupId = &id + _, err := client.DeleteUMemcacheGroup(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "memcache[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of memcache intances to delete") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZoneEmpty(cmd, req) + + cmd.MarkFlagRequired("umem-id") + + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/memcache/internal/memcache/list.go b/products/memcache/internal/memcache/list.go new file mode 100644 index 0000000000..a43667c42b --- /dev/null +++ b/products/memcache/internal/memcache/list.go @@ -0,0 +1,57 @@ +package memcache + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList returns ucloud memcache list. +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeUMemcacheGroupRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List memcache instances", + Long: "List memcache instances", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeUMemcacheGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []Row{} + for _, ins := range resp.DataSet { + row := Row{ + ResourceID: ins.GroupId, + Name: ins.Name, + Group: ins.Tag, + Size: fmt.Sprintf("%dGB", ins.Size), + UsedSize: fmt.Sprintf("%dMB", ins.UsedSize), + State: ins.State, + CreateTime: common.FormatDate(ins.CreateTime), + Address: fmt.Sprintf("%s:%d", ins.VirtualIP, ins.Port), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.GroupId = flags.String("umem-id", "", "Optional. Resource ID of the redis to list") + ctx.BindRegion(cmd, req) + ctx.BindZoneEmpty(cmd, req) + ctx.BindProjectID(cmd, req) + ctx.BindOffset(cmd, req) + ctx.BindLimit(cmd, req) + + return cmd +} diff --git a/products/memcache/internal/memcache/poll.go b/products/memcache/internal/memcache/poll.go new file mode 100644 index 0000000000..a9b5550b30 --- /dev/null +++ b/products/memcache/internal/memcache/poll.go @@ -0,0 +1,32 @@ +package memcache + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func describeByID(ctx *cli.Context) func(string, *request.CommonBase) (interface{}, error) { + return func(memcacheID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeUMemRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.Protocol = sdk.String("memcache") + req.ResourceId = &memcacheID + + resp, err := client.DescribeUMem(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) < 1 { + return nil, fmt.Errorf("resource [%s] may not exist", memcacheID) + } + return &resp.DataSet[0], nil + } +} diff --git a/products/memcache/internal/memcache/resize.go b/products/memcache/internal/memcache/resize.go new file mode 100644 index 0000000000..e888e7ac9b --- /dev/null +++ b/products/memcache/internal/memcache/resize.go @@ -0,0 +1,82 @@ +package memcache + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResize returns ucloud memcache resize. +func newResize(ctx *cli.Context) *cobra.Command { + idNames := make([]string, 0) + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewResizeUMemcacheGroupRequest() + cmd := &cobra.Command{ + Use: "resize", + Short: "Resize memcache instances", + Long: "Resize memcache instances", + Run: func(c *cobra.Command, args []string) { + reqs := make([]request.Common, len(idNames)) + for idx, idname := range idNames { + id := ctx.PickResourceID(idname) + next := *req + next.GroupId = &id + reqs[idx] = &next + } + prog := ctx.NewProgress() + if len(reqs) > 5 { + prog.Disable() + } + ctx.ConcurrentAction(reqs, 10, resize(ctx, client, prog)) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of memcache to resize") + req.Size = flags.Int("size-gb", 0, "Required. Target memory size in GB. Accept values:1,2,4,8,16,32") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + command.SetFlagValues(cmd, "size-gb", "1", "2", "4", "8", "16", "32") + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("size-gb") + return cmd +} + +func resize(ctx *cli.Context, client *umem.UMemClient, prog *cli.Progress) func(request.Common) (bool, []string) { + return func(creq request.Common) (bool, []string) { + req := creq.(*umem.ResizeUMemcacheGroupRequest) + block := prog.NewBlock() + logs := []string{} + _, err := client.ResizeUMemcacheGroup(req) + if err != nil { + msg := fmt.Sprintf("resize memcache[%s] failed: %s", *req.GroupId, cli.ParseError(err)) + block.Append(cli.ParseError(err)) + logs = append(logs, msg) + return false, logs + } + text := fmt.Sprintf("memcache[%s] is resizing", *req.GroupId) + ret := ctx.PollerTo(ctx.ProgressWriter(), describeByID(ctx)).Sspoll(*req.GroupId, text, []string{UMEM_RUNNING, UMEM_FAIL}, block, nil) + if ret.Err != nil { + block.Append(cli.ParseError(ret.Err)) + logs = append(logs, ret.Err.Error()) + } + if ret.Timeout { + logs = append(logs, fmt.Sprintf("poll memcache[%s] timeout", *req.GroupId)) + } + return ret.Done, logs + } +} diff --git a/products/memcache/internal/memcache/restart.go b/products/memcache/internal/memcache/restart.go new file mode 100644 index 0000000000..9fb8b8edec --- /dev/null +++ b/products/memcache/internal/memcache/restart.go @@ -0,0 +1,79 @@ +package memcache + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestart returns ucloud memcache restart. +func newRestart(ctx *cli.Context) *cobra.Command { + idNames := make([]string, 0) + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewRestartUMemcacheGroupRequest() + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart memcache instances", + Long: "Restart memcache instances", + Run: func(c *cobra.Command, args []string) { + reqs := make([]request.Common, len(idNames)) + for idx, idname := range idNames { + id := ctx.PickResourceID(idname) + next := *req + next.GroupId = &id + reqs[idx] = &next + } + prog := ctx.NewProgress() + if len(reqs) > 5 { + prog.Disable() + } + ctx.ConcurrentAction(reqs, 10, restart(ctx, client, prog)) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of memcache to restart") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("umem-id") + return cmd +} + +func restart(ctx *cli.Context, client *umem.UMemClient, prog *cli.Progress) func(request.Common) (bool, []string) { + return func(creq request.Common) (bool, []string) { + req := creq.(*umem.RestartUMemcacheGroupRequest) + block := prog.NewBlock() + logs := []string{} + _, err := client.RestartUMemcacheGroup(req) + if err != nil { + msg := fmt.Sprintf("restart memcache[%s] failed: %s", *req.GroupId, cli.ParseError(err)) + block.Append(cli.ParseError(err)) + logs = append(logs, msg) + return false, logs + } + text := fmt.Sprintf("memcache[%s] is restarting", *req.GroupId) + ret := ctx.PollerTo(ctx.ProgressWriter(), describeByID(ctx)).Sspoll(*req.GroupId, text, []string{UMEM_RUNNING, UMEM_FAIL}, block, nil) + if ret.Err != nil { + block.Append(cli.ParseError(ret.Err)) + logs = append(logs, ret.Err.Error()) + } + if ret.Timeout { + logs = append(logs, fmt.Sprintf("poll memcache[%s] timeout", *req.GroupId)) + } + return ret.Done, logs + } +} diff --git a/products/memcache/internal/memcache/rows.go b/products/memcache/internal/memcache/rows.go new file mode 100644 index 0000000000..5e0e1b6435 --- /dev/null +++ b/products/memcache/internal/memcache/rows.go @@ -0,0 +1,12 @@ +package memcache + +type Row struct { + ResourceID string + Name string + Address string + Size string + UsedSize string + State string + Group string + CreateTime string +} diff --git a/products/memcache/internal/memcache/status.go b/products/memcache/internal/memcache/status.go new file mode 100644 index 0000000000..5b78cb4831 --- /dev/null +++ b/products/memcache/internal/memcache/status.go @@ -0,0 +1,6 @@ +package memcache + +const ( + UMEM_FAIL = "Fail" + UMEM_RUNNING = "Running" +) diff --git a/products/memcache/internal/memcache/vpc.go b/products/memcache/internal/memcache/vpc.go new file mode 100644 index 0000000000..60ef1dc02a --- /dev/null +++ b/products/memcache/internal/memcache/vpc.go @@ -0,0 +1,119 @@ +package memcache + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func fillDefaultVPCAndSubnet(ctx *cli.Context, vpcID, subnetID *string, projectID, region, zone string) error { + if *vpcID != "" && *subnetID != "" { + return nil + } + vpcs, err := getAllVPCIns(ctx, projectID, region) + if err != nil { + return fmt.Errorf("failed to get vpc list: %s", err) + } + if len(vpcs) == 0 { + return fmt.Errorf("no vpc found in region[%s], please specify --vpc-id and --subnet-id", region) + } + + var defaultVPC *vpc.VPCInfo + for i := range vpcs { + if vpcs[i].VPCType == "DefaultVPC" { + defaultVPC = &vpcs[i] + break + } + } + if defaultVPC == nil { + defaultVPC = &vpcs[0] + } + + if *vpcID == "" { + *vpcID = defaultVPC.VPCId + } + + if *subnetID == "" { + subnets, err := getAllSubnets(ctx, *vpcID, projectID, region) + if err != nil { + return fmt.Errorf("failed to get subnet list: %s", err) + } + if len(subnets) == 0 { + return fmt.Errorf("no subnet found in vpc[%s], please specify --subnet-id", *vpcID) + } + if zone != "" { + for _, sn := range subnets { + if sn.Zone == zone { + *subnetID = sn.SubnetId + return nil + } + } + } + *subnetID = subnets[0].SubnetId + } + + return nil +} + +func getAllVPCIns(ctx *cli.Context, project, region string) ([]vpc.VPCInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + vpcInsList, err := getAllVPCIns(ctx, project, region) + list := []string{} + if err != nil { + return nil + } + for _, vpc := range vpcInsList { + list = append(list, fmt.Sprintf("%s/%s", vpc.VPCId, vpc.Name)) + } + return list +} + +func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpc.SubnetInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + if vpcID != "" { + req.VPCId = sdk.String(cli.PickResourceID(vpcID)) + } + subnets := []vpc.SubnetInfo{} + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.DescribeSubnet(req) + if err != nil { + return nil, err + } + subnets = append(subnets, resp.DataSet...) + if limit+offset >= resp.TotalCount { + break + } + } + return subnets, nil +} + +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + subnets, err := getAllSubnets(ctx, vpcID, project, region) + if err != nil { + return nil + } + list := []string{} + for _, s := range subnets { + list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return list +} diff --git a/products/memcache/product.go b/products/memcache/product.go new file mode 100644 index 0000000000..267358904a --- /dev/null +++ b/products/memcache/product.go @@ -0,0 +1,20 @@ +package memcache + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalmemcache "github.com/ucloud/ucloud-cli/products/memcache/internal/memcache" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "memcache", Commands: []string{"memcache"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalmemcache.NewCommand(ctx)} +} diff --git a/products/memcache/product.yaml b/products/memcache/product.yaml new file mode 100644 index 0000000000..b1a5cc3514 --- /dev/null +++ b/products/memcache/product.yaml @@ -0,0 +1,6 @@ +name: memcache +owners: + - ucloud-umem-qingpfang +commands: + - memcache +enabled: true diff --git a/products/memcache/testdata/cmdtree.golden b/products/memcache/testdata/cmdtree.golden new file mode 100644 index 0000000000..6ed98b2b99 --- /dev/null +++ b/products/memcache/testdata/cmdtree.golden @@ -0,0 +1,35 @@ +ucloud memcache use=memcache short=List and manipulate memcache instances +ucloud memcache create use=create short=Create memcache instance + flag=charge-type short= default=Month required= + flag=group short= default= required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=size-gb short= default=1 required= + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud memcache delete use=delete short=Delete memcache instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud memcache list use=list short=List memcache instances + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default= required= + flag=zone short= default= required= +ucloud memcache resize use=resize short=Resize memcache instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=size-gb short= default=0 required=true + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud memcache restart use=restart short=Restart memcache instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default=[] required=true + flag=zone short= default= required= diff --git a/products/memcache/testdata/completion.golden b/products/memcache/testdata/completion.golden new file mode 100644 index 0000000000..044307428e --- /dev/null +++ b/products/memcache/testdata/completion.golden @@ -0,0 +1,23 @@ +ucloud memcache create charge-type static Dynamic,Month,Year +ucloud memcache create project-id dynamic +ucloud memcache create region dynamic +ucloud memcache create size-gb static 1,16,2,32,4,8 +ucloud memcache create subnet-id dynamic +ucloud memcache create vpc-id dynamic +ucloud memcache create zone dynamic +ucloud memcache delete project-id dynamic +ucloud memcache delete region dynamic +ucloud memcache delete umem-id dynamic +ucloud memcache delete zone dynamic +ucloud memcache list project-id dynamic +ucloud memcache list region dynamic +ucloud memcache list zone dynamic +ucloud memcache resize project-id dynamic +ucloud memcache resize region dynamic +ucloud memcache resize size-gb static 1,16,2,32,4,8 +ucloud memcache resize umem-id dynamic +ucloud memcache resize zone dynamic +ucloud memcache restart project-id dynamic +ucloud memcache restart region dynamic +ucloud memcache restart umem-id dynamic +ucloud memcache restart zone dynamic diff --git a/products/mysql/internal/mysql/backup.go b/products/mysql/internal/mysql/backup.go new file mode 100644 index 0000000000..0d0ba8e452 --- /dev/null +++ b/products/mysql/internal/mysql/backup.go @@ -0,0 +1,21 @@ +package mysql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUDBBackup ucloud udb backup +func newUDBBackup(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "backup", + Short: "List and manipulate backups of MySQL instance", + Long: "List and manipulate backups of MySQL instance", + } + cmd.AddCommand(newUDBBackupCreate(ctx)) + cmd.AddCommand(newUDBBackupList(ctx)) + cmd.AddCommand(newUDBBackupDelete(ctx)) + cmd.AddCommand(newUDBBackupGetDownloadURL(ctx)) + return cmd +} diff --git a/products/mysql/internal/mysql/backup_create.go b/products/mysql/internal/mysql/backup_create.go new file mode 100644 index 0000000000..368d71d2a4 --- /dev/null +++ b/products/mysql/internal/mysql/backup_create.go @@ -0,0 +1,51 @@ +package mysql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUDBBackupCreate ucloud udb backup create +func newUDBBackupCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewBackupUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create backups for MySQL instance manually", + Long: "Create backups for MySQL instance manually", + Run: func(c *cobra.Command, args []string) { + *req.DBId = ctx.PickResourceID(*req.DBId) + _, err := client.BackupUDBInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "udb[%s] backuped\n", *req.DBId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.DBId, Action: "create", Status: "Backuped"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.DBId = flags.String("udb-id", "", "Required. Resource ID of UDB instnace to backup") + req.BackupName = flags.String("name", "", "Required. Name of backup") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("udb-id") + cmd.MarkFlagRequired("name") + + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "sql", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/backup_delete.go b/products/mysql/internal/mysql/backup_delete.go new file mode 100644 index 0000000000..ba8f713248 --- /dev/null +++ b/products/mysql/internal/mysql/backup_delete.go @@ -0,0 +1,51 @@ +package mysql + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUDBBackupDelete ucloud udb backup delete +func newUDBBackupDelete(ctx *cli.Context) *cobra.Command { + ids := []int{} + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDeleteUDBBackupRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete backups of MySQL instance", + Long: "Delete backups of MySQL instance", + Example: "ucloud udb backup delete --backup-id 65534,65535", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range ids { + req.BackupId = sdk.Int(id) + _, err := client.DeleteUDBBackup(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "backup[%d] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: strconv.Itoa(id), Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.IntSliceVar(&ids, "backup-id", nil, "Required. BackupID of backups to delete") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("backup-id") + return cmd +} diff --git a/products/mysql/internal/mysql/backup_download.go b/products/mysql/internal/mysql/backup_download.go new file mode 100644 index 0000000000..c5b47ddcb9 --- /dev/null +++ b/products/mysql/internal/mysql/backup_download.go @@ -0,0 +1,46 @@ +package mysql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUDBBackupGetDownloadURL ucloud udb backup get-download-url +func newUDBBackupGetDownloadURL(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBInstanceBackupURLRequest() + cmd := &cobra.Command{ + Use: "download", + Short: "Display download url of backup", + Long: "Display download url of backup", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeUDBInstanceBackupURL(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintln(ctx.Out(), resp.BackupPath) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.BackupId = flags.Int("backup-id", -1, "Required. BackupID of backup to delete") + req.DBId = flags.String("udb-id", "", "Required. Resource ID of udb which the backup belongs to") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("udb-id") + cmd.MarkFlagRequired("backup-id") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "sql", *req.ProjectId, *req.Region, *req.Zone) + }) + return cmd +} diff --git a/products/mysql/internal/mysql/backup_list.go b/products/mysql/internal/mysql/backup_list.go new file mode 100644 index 0000000000..1af6c8f665 --- /dev/null +++ b/products/mysql/internal/mysql/backup_list.go @@ -0,0 +1,136 @@ +package mysql + +import ( + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type udbBackupRow struct { + BackupID int + BackupName string + DB string + BackupSize string + BackupType string + Status string + AvailabilityZone string + BackupBeginTime string + BackupEndTime string +} + +var dbTypeMap = map[string]string{ + "mysql": "sql", + "mongodb": "nosql", + "postgresql": "postgresql", + "sqlserver": "sqlserver", +} + +var dbTypeList = []string{"mysql", "mongodb", "postgresql", "sqlserver"} + +// newUDBBackupList ucloud udb backup list +func newUDBBackupList(ctx *cli.Context) *cobra.Command { + var bpType, dbType, beginTime, endTime, backupID string + bpTypeMap := map[string]int{ + "manual": 1, + "auto": 0, + } + reverseBpTypeMap := map[int]string{ + 1: "manual", + 0: "auto", + } + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBBackupRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List backups of MySQL instance", + Long: "List backups of MySQL instance", + Run: func(c *cobra.Command, args []string) { + if v, ok := bpTypeMap[bpType]; ok { + req.BackupType = &v + } + if v, ok := dbTypeMap[dbType]; ok { + req.ClassType = &v + } + if *req.DBId != "" { + *req.DBId = ctx.PickResourceID(*req.DBId) + } + if backupID != "" { + id, err := strconv.Atoi(ctx.PickResourceID(backupID)) + if err != nil { + ctx.HandleError(err) + return + } + req.BackupId = &id + } + if beginTime != "" { + bt, err := time.Parse("2006-01-02/15:04:05", beginTime) + if err != nil { + ctx.HandleError(err) + return + } + req.BeginTime = sdk.Int(int(bt.Unix())) + } + if endTime != "" { + bt, err := time.Parse("2006-01-02/15:04:05", endTime) + if err != nil { + ctx.HandleError(err) + return + } + req.EndTime = sdk.Int(int(bt.Unix())) + } + resp, err := client.DescribeUDBBackup(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []udbBackupRow{} + for _, ins := range resp.DataSet { + row := udbBackupRow{ + BackupID: ins.BackupId, + BackupName: ins.BackupName, + AvailabilityZone: ins.Zone, + DB: fmt.Sprintf("%s|%s", ins.DBName, ins.DBId), + BackupSize: fmt.Sprintf("%dB", ins.BackupSize), + BackupType: reverseBpTypeMap[ins.BackupType], + Status: ins.State, + BackupBeginTime: common.FormatDateTime(ins.BackupTime), + BackupEndTime: common.FormatDateTime(ins.BackupEndTime), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.DBId = flags.String("udb-id", "", "Optional. Resource ID of UDB for list the backups of the specifid UDB") + flags.StringVar(&backupID, "backup-id", "", "Optional. Resource ID of backup. List the specified backup only") + flags.StringVar(&bpType, "backup-type", "", "Optional. Backup type. Accept values:auto or manual") + flags.StringVar(&dbType, "db-type", "", "Optional. Only list backups of the UDB of the specified DB type") + flags.StringVar(&beginTime, "begin-time", "", "Optional. Begin time of backup. For example, 2019-02-26/11:21:39") + flags.StringVar(&endTime, "end-time", "", "Optional. End time of backup. For example, 2019-02-26/11:31:39") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + ctx.BindOffset(cmd, req) + ctx.BindLimit(cmd, req) + + command.SetFlagValues(cmd, "backup-type", "auto", "manual") + command.SetFlagValues(cmd, "db-type", dbTypeList...) + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "sql", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/cmd.go b/products/mysql/internal/mysql/cmd.go new file mode 100644 index 0000000000..ccaa92ecb1 --- /dev/null +++ b/products/mysql/internal/mysql/cmd.go @@ -0,0 +1,22 @@ +package mysql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `mysql` root command and mounts the `db` subtree. +// Mirrors cmd/mysql.go NewCmdMysql + NewCmdMysqlDB. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "mysql", + Short: "Manipulate MySQL on UCloud platform", + Long: "Manipulate MySQL on UCloud platform", + } + cmd.AddCommand(newMysqlDB(ctx)) + cmd.AddCommand(newUDBConf(ctx)) + cmd.AddCommand(newUDBBackup(ctx)) + cmd.AddCommand(newUDBLog(ctx)) + return cmd +} diff --git a/products/mysql/internal/mysql/completion.go b/products/mysql/internal/mysql/completion.go new file mode 100644 index 0000000000..37cc2f3bcb --- /dev/null +++ b/products/mysql/internal/mysql/completion.go @@ -0,0 +1,178 @@ +package mysql + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +var dbVersionList = []string{"mysql-5.7", "mysql-8.0", "mysql-8.4", "percona-5.7"} + +// getAllVPCIns mirrors cmd/vpc.go getAllVPCIns, copied here (not imported) so +// the product stays self-contained per the boundary rules. +func getAllVPCIns(ctx *cli.Context, project, region string) ([]vpc.VPCInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +// getAllVPCIdNames mirrors cmd/vpc.go getAllVPCIdNames. +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + vpcInsList, err := getAllVPCIns(ctx, project, region) + list := []string{} + if err != nil { + return nil + } + for _, vpc := range vpcInsList { + list = append(list, fmt.Sprintf("%s/%s", vpc.VPCId, vpc.Name)) + } + return list +} + +// getAllSubnets mirrors cmd/vpc.go getAllSubnets. +func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpc.SubnetInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + if vpcID != "" { + req.VPCId = sdk.String(cli.PickResourceID(vpcID)) + } + subnets := []vpc.SubnetInfo{} + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.DescribeSubnet(req) + if err != nil { + ctx.HandleError(err) + return nil, err + } + subnets = append(subnets, resp.DataSet...) + if limit+offset >= resp.TotalCount { + break + } + } + return subnets, nil +} + +// getAllSubnetIDNames mirrors cmd/vpc.go getAllSubnetIDNames. +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + subnets, err := getAllSubnets(ctx, vpcID, project, region) + if err != nil { + return nil + } + list := []string{} + for _, s := range subnets { + list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return list +} + +func getUDBIDList(ctx *cli.Context, states []string, dbType, project, region, zone string) []string { + udbs, err := getUDBList(ctx, states, dbType, project, region, zone) + if err != nil { + return nil + } + list := []string{} + for _, db := range udbs { + list = append(list, fmt.Sprintf("%s/%s", db.DBId, db.Name)) + } + return list +} + +func getUDBList(ctx *cli.Context, states []string, dbType, project, region, zone string) ([]udb.UDBInstanceSet, error) { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBInstanceRequest() + if dbType == "" { + dbType = "sql" + } + req.ClassType = &dbType + req.ProjectId = &project + req.Region = ®ion + req.Zone = &zone + list := []udb.UDBInstanceSet{} + for offset, limit := 0, 50; ; offset += limit { + req.Offset = sdk.Int(offset) + req.Limit = sdk.Int(limit) + resp, err := client.DescribeUDBInstance(req) + if err != nil { + return nil, err + } + for _, ins := range resp.DataSet { + if states != nil { + for _, s := range states { + if s == ins.State { + list = append(list, ins) + } + } + } else { + list = append(list, ins) + } + } + if offset+limit >= resp.TotalCount { + break + } + } + return list, nil +} + +func getConfList(ctx *cli.Context, dbType, project, region, zone string) ([]udb.UDBParamGroupSet, error) { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBParamGroupRequest() + req.ClassType = &dbType + req.ProjectId = &project + req.Region = ®ion + req.Zone = &zone + list := []udb.UDBParamGroupSet{} + for offset, limit := 0, 50; ; offset += limit { + req.Offset = sdk.Int(offset) + req.Limit = sdk.Int(limit) + resp, err := client.DescribeUDBParamGroup(req) + if err != nil { + return nil, err + } + for _, conf := range resp.DataSet { + list = append(list, conf) + } + if resp.TotalCount <= offset+limit { + break + } + } + return list, nil +} + +func getModifiableConfIDList(ctx *cli.Context, dbType, project, region, zone string) []string { + confs, err := getConfList(ctx, dbType, project, region, zone) + if err != nil { + return nil + } + list := []string{} + for _, conf := range confs { + if conf.Modifiable == true { + list = append(list, fmt.Sprintf("%d/%s", conf.GroupId, conf.GroupName)) + } + } + return list +} + +func getConfIDList(ctx *cli.Context, dbType, project, region, zone string) []string { + confs, err := getConfList(ctx, dbType, project, region, zone) + if err != nil { + return nil + } + list := []string{} + for _, conf := range confs { + list = append(list, fmt.Sprintf("%d/%s", conf.GroupId, conf.GroupName)) + } + return list +} diff --git a/products/mysql/internal/mysql/conf.go b/products/mysql/internal/mysql/conf.go new file mode 100644 index 0000000000..9c1936ed55 --- /dev/null +++ b/products/mysql/internal/mysql/conf.go @@ -0,0 +1,25 @@ +package mysql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUDBConf ucloud udb conf +func newUDBConf(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "conf", + Short: "List and manipulate configuration files of MySQL instances", + Long: "List and manipulate configuration files of MySQL instances", + } + cmd.AddCommand(newUDBConfList(ctx)) + cmd.AddCommand(newUDBConfDescribe(ctx)) + cmd.AddCommand(newUDBConfClone(ctx)) + cmd.AddCommand(newUDBConfUpload(ctx)) + cmd.AddCommand(newUDBConfUpdate(ctx)) + cmd.AddCommand(newUDBConfDelete(ctx)) + cmd.AddCommand(newUDBConfApply(ctx)) + cmd.AddCommand(newUDBConfDownload(ctx)) + return cmd +} diff --git a/products/mysql/internal/mysql/conf_apply.go b/products/mysql/internal/mysql/conf_apply.go new file mode 100644 index 0000000000..aa7f3a2a1c --- /dev/null +++ b/products/mysql/internal/mysql/conf_apply.go @@ -0,0 +1,97 @@ +package mysql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUDBConfApply ucloud udb conf apply +func newUDBConfApply(ctx *cli.Context) *cobra.Command { + var confID string + var udbIDs []string + var restart, yes, async bool + + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewChangeUDBParamGroupRequest() + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply configuration for UDB instances", + Long: "Apply configuration for UDB instances", + Run: func(c *cobra.Command, args []string) { + req.GroupId = sdk.String(ctx.PickResourceID(confID)) + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range udbIDs { + req.DBId = sdk.String(ctx.PickResourceID(idname)) + if restart { + ok, err := ctx.Confirm(yes, fmt.Sprintf("udb[%s] is about to restart, do you want to continue?", idname)) + if err != nil { + ctx.HandleError(err) + continue + } + if !ok { + continue + } + } + _, err := client.ChangeUDBParamGroup(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "conf[%s] has applied for udb[%s]\n", confID, idname) + results = append(results, cli.OpResultRow{ResourceID: *req.DBId, Action: "apply", Status: "Applied"}) + if !restart { + continue + } + restartReq := client.NewRestartUDBInstanceRequest() + restartReq.Region = req.Region + restartReq.Zone = req.Zone + restartReq.ProjectId = req.ProjectId + restartReq.DBId = req.DBId + _, err = client.RestartUDBInstance(restartReq) + if err != nil { + ctx.HandleError(err) + continue + } + if async { + fmt.Fprintf(w, "udb[%s] is restarting\n", idname) + } else { + text := fmt.Sprintf("udb[%s] is restarting", idname) + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(*req.DBId, text, []string{UDB_FAIL, UDB_RUNNING}) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&confID, "conf-id", "", "Required. ConfID of the configuration to be applied") + flags.StringSliceVar(&udbIDs, "udb-id", nil, "Required. Resource ID of UDB instances to change configuration") + flags.BoolVar(&restart, "restart-after-apply", true, "Optional. The new configuration will take effect after DB restarts") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("conf-id") + cmd.MarkFlagRequired("udb-id") + + command.SetCompletion(cmd, "conf-id", func() []string { + return getModifiableConfIDList(ctx, "", *req.ProjectId, *req.Region, *req.Zone) + }) + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/conf_clone.go b/products/mysql/internal/mysql/conf_clone.go new file mode 100644 index 0000000000..6e1908354e --- /dev/null +++ b/products/mysql/internal/mysql/conf_clone.go @@ -0,0 +1,87 @@ +package mysql + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUDBConfClone ucloud udb conf clone +func newUDBConfClone(ctx *cli.Context) *cobra.Command { + var srcConfID string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewCreateUDBParamGroupRequest() + cmd := &cobra.Command{ + Use: "clone", + Short: "Create configuration file by cloning existed configuration", + Long: "Create configuration file by cloning existed configuration", + Run: func(c *cobra.Command, args []string) { + id, err := strconv.Atoi(ctx.PickResourceID(srcConfID)) + if err != nil { + ctx.HandleError(err) + return + } + if *req.DBTypeId == "" { + confIns, err := getConfByID(ctx, id, *req.ProjectId, *req.Region, *req.Zone) + if err != nil { + ctx.HandleError(err) + return + } + req.DBTypeId = sdk.String(confIns.DBTypeId) + } + req.SrcGroupId = &id + resp, err := client.CreateUDBParamGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "conf[%d] created\n", resp.GroupId) + ctx.EmitResult(cli.OpResultRow{ResourceID: strconv.Itoa(resp.GroupId), Action: "clone", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.DBTypeId = flags.String("db-version", "", fmt.Sprintf("Required. Version of DB. Accept values:%s", strings.Join(dbVersionList, ", "))) + req.GroupName = flags.String("name", "", "Required. Name of configuration. It's length should be between 6 and 63") + req.Description = flags.String("description", " ", "Optional. Description of the configuration to clone") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.StringVar(&srcConfID, "src-conf-id", "", "Optional. The ConfID of source configuration which to be cloned from") + + command.SetFlagValues(cmd, "db-version", dbVersionList...) + command.SetCompletion(cmd, "src-conf-id", func() []string { + return getConfIDList(ctx, "sql", *req.ProjectId, *req.Region, *req.Zone) + }) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("src-conf-id") + return cmd +} + +func getConfByID(ctx *cli.Context, confID int, project, region, zone string) (*udb.UDBParamGroupSet, error) { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBParamGroupRequest() + req.ProjectId = &project + req.Region = ®ion + req.Zone = &zone + req.GroupId = &confID + resp, err := client.DescribeUDBParamGroup(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) != 1 { + return nil, fmt.Errorf("conf-id[%d] may not exist", *req.GroupId) + } + return &resp.DataSet[0], nil +} diff --git a/products/mysql/internal/mysql/conf_delete.go b/products/mysql/internal/mysql/conf_delete.go new file mode 100644 index 0000000000..f1e4c7d00b --- /dev/null +++ b/products/mysql/internal/mysql/conf_delete.go @@ -0,0 +1,53 @@ +package mysql + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUDBConfDelete ucloud udb conf delete +func newUDBConfDelete(ctx *cli.Context) *cobra.Command { + var confID string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDeleteUDBParamGroupRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete configuration of udb by conf-id", + Long: "Delete configuration of udb by conf-id", + Run: func(c *cobra.Command, args []string) { + id, err := strconv.Atoi(ctx.PickResourceID(confID)) + if err != nil { + ctx.HandleError(err) + return + } + req.GroupId = &id + _, err = client.DeleteUDBParamGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "conf[%s] deleted\n", confID) + ctx.EmitResult(cli.OpResultRow{ResourceID: strconv.Itoa(id), Action: "delete", Status: "Deleted"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&confID, "conf-id", "", "Required. ConfID of the configuration to delete") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("conf-id") + command.SetCompletion(cmd, "conf-id", func() []string { + return getModifiableConfIDList(ctx, "", *req.ProjectId, *req.Region, *req.Zone) + }) + return cmd +} diff --git a/products/mysql/internal/mysql/conf_describe.go b/products/mysql/internal/mysql/conf_describe.go new file mode 100644 index 0000000000..bf4cb372d3 --- /dev/null +++ b/products/mysql/internal/mysql/conf_describe.go @@ -0,0 +1,89 @@ +package mysql + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// UDBConfParamRow 参数配置展示表格行 +type UDBConfParamRow struct { + Key string + Value string +} + +// newUDBConfDescribe ucloud udb conf describe +func newUDBConfDescribe(ctx *cli.Context) *cobra.Command { + var confID string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBParamGroupRequest() + req.RegionFlag = sdk.Bool(false) + cmd := &cobra.Command{ + Use: "describe", + Short: "Display details about a configuration file of MySQL instance", + Long: "Display details about a configuration file of MySQL instance", + Run: func(c *cobra.Command, args []string) { + id, err := strconv.Atoi(ctx.PickResourceID(confID)) + if err != nil { + ctx.HandleError(err) + return + } + req.GroupId = &id + resp, err := client.DescribeUDBParamGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + if len(resp.DataSet) != 1 { + ctx.HandleError(fmt.Errorf("conf-id[%d] may not be exist", *req.GroupId)) + return + } + conf := resp.DataSet[0] + attrs := []cli.DescribeRow{ + {Attribute: "ConfID", Content: strconv.Itoa(conf.GroupId)}, + {Attribute: "DBVersion", Content: conf.DBTypeId}, + {Attribute: "Name", Content: conf.GroupName}, + {Attribute: "Description", Content: conf.Description}, + {Attribute: "Modifiable", Content: strconv.FormatBool(conf.Modifiable)}, + {Attribute: "Zone", Content: conf.Zone}, + } + fmt.Fprintln(ctx.ProgressWriter(), "Attributes:") + ctx.PrintList(attrs) + + params := []UDBConfParamRow{} + for _, p := range conf.ParamMember { + if p.Value == "" { + continue + } + row := UDBConfParamRow{ + Key: p.Key, + Value: p.Value, + } + params = append(params, row) + } + fmt.Fprintln(ctx.ProgressWriter(), "\nParameters:") + ctx.PrintList(params) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&confID, "conf-id", "", "Requried. Configuration identifier for the configuration to be described") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("conf-id") + command.SetCompletion(cmd, "conf-id", func() []string { + return getConfIDList(ctx, "sql", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/conf_download.go b/products/mysql/internal/mysql/conf_download.go new file mode 100644 index 0000000000..f75043bc26 --- /dev/null +++ b/products/mysql/internal/mysql/conf_download.go @@ -0,0 +1,56 @@ +package mysql + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUDBConfDownload ucloud udb conf download +func newUDBConfDownload(ctx *cli.Context) *cobra.Command { + var confID string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewExtractUDBParamGroupRequest() + cmd := &cobra.Command{ + Use: "download", + Short: "Download UDB configuration", + Long: "Download UDB configuration", + Run: func(c *cobra.Command, args []string) { + id, err := strconv.Atoi(ctx.PickResourceID(confID)) + if err != nil { + ctx.HandleError(err) + return + } + + req.GroupId = &id + resp, err := client.ExtractUDBParamGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprint(ctx.Out(), resp.Content) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&confID, "conf-id", "", "Required. ConfID of configuration to download") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("conf-id") + + command.SetCompletion(cmd, "conf-id", func() []string { + return getConfIDList(ctx, "sql", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/conf_list.go b/products/mysql/internal/mysql/conf_list.go new file mode 100644 index 0000000000..263df50035 --- /dev/null +++ b/products/mysql/internal/mysql/conf_list.go @@ -0,0 +1,72 @@ +package mysql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// UDBConfRow 表格行 +type UDBConfRow struct { + ConfID int + DBVersion string + Name string + Description string + Modifiable bool + Zone string +} + +// newUDBConfList ucloud mysql conf list +func newUDBConfList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBParamGroupRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List configuartion files of MySQL instances", + Long: "List configuartion files of MySQL instances", + Run: func(c *cobra.Command, args []string) { + if *req.GroupId == 0 { + req.GroupId = nil + } + resp, err := client.DescribeUDBParamGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []UDBConfRow{} + for _, ins := range resp.DataSet { + row := UDBConfRow{ + ConfID: ins.GroupId, + Name: ins.GroupName, + Zone: ins.Zone, + DBVersion: ins.DBTypeId, + Description: ins.Description, + Modifiable: ins.Modifiable, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + ctx.BindOffset(cmd, req) + ctx.BindLimit(cmd, req) + req.GroupId = flags.Int("conf-id", 0, "Optional. Configuration identifier for the configuration to be described") + req.ClassType = sdk.String("sql") + + command.SetCompletion(cmd, "conf-id", func() []string { + return getConfIDList(ctx, *req.ClassType, *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/conf_update.go b/products/mysql/internal/mysql/conf_update.go new file mode 100644 index 0000000000..1faded15e5 --- /dev/null +++ b/products/mysql/internal/mysql/conf_update.go @@ -0,0 +1,133 @@ +package mysql + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUDBConfUpdate ucloud udb conf update +func newUDBConfUpdate(ctx *cli.Context) *cobra.Command { + var confID, key, value, file string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewUpdateUDBParamGroupRequest() + cmd := &cobra.Command{ + Use: "update", + Short: "Update parameters of DB's configuration", + Long: "Update parameters of DB's configuration", + Run: func(c *cobra.Command, args []string) { + id, err := strconv.Atoi(ctx.PickResourceID(confID)) + if err != nil { + ctx.HandleError(err) + return + } + req.GroupId = &id + + w := ctx.ProgressWriter() + updated := 0 + if key != "" && value != "" { + req.Key = &key + req.Value = &value + _, err := client.UpdateUDBParamGroup(req) + if err != nil { + ctx.HandleError(err) + } else { + fmt.Fprintf(w, "conf[%s]'sparameter[%s = %s] updated\n", confID, key, value) + updated++ + } + } + if file != "" { + params, err := parseParam(file) + if err != nil { + ctx.HandleError(err) + return + } + for _, p := range params { + req.Key = sdk.String(p.Key) + req.Value = sdk.String(p.Value) + _, err := client.UpdateUDBParamGroup(req) + if err != nil { + ctx.HandleError(fmt.Errorf("conf[%s]'s parameter[%s = %s] failed: %w", confID, p.Key, p.Value, err)) + } else { + fmt.Fprintf(w, "conf[%s]'sparameter[%s = %s] updated\n", confID, p.Key, p.Value) + updated++ + } + fmt.Fprintln(w) + } + } + results := []cli.OpResultRow{} + if updated > 0 { + results = append(results, cli.OpResultRow{ResourceID: strconv.Itoa(id), Action: "update", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&confID, "conf-id", "", "Required. ConfID of configuration to update") + flags.StringVar(&key, "key", "", "Optional. Key of parameter") + flags.StringVar(&value, "value", "", "Optional. Value of parameter") + flags.StringVar(&file, "file", "", "Optional. Path of file in which each parameter occupies one line with format 'key = value'") + + command.SetCompletion(cmd, "conf-id", func() []string { + return getModifiableConfIDList(ctx, "", *req.ProjectId, *req.Region, *req.Zone) + }) + command.SetCompletion(cmd, "file", func() []string { + return common.GetFileList("") + }) + + cmd.MarkFlagRequired("conf-id") + return cmd +} + +type confParam struct { + Key string + Value string +} + +func parseParam(filePath string) ([]confParam, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + params := []confParam{} + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + if len(line) == 0 { + continue + } + strs := strings.SplitN(line, "=", 2) + if len(strs) < 2 { + continue + } + param := confParam{ + Key: strings.TrimSpace(strs[0]), + Value: strings.TrimSpace(strs[1]), + } + params = append(params, param) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return params, nil +} diff --git a/products/mysql/internal/mysql/conf_upload.go b/products/mysql/internal/mysql/conf_upload.go new file mode 100644 index 0000000000..c3f3a76a63 --- /dev/null +++ b/products/mysql/internal/mysql/conf_upload.go @@ -0,0 +1,85 @@ +package mysql + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +var udbSubtypeMap = map[string]int{ + "unknow": 0, + "Shardsvr-MMAPv1": 1, + "Shardsvr-WiredTiger": 2, + "Configsvr-MMAPv1": 3, + "Configsvr-WiredTiger": 4, + "Mongos": 5, + "Mysql": 10, + "Postgresql": 20, +} + +var subtypeList = []string{"Shardsvr-MMAPv1", "Shardsvr-WiredTiger", "Configsvr-MMAPv1", "Configsvr-WiredTiger", "Mongos", "Mysql", "Postgresql"} + +// newUDBConfUpload ucloud udb conf upload +func newUDBConfUpload(ctx *cli.Context) *cobra.Command { + var file string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewUploadUDBParamGroupRequest() + cmd := &cobra.Command{ + Use: "upload", + Short: "Create configuration file by uploading local DB configuration file", + Long: "Create configuration file by uploading local DB configuration file", + Run: func(c *cobra.Command, args []string) { + content, err := cli.ReadFile(file) + if err != nil { + ctx.HandleError(err) + return + } + if l := len(*req.GroupName); l < 6 || l > 63 { + ctx.HandleError(fmt.Errorf("length of name shoud be between 6 and 63")) + return + } + req.Content = sdk.String(base64.StdEncoding.EncodeToString([]byte(content))) + req.ParamGroupTypeId = sdk.Int(10) + resp, err := client.UploadUDBParamGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "conf[%d] uploaded\n", resp.GroupId) + ctx.EmitResult(cli.OpResultRow{ResourceID: strconv.Itoa(resp.GroupId), Action: "upload", Status: "Uploaded"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&file, "conf-file", "", "Required. Path of local configuration file") + req.DBTypeId = flags.String("db-version", "", fmt.Sprintf("Required. Version of DB. Accept values:%s", strings.Join(dbVersionList, ", "))) + req.GroupName = flags.String("name", "", "Required. Name of configuration. It's length should be between 6 and 63") + req.Description = flags.String("description", " ", "Optional. Description of the configuration to clone") + // flags.StringVar(&subtype, "db-type", "", fmt.Sprintf("Optional. DB type. Accept values: %s", strings.Join(subtypeList, ", "))) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("conf-file") + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("db-version") + // cmd.MarkFlagRequired("db-type") + + command.SetFlagValues(cmd, "db-version", dbVersionList...) + // command.SetFlagValues(cmd, "db-type", subtypeList...) + command.SetCompletion(cmd, "conf-file", func() []string { + return common.GetFileList("") + }) + return cmd +} diff --git a/products/mysql/internal/mysql/create.go b/products/mysql/internal/mysql/create.go new file mode 100644 index 0000000000..a0092e8add --- /dev/null +++ b/products/mysql/internal/mysql/create.go @@ -0,0 +1,349 @@ +package mysql + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +var dbStorageClassList = []string{"CLOUD_RSSD"} +var dbSpecClassList = []string{"O", "O2"} +var dbMachineTypeList = []string{ + "o.mysql2m.small", // 1C2G + "o.mysql2m.medium", // 2C4G + "o.mysql2m.xlarge", // 4C8G + "o.mysql2m.2xlarge", // 8C16G + "o.mysql2m.4xlarge", // 16C32G + "o.mysql2m.8xlarge", // 32C64G + "o.mysql2m.12xlarge", // 48C96G + "o.mysql2m.16xlarge", // 64C128G + "o.mysql4m.medium", // 2C8G + "o.mysql4m.xlarge", // 4C16G + "o.mysql4m.2xlarge", // 8C32G + "o.mysql4m.4xlarge", // 16C64G + "o.mysql4m.8xlarge", // 32C128G + "o.mysql4m.16xlarge", // 64C256G + "o.mysql8m.medium", // 2C16G + "o.mysql8m.xlarge", // 4C32G + "o.mysql8m.2xlarge", // 8C64G + "o.mysql8m.4xlarge", // 16C128G + "o.mysql8m.8xlarge", // 32C256G + "o.mysql8m.16xlarge", // 64C512G +} + +// getDefaultParamGroupID 通过 ListUDBParamTemplate 获取指定 DB 版本的默认配置模板 ID +// ListUDBParamTemplate请求体 不传TemplateType即为获取默认参数模板 +func getDefaultParamGroupID(ctx *cli.Context, dbVersion, project, region, zone string) (int, error) { + params := map[string]interface{}{ + "Action": "ListUDBParamTemplate", + "Region": region, + "Zone": zone, + "ProjectId": project, + "DBVersion": dbVersion, + } + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + return 0, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return 0, fmt.Errorf("call ListUDBParamTemplate: %w", err) + } + dataSet, ok := resp.GetPayload()["DataSet"].([]interface{}) + if !ok || len(dataSet) == 0 { + return 0, fmt.Errorf("no param template found for version %s in %s/%s", dbVersion, region, zone) + } + // 取第一个默认模板 + m, _ := dataSet[0].(map[string]interface{}) + id, _ := m["Id"].(float64) + return int(id), nil +} + +// listParamTemplates 通过 ListUDBParamTemplate 获取指定版本的参数模板列表,用于自动补全 +func listParamTemplates(ctx *cli.Context, dbVersion, project, region, zone string) []string { + params := map[string]interface{}{ + "Action": "ListUDBParamTemplate", + "Region": region, + "Zone": zone, + "ProjectId": project, + "DBVersion": dbVersion, + } + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + return nil + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil + } + dataSet, ok := resp.GetPayload()["DataSet"].([]interface{}) + if !ok { + return nil + } + var list []string + for _, item := range dataSet { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + id, _ := m["Id"].(float64) + name, _ := m["Name"].(string) + list = append(list, fmt.Sprintf("%d/%s", int(id), name)) + } + return list +} + +// newCreate ucloud mysql create +func newCreate(ctx *cli.Context) *cobra.Command { + var confID string + var backupID int + var async bool + var labels []string + var name, password, version, machineType, storageClass, specClass string + var port, diskSpace int + var chargeType string + var quantity int + var mode, vpcID, subnetID, backupZone string + var backupCount, backupTime, backupDuration int + var disableSemisync bool + var tag, dbSubVersion, alarmTemplateID, backupURL string + var caseSensitivity, semisyncFlag int + var couponID string + var common request.CommonBase + + cmd := &cobra.Command{ + Use: "create", + Short: "Create MySQL instance on UCloud platform", + Long: "Create MySQL instance on UCloud platform", + Run: func(c *cobra.Command, args []string) { + region := common.GetRegion() + zone := common.GetZone() + projectID := common.GetProjectId() + if len(name) < 6 { + ctx.HandleError(fmt.Errorf("name must be at least 6 characters")) + return + } + if diskSpace < 20 || diskSpace > 32000 { + ctx.HandleError(fmt.Errorf("disk-size-gb must be between 20 and 32000")) + return + } + + // ParamGroupId: 用户传了就用,没传则自动获取默认模板 + var paramGroupID int + if c.Flags().Changed("param-group-id") { + confID = ctx.PickResourceID(confID) + id, err := strconv.Atoi(confID) + if err != nil { + ctx.HandleError(fmt.Errorf("invalid param-group-id: %w", err)) + return + } + paramGroupID = id + } else { + id, err := getDefaultParamGroupID(ctx, version, projectID, region, zone) + if err != nil { + ctx.HandleError(err) + return + } + paramGroupID = id + } + + params := map[string]interface{}{ + "Action": "CreateUDBMySQLInstance", + "Region": region, + "Zone": zone, + "Name": name, + "AdminPassword": password, + "DBTypeId": version, + "Port": port, + "DiskSpace": diskSpace, + "ParamGroupId": paramGroupID, + "MachineType": machineType, + "StorageClass": storageClass, + "SpecificationClass": specClass, + "ChargeType": chargeType, + "Quantity": quantity, + "InstanceMode": mode, + "BackupCount": backupCount, + "BackupTime": backupTime, + "BackupDuration": backupDuration, + "DisableSemisync": disableSemisync, + "SemisyncFlag": semisyncFlag, + } + if projectID != "" { + params["ProjectId"] = projectID + } + + // 以下为可选参数,仅在用户显式指定时下发 + if c.Flags().Changed("vpc-id") { + params["VPCId"] = vpcID + } + if c.Flags().Changed("subnet-id") { + params["SubnetId"] = subnetID + } + if c.Flags().Changed("backup-zone") { + params["BackupZone"] = backupZone + } + if c.Flags().Changed("backup-id") { + params["BackupId"] = backupID + } + if c.Flags().Changed("tag") { + params["Tag"] = tag + } + if c.Flags().Changed("db-sub-version") { + params["DBSubVersion"] = dbSubVersion + } + if c.Flags().Changed("case-sensitivity") { + params["CaseSensitivityParam"] = caseSensitivity + } + if c.Flags().Changed("alarm-template-id") { + params["AlarmTemplateId"] = alarmTemplateID + } + if c.Flags().Changed("backup-url") { + params["BackupURL"] = backupURL + } + if c.Flags().Changed("coupon-id") { + params["CouponId"] = couponID + } + + idx := 0 + for _, l := range labels { + parts := strings.SplitN(l, "=", 2) + if len(parts) == 2 { + params[fmt.Sprintf("Labels.%d.Key", idx)] = parts[0] + params[fmt.Sprintf("Labels.%d.Value", idx)] = parts[1] + idx++ + } + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(err) + return + } + + dbID, _ := resp.GetPayload()["DBId"].(string) + if dbID == "" { + ctx.HandleError(fmt.Errorf("empty DBId in response")) + return + } + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "udb[%s] is initializing\n", dbID) + } else { + text := fmt.Sprintf("udb[%s] is initializing", dbID) + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(dbID, text, []string{UDB_RUNNING, UDB_FAIL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: dbID, Action: "create", Status: "Initializing"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + // Required flags + flags.StringVar(&name, "name", "", "Required. Instance name, at least 6 characters") + flags.StringVar(&password, "password", "", "Required. Admin password") + flags.StringVar(&version, "version", "", "Required. DB version. Options: mysql-5.7, mysql-8.0, mysql-8.4, percona-5.7") + flags.StringVar(&machineType, "machine-type", "", "Required. Machine type ID, e.g. o.mysql2m.xlarge for 4C8G. See 'ucloud mysql db list-machine-type'") + + // Optional flags + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + flags.IntVar(&port, "port", 3306, "Optional. Port, default 3306") + flags.IntVar(&diskSpace, "disk-size-gb", 20, "Optional. Disk size (GiB), 20-32000, default 20") + flags.StringVar(&storageClass, "storage-class", "CLOUD_RSSD", "Optional. Storage class: CLOUD_RSSD") + flags.StringVar(&specClass, "spec-class", "O", "Optional. Spec class: O(NVMe) / O2") + + flags.StringVar(&confID, "param-group-id", "", "Optional. Param group ID. Auto-fetched if omitted. See 'ucloud mysql conf list'") + flags.StringVar(&chargeType, "charge-type", "Month", "Optional. Year / Month / Dynamic") + flags.IntVar(&quantity, "quantity", 1, "Optional. Purchase duration") + flags.IntVar(&backupID, "backup-id", -1, "Optional. Restore from backup ID") + flags.StringVar(&mode, "mode", "HA", "Optional. Normal / HA") + flags.StringVar(&vpcID, "vpc-id", "", "Optional. VPC ID. See 'ucloud vpc list'") + flags.StringVar(&subnetID, "subnet-id", "", "Optional. Subnet ID. See 'ucloud subnet list'") + flags.StringVar(&backupZone, "backup-zone", "", "Optional. Backup zone for cross-AZ HA") + flags.IntVar(&backupCount, "backup-count", 7, "Optional. Weekly backup count, default 7") + flags.IntVar(&backupTime, "backup-time", 1, "Optional. Backup start hour (0-23), default 1") + flags.IntVar(&backupDuration, "backup-duration", 24, "Optional. Backup interval hours, default 24") + flags.BoolVar(&disableSemisync, "disable-semisync", false, "Optional. Enable async HA") + flags.StringVar(&tag, "tag", "", "Optional. Business group name") + flags.StringVar(&dbSubVersion, "db-sub-version", "", "Optional. MySQL minor version") + flags.IntVar(&caseSensitivity, "case-sensitivity", -1, "Optional. 0=case-sensitive, 1=insensitive (MySQL 8.0 only)") + flags.StringVar(&alarmTemplateID, "alarm-template-id", "", "Optional. Alarm template ID") + flags.StringVar(&backupURL, "backup-url", "", "Optional. US3 backup download URL") + flags.IntVar(&semisyncFlag, "semisync-flag", 0, "Optional. 1=enable semi-sync, 2=disable, 0=default(enable)") + flags.StringSliceVar(&labels, "label", nil, "Optional. Resource label, format: key=value, repeatable") + flags.StringVar(&couponID, "coupon-id", "", "Optional. Coupon ID") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for creation to finish") + + command.SetFlagValues(cmd, "version", dbVersionList...) + command.SetFlagValues(cmd, "storage-class", dbStorageClassList...) + command.SetFlagValues(cmd, "spec-class", dbSpecClassList...) + command.SetFlagValues(cmd, "charge-type", "Month", "Dynamic", "Year") + command.SetFlagValues(cmd, "mode", "Normal", "HA") + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, common.GetProjectId(), common.GetRegion()) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, vpcID, common.GetProjectId(), common.GetRegion()) + }) + command.SetCompletion(cmd, "param-group-id", func() []string { + return listParamTemplates(ctx, version, common.GetProjectId(), common.GetRegion(), common.GetZone()) + }) + command.SetFlagValues(cmd, "machine-type", dbMachineTypeList...) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("version") + cmd.MarkFlagRequired("machine-type") + + // 自定义 usage,突出必填参数 + requiredFlags := []string{"name", "password", "version", "machine-type"} + cmd.SetUsageFunc(func(c *cobra.Command) error { + w := c.OutOrStderr() + fmt.Fprintln(w, "Usage:") + fmt.Fprintf(w, " %s [flags]\n\n", c.CommandPath()) + fmt.Fprintln(w, "★ Required flags (must be provided):") + for _, name := range requiredFlags { + f := c.Flags().Lookup(name) + if f != nil { + fmt.Fprintf(w, " --%-20s %s\n", f.Name, f.Usage) + } + } + fmt.Fprintln(w, "\nOptional flags:") + c.Flags().VisitAll(func(f *pflag.Flag) { + for _, req := range requiredFlags { + if f.Name == req { + return + } + } + defVal := "" + if f.DefValue != "" && f.DefValue != "[]" { + defVal = fmt.Sprintf(" (default %s)", f.DefValue) + } + fmt.Fprintf(w, " --%-20s %s%s\n", f.Name, f.Usage, defVal) + }) + return nil + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/create_slave.go b/products/mysql/internal/mysql/create_slave.go new file mode 100644 index 0000000000..918f47afcd --- /dev/null +++ b/products/mysql/internal/mysql/create_slave.go @@ -0,0 +1,77 @@ +package mysql + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +var dbDiskTypeList = []string{"normal", "sata_ssd", "pcie_ssd"} + +// newCreateSlave ucloud udb create-slave +func newCreateSlave(ctx *cli.Context) *cobra.Command { + var diskType string + var async bool + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewCreateUDBSlaveRequest() + cmd := &cobra.Command{ + Use: "create-slave", + Short: "Create slave database", + Long: "Create slave database", + Run: func(c *cobra.Command, args []string) { + *req.SrcId = ctx.PickResourceID(*req.SrcId) + switch diskType { + case "normal": + req.UseSSD = sdk.Bool(false) + case "sata_ssd": + req.UseSSD = sdk.Bool(true) + req.SSDType = sdk.String("SATA") + case "pcie_ssd": + req.UseSSD = sdk.Bool(true) + req.SSDType = sdk.String("PCI-E") + } + *req.MemoryLimit *= 1000 + resp, err := client.CreateUDBSlave(req) + if err != nil { + ctx.HandleError(err) + return + } + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "udb[%s] is initializing\n", resp.DBId) + } else { + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(resp.DBId, fmt.Sprintf("udb[%s] is initializing", resp.DBId), []string{UDB_RUNNING, UDB_FAIL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.DBId, Action: "create-slave", Status: "Initializing"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.SrcId = flags.String("master-udb-id", "", "Required. Resource ID of master UDB instance") + req.Name = flags.String("name", "", "Required. Name of the slave DB to create") + req.Port = flags.Int("port", 3306, "Optional. Port of the slave db service") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.StringVar(&diskType, "disk-type", "Normal", fmt.Sprintf("Optional. Setting this flag means using SSD disk. Accept values: %s", strings.Join(dbDiskTypeList, ", "))) + req.MemoryLimit = flags.Int("memory-size-gb", 1, "Optional. Memory size of udb instance. From 1 to 128. Unit GB") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for the long-running operation to finish") + req.IsLock = flags.Bool("is-lock", false, "Optional. Lock master DB or not") + + cmd.MarkFlagRequired("master-udb-id") + cmd.MarkFlagRequired("name") + + command.SetFlagValues(cmd, "disk-type", dbDiskTypeList...) + command.SetCompletion(cmd, "master-udb-id", func() []string { + return getUDBIDList(ctx, nil, "", *req.ProjectId, *req.Region, *req.Zone) + }) + return cmd +} diff --git a/products/mysql/internal/mysql/db.go b/products/mysql/internal/mysql/db.go new file mode 100644 index 0000000000..16443545fb --- /dev/null +++ b/products/mysql/internal/mysql/db.go @@ -0,0 +1,32 @@ +package mysql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newMysqlDB ucloud mysql db +func newMysqlDB(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "db", + Short: "Manange MySQL instances", + Long: "Manange MySQL instances", + } + + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newStart(ctx)) + cmd.AddCommand(newStop(ctx)) + cmd.AddCommand(newRestart(ctx)) + cmd.AddCommand(newResize(ctx)) + cmd.AddCommand(newRestore(ctx)) + cmd.AddCommand(newResetPassword(ctx)) + cmd.AddCommand(newCreateSlave(ctx)) + cmd.AddCommand(newPromoteSlave(ctx)) + cmd.AddCommand(newListMachineType(ctx)) + // cmd.AddCommand(newPromoteToHA(ctx)) + + return cmd +} diff --git a/products/mysql/internal/mysql/delete.go b/products/mysql/internal/mysql/delete.go new file mode 100644 index 0000000000..ff95d608b5 --- /dev/null +++ b/products/mysql/internal/mysql/delete.go @@ -0,0 +1,77 @@ +package mysql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud udb delete +func newDelete(ctx *cli.Context) *cobra.Command { + var idNames []string + var yes bool + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDeleteUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete MySQL instances by udb-id", + Long: "Delete MySQL instances by udb-id", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the udb(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + any, err := describeUdbByID(ctx)(id, nil) + if err != nil { + ctx.HandleError(err) + continue + } + req.DBId = &id + ins, ok := any.(*udb.UDBInstanceSet) + if ok && ins.State == UDB_RUNNING { + stopReq := client.NewStopUDBInstanceRequest() + stopReq.ProjectId = req.ProjectId + stopReq.Region = req.Region + stopReq.Zone = req.Zone + stopReq.DBId = req.DBId + stopUdbIns(ctx, stopReq, false, w) + } + _, err = client.DeleteUDBInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "udb[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to delete") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.") + + cmd.MarkFlagRequired("udb-id") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "", *req.ProjectId, *req.Region, *req.Zone) + }) + return cmd +} diff --git a/products/mysql/internal/mysql/list.go b/products/mysql/internal/mysql/list.go new file mode 100644 index 0000000000..7f11c42ec5 --- /dev/null +++ b/products/mysql/internal/mysql/list.go @@ -0,0 +1,86 @@ +package mysql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList ucloud udb list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List MySQL instances", + Long: "List MySQL instances", + Run: func(c *cobra.Command, args []string) { + if *req.DBId != "" { + *req.DBId = ctx.PickResourceID(*req.DBId) + } + resp, err := client.DescribeUDBInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []UDBMysqlRow{} + for _, ins := range resp.DataSet { + row := UDBMysqlRow{} + row.Name = ins.Name + row.Zone = ins.Zone + row.Role = ins.Role + row.ResourceID = ins.DBId + row.Group = ins.Tag + row.VPC = ins.VPCId + row.Subnet = ins.SubnetId + row.IP = ins.VirtualIP + row.Mode = ins.InstanceMode + row.DiskType = ins.InstanceType + row.Status = ins.State + row.Config = fmt.Sprintf("%s|%dG|%dG", ins.DBTypeId, ins.MemoryLimit/1000, ins.DiskSpace) + list = append(list, row) + for _, slave := range ins.DataSet { + row := UDBMysqlRow{} + row.Name = slave.Name + row.Zone = slave.Zone + row.Role = fmt.Sprintf("⮑ %s", slave.Role) + row.ResourceID = slave.DBId + row.Group = slave.Tag + row.VPC = slave.VPCId + row.Subnet = slave.SubnetId + row.IP = slave.VirtualIP + row.Mode = slave.InstanceMode + row.DiskType = slave.InstanceType + row.Config = fmt.Sprintf("%s|%dG|%dG", slave.DBTypeId, slave.MemoryLimit/1000, slave.DiskSpace) + row.Status = slave.State + list = append(list, row) + } + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.DBId = flags.String("udb-id", "", "Optional. List the specified mysql") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindLimit(cmd, req) + ctx.BindOffset(cmd, req) + req.IncludeSlaves = flags.Bool("include-slaves", false, "Optional. When specifying the udb-id, whether to display its slaves together. Accept values:true, false") + req.ClassType = sdk.String("sql") + + command.SetFlagValues(cmd, "include-slaves", "true", "false") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "sql", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/list_machine_type.go b/products/mysql/internal/mysql/list_machine_type.go new file mode 100644 index 0000000000..bd6915553e --- /dev/null +++ b/products/mysql/internal/mysql/list_machine_type.go @@ -0,0 +1,59 @@ +package mysql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListMachineType ucloud mysql db list-machine-type +func newListMachineType(ctx *cli.Context) *cobra.Command { + var mode string + var common request.CommonBase + cmd := &cobra.Command{ + Use: "list-machine-type", + Short: "List available MySQL machine types", + Long: "List available MySQL machine types via ListUDBMachineType API", + Run: func(c *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewListUDBMachineTypeRequest() + req.Region = sdk.String(common.GetRegion()) + req.Zone = sdk.String(common.GetZone()) + req.ProjectId = sdk.String(common.GetProjectId()) + if mode != "" { + req.InstanceMode = &mode + } + resp, err := client.ListUDBMachineType(req) + if err != nil { + ctx.HandleError(err) + return + } + var rows []MachineTypeRow + for _, mt := range resp.DataSet { + rows = append(rows, MachineTypeRow{ + ID: mt.ID, + Description: mt.Description, + Cpu: mt.Cpu, + Memory: mt.Memory, + Group: mt.Group, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + flags.StringVar(&mode, "mode", "", "Optional. Instance mode: Normal / HA") + command.SetFlagValues(cmd, "mode", "Normal", "HA") + + return cmd +} diff --git a/products/mysql/internal/mysql/logs.go b/products/mysql/internal/mysql/logs.go new file mode 100644 index 0000000000..e7f7d87fd9 --- /dev/null +++ b/products/mysql/internal/mysql/logs.go @@ -0,0 +1,23 @@ +package mysql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUDBLog ucloud udb log +func newUDBLog(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "logs", + Short: "List and manipulate logs of MySQL instance", + Long: "List and manipulate logs of MySQL instance", + } + + cmd.AddCommand(newUDBLogArchiveCreate(ctx)) + cmd.AddCommand(newUDBLogArchiveList(ctx)) + cmd.AddCommand(newUDBLogArchiveGetDownloadURL(ctx)) + cmd.AddCommand(newUDBLogArchiveDelete(ctx)) + + return cmd +} diff --git a/products/mysql/internal/mysql/logs_archive.go b/products/mysql/internal/mysql/logs_archive.go new file mode 100644 index 0000000000..42d949e0f4 --- /dev/null +++ b/products/mysql/internal/mysql/logs_archive.go @@ -0,0 +1,108 @@ +package mysql + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUDBLogArchiveCreate ucloud udb log archive create +func newUDBLogArchiveCreate(ctx *cli.Context) *cobra.Command { + var udbID string + var name, logType, beginTime, endTime string + var commonBase request.CommonBase + cmd := &cobra.Command{ + Use: "archive", + Short: "Archive the log of mysql as a compressed file", + Long: "Archive the log of mysql as a compressed file", + Example: "ucloud mysql logs archive --name test.cli2 --udb-id udb-xxx/test.cli1 --log-type slow_query --begin-time 2019-02-23/15:30:00 --end-time 2019-02-24/15:31:00", + Run: func(c *cobra.Command, args []string) { + region := commonBase.GetRegion() + zone := commonBase.GetZone() + project := commonBase.GetProjectId() + udbID = ctx.PickResourceID(udbID) + client := cli.NewServiceClient(ctx, udb.NewClient) + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + if logType == "slow_query" { + if beginTime == "" || endTime == "" { + ctx.HandleError(fmt.Errorf("both begin-time and end-time can not be empty")) + return + } + bt, err := time.Parse(common.DateTimeLayout, beginTime) + if err != nil { + ctx.HandleError(err) + return + } + et, err := time.Parse(common.DateTimeLayout, endTime) + if err != nil { + ctx.HandleError(err) + return + } + + req := client.NewBackupUDBInstanceSlowLogRequest() + req.BeginTime = sdk.Int(int(bt.Unix())) + req.EndTime = sdk.Int(int(et.Unix())) + req.DBId = &udbID + req.BackupName = &name + req.Region = ®ion + req.ProjectId = &project + + _, err = client.BackupUDBInstanceSlowLog(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(w, "mysql log archive[%s] created\n", name) + results = append(results, cli.OpResultRow{ResourceID: name, Action: "archive", Status: "Created"}) + } else if logType == "error" { + req := client.NewBackupUDBInstanceErrorLogRequest() + req.DBId = &udbID + req.BackupName = &name + req.Region = ®ion + req.Zone = &zone + req.ProjectId = &project + + _, err := client.BackupUDBInstanceErrorLog(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(w, "mysql log archive[%s] created\n", name) + results = append(results, cli.OpResultRow{ResourceID: name, Action: "archive", Status: "Created"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&udbID, "udb-id", "", "Required. Resource ID of UDB instance which we fetch logs from") + flags.StringVar(&name, "name", "", "Required. Name of compressed file") + flags.StringVar(&logType, "log-type", "", "Required. Type of log to package. Accept values: slow_query, error") + flags.StringVar(&beginTime, "begin-time", "", "Optional. Required when log-type is slow. For example 2019-01-02/15:04:05") + flags.StringVar(&endTime, "end-time", "", "Optional. Required when log-type is slow. For example 2019-01-02/15:04:05") + ctx.BindRegion(cmd, &commonBase) + ctx.BindZone(cmd, &commonBase) + ctx.BindProjectID(cmd, &commonBase) + + cmd.MarkFlagRequired("udb-id") + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("log-type") + + command.SetFlagValues(cmd, "log-type", "slow_query", "error") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "sql", commonBase.GetProjectId(), commonBase.GetRegion(), commonBase.GetZone()) + }) + return cmd +} diff --git a/products/mysql/internal/mysql/logs_delete.go b/products/mysql/internal/mysql/logs_delete.go new file mode 100644 index 0000000000..18d8382bc1 --- /dev/null +++ b/products/mysql/internal/mysql/logs_delete.go @@ -0,0 +1,52 @@ +package mysql + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUDBLogArchiveDelete ucloud udb log archive delete +func newUDBLogArchiveDelete(ctx *cli.Context) *cobra.Command { + var ids []int + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDeleteUDBLogPackageRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete log archives(log files)", + Long: "Delete log archives(log files)", + Example: "ucloud mysql logs delete --archive-id 35025", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range ids { + req.BackupId = sdk.Int(id) + _, err := client.DeleteUDBLogPackage(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "archive[%d] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: strconv.Itoa(id), Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.IntSliceVar(&ids, "archive-id", nil, "Optional. ArchiveID of log archives to delete") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("archive-id") + + return cmd +} diff --git a/products/mysql/internal/mysql/logs_download.go b/products/mysql/internal/mysql/logs_download.go new file mode 100644 index 0000000000..e5b370b9ce --- /dev/null +++ b/products/mysql/internal/mysql/logs_download.go @@ -0,0 +1,50 @@ +package mysql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUDBLogArchiveGetDownloadURL ucloud udb log archive get-download-url +func newUDBLogArchiveGetDownloadURL(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBBinlogBackupURLRequest() + cmd := &cobra.Command{ + Use: "download", + Short: "Display url of an archive(log file)", + Long: "Display url of an archive(log file)", + Example: "ucloud mysql logs download --udb-id udb-urixxx/test.cli1 --archive-id 35044", + Run: func(c *cobra.Command, args []string) { + *req.DBId = ctx.PickResourceID(*req.DBId) + resp, err := client.DescribeUDBBinlogBackupURL(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintln(ctx.Out(), resp.BackupPath) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.BackupId = flags.Int("archive-id", 0, "Required. ArchiveID of archive to download") + req.DBId = flags.String("udb-id", "", "Required. Resource ID of UDB which the archive belongs to") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("archive-id") + cmd.MarkFlagRequired("udb-id") + + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "sql", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/logs_list.go b/products/mysql/internal/mysql/logs_list.go new file mode 100644 index 0000000000..974353277d --- /dev/null +++ b/products/mysql/internal/mysql/logs_list.go @@ -0,0 +1,118 @@ +package mysql + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type udbArchiveRow struct { + ArchiveID int + Name string + LogType string + DB string + Size string + Status string + CreateTime string +} + +// newUDBLogArchiveList ucloud udb log archive list +func newUDBLogArchiveList(ctx *cli.Context) *cobra.Command { + var beginTime, endTime string + logTypes := []string{} + logTypeMap := map[string]int{ + "binlog": 2, + "slow_query": 3, + "error": 4, + } + rLogTypeMap := map[int]string{ + 2: "binlog", + 3: "slow_query", + 4: "error", + } + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBLogPackageRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List mysql log archives(log files)", + Long: "List mysql log archives(log files)", + Run: func(c *cobra.Command, args []string) { + if beginTime != "" { + bt, err := time.Parse(common.DateTimeLayout, beginTime) + if err != nil { + ctx.HandleError(err) + return + } + req.BeginTime = sdk.Int(int(bt.Unix())) + } + if endTime != "" { + et, err := time.Parse(common.DateTimeLayout, endTime) + if err != nil { + ctx.HandleError(err) + return + } + req.EndTime = sdk.Int(int(et.Unix())) + } + + if *req.DBId != "" { + *req.DBId = ctx.PickResourceID(*req.DBId) + } + + for _, s := range logTypes { + if v, ok := logTypeMap[s]; ok { + req.Types = append(req.Types, v) + } else { + ctx.HandleError(fmt.Errorf("log-type should be one of 'binlog', 'slow_query' or 'error'")) + } + } + + resp, err := client.DescribeUDBLogPackage(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []udbArchiveRow{} + for _, ins := range resp.DataSet { + row := udbArchiveRow{ + ArchiveID: ins.BackupId, + Name: ins.BackupName, + LogType: rLogTypeMap[ins.BackupType], + DB: fmt.Sprintf("%s|%s", ins.DBId, ins.DBName), + Size: fmt.Sprintf("%dB", ins.BackupSize), + Status: ins.State, + CreateTime: common.FormatDateTime(ins.BackupTime), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&logTypes, "log-type", nil, "Optional. Type of log. Accept Values: binlog, slow_query and error") + req.DBId = flags.String("udb-id", "", "Optional. Resource ID of UDB instance which the listed logs belong to") + flags.StringVar(&beginTime, "begin-time", "", "Optional. For example 2019-01-02/15:04:05") + flags.StringVar(&endTime, "end-time", "", "Optional. For example 2019-01-02/15:04:05") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindLimit(cmd, req) + ctx.BindOffset(cmd, req) + + command.SetFlagValues(cmd, "log-type", "binlog", "slow_query", "error") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "sql", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/poll.go b/products/mysql/internal/mysql/poll.go new file mode 100644 index 0000000000..d34ce578e5 --- /dev/null +++ b/products/mysql/internal/mysql/poll.go @@ -0,0 +1,52 @@ +package mysql + +import ( + "fmt" + "io" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// stopUdbIns stops the instance and narrates progress to out (the caller passes +// ctx.ProgressWriter(): stdout in table mode, stderr in json/yaml). Returns the +// stop error so callers can decide whether to record a structured result. +func stopUdbIns(ctx *cli.Context, req *udb.StopUDBInstanceRequest, async bool, out io.Writer) error { + client := cli.NewServiceClient(ctx, udb.NewClient) + _, err := client.StopUDBInstance(req) + if err != nil { + ctx.HandleError(err) + return err + } + text := fmt.Sprintf("udb[%s] is stopping", *req.DBId) + if async { + fmt.Fprintln(out, text) + } else { + ctx.PollerTo(out, describeUdbByID(ctx)).Spoll(*req.DBId, text, []string{UDB_SHUTOFF, UDB_FAIL}) + } + return nil +} + +// describeUdbByID returns the poller's describe func, closing over ctx so it +// can build an authed udb client. Mirrors cmd/mysql.go's describeUdbByID. +func describeUdbByID(ctx *cli.Context) func(udbID string, commonBase *request.CommonBase) (interface{}, error) { + return func(udbID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBInstanceRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.DBId = sdk.String(udbID) + resp, err := client.DescribeUDBInstance(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) < 1 { + return nil, fmt.Errorf("udb[%s] may not exist", udbID) + } + return &resp.DataSet[0], nil + } +} diff --git a/products/mysql/internal/mysql/promote_slave.go b/products/mysql/internal/mysql/promote_slave.go new file mode 100644 index 0000000000..f00f2df12d --- /dev/null +++ b/products/mysql/internal/mysql/promote_slave.go @@ -0,0 +1,53 @@ +package mysql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPromoteSlave ucloud udb promote-slave +func newPromoteSlave(ctx *cli.Context) *cobra.Command { + var ids []string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewPromoteUDBSlaveRequest() + cmd := &cobra.Command{ + Use: "promote-slave", + Short: "Promote slave db to master", + Long: "Promote slave db to master", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + // loop aborts on first error (legacy); defer still emits partial results + defer func() { ctx.EmitResult(results...) }() + for _, id := range ids { + req.DBId = sdk.String(id) + _, err := client.PromoteUDBSlave(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(w, "udb[%s] was promoted\n", *req.DBId) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "promote-slave", Status: "Promoted"}) + } + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, "udb-id", nil, "Required. Resource ID of slave db to promote") + req.IsForce = flags.Bool("is-force", false, "Optional. Force to promote slave db or not. If the slave db falls behind, the force promote may lose some data") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("udb-id") + + return cmd +} diff --git a/products/mysql/internal/mysql/promote_to_ha.go b/products/mysql/internal/mysql/promote_to_ha.go new file mode 100644 index 0000000000..072e1b5083 --- /dev/null +++ b/products/mysql/internal/mysql/promote_to_ha.go @@ -0,0 +1,84 @@ +package mysql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newPromoteToHA ucloud udb promote-to-ha 低频操作 暂不开放 +// Migrated for parity but NOT mounted (mirrors cmd/mysql.go's commented-out registration). +func newPromoteToHA(ctx *cli.Context) *cobra.Command { + var idNames []string + var common request.CommonBase + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewPromoteUDBInstanceToHARequest() + cmd := &cobra.Command{ + Use: "promote-to-ha", + Short: "Promote db of normal mode to high availability db. ", + Long: "Promote db of normal mode to high availability db", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.DBId = &id + _, err := client.PromoteUDBInstanceToHA(req) + if err != nil { + ctx.HandleError(err) + continue + } + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(id, fmt.Sprintf("udb[%s] is synchronizing data", id), []string{UDB_TOBE_SWITCH, UDB_FAIL}) + any, err := describeUdbByID(ctx)(id, nil) + if err != nil { + ctx.HandleError(fmt.Errorf("udb[%s] promoted failed, please contact technical support; %v", idname, err)) + continue + } + ins, ok := any.(*udb.UDBInstanceSet) + if !ok { + ctx.HandleError(fmt.Errorf("udb[%s] promoted failed, please contact technical support", idname)) + continue + } + if ins.State != UDB_TOBE_SWITCH { + ctx.HandleError(fmt.Errorf("udb[%s] promoted failed, please contact technical support. udb[%s]'s status:%s", idname, idname, ins.State)) + continue + } + switchReq := client.NewSwitchUDBInstanceToHARequest() + switchReq.DBId = &id + switchReq.Region = req.Region + switchReq.ProjectId = req.ProjectId + switchReq.ChargeType = &ins.ChargeType + switchReq.Quantity = sdk.String("0") + // Original read base.ConfigIns.Zone (global default zone); products + // must not import base. This command is migrated for parity but is + // NOT mounted (see newMysqlDB), so it is unreachable. Zone falls back + // to the bound region's CommonBase zone (empty here). See report. + switchReq.Zone = sdk.String(common.GetZone()) + switchResp, err := client.SwitchUDBInstanceToHA(switchReq) + if err != nil { + ctx.HandleError(fmt.Errorf("udb[%s] promoted failed, please contact technical support; %v", idname, err)) + continue + } + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(switchResp.DBId, fmt.Sprintf("udb[%s] is switching to high availability mode", switchResp.DBId), []string{UDB_RUNNING, UDB_FAIL}) + } + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to be promoted as high availability mode") + + cmd.MarkFlagRequired("udb-id") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "", *req.ProjectId, *req.Region, "") + }) + return cmd +} diff --git a/products/mysql/internal/mysql/reset_password.go b/products/mysql/internal/mysql/reset_password.go new file mode 100644 index 0000000000..2e43b3ee69 --- /dev/null +++ b/products/mysql/internal/mysql/reset_password.go @@ -0,0 +1,58 @@ +package mysql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResetPassword ucloud udb reset-password +func newResetPassword(ctx *cli.Context) *cobra.Command { + var idNames []string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewModifyUDBInstancePasswordRequest() + cmd := &cobra.Command{ + Use: "reset-password", + Short: "Reset password of MySQL instances", + Long: "Reset password of MySQL instances", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.DBId = &id + _, err := client.ModifyUDBInstancePassword(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "udb[%s]'s password modified\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "reset-password", Status: "PasswordReset"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to reset password") + req.Password = flags.String("password", "", "Required. New password") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("udb-id") + cmd.MarkFlagRequired("password") + + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/resize.go b/products/mysql/internal/mysql/resize.go new file mode 100644 index 0000000000..03c2b99514 --- /dev/null +++ b/products/mysql/internal/mysql/resize.go @@ -0,0 +1,131 @@ +package mysql + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResize ucloud udb resize +func newResize(ctx *cli.Context) *cobra.Command { + var diskTypes = []string{"normal", "sata_ssd", "pcie_ssd", "normal_volume", "sata_ssd_volume", "pcie_ssd_volume"} + var async, yes bool + var idNames []string + var memory, disk int + var diskType string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewResizeUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "resize", + Short: "Reszie MySQL instances, such as memory size, disk size and disk type", + Long: "Reszie MySQL instances, such as memory size, disk size and disk type", + Run: func(c *cobra.Command, args []string) { + if diskType != "" { + switch diskType { + case "normal": + req.InstanceType = sdk.String("Normal") + case "sata_ssd": + req.InstanceType = sdk.String("SATA_SSD") + case "pcie_ssd": + req.InstanceType = sdk.String("PCIE_SSD") + case "normal_volume": + req.InstanceType = sdk.String("Normal_Volume") + case "sata_ssd_volume": + req.InstanceType = sdk.String("SATA_SSD_Volume") + case "pcie_ssd_volume": + req.InstanceType = sdk.String("PCIE_SSD_Volume") + default: + req.InstanceType = &diskType + } + } + + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.DBId = &id + any, err := describeUdbByID(ctx)(id, nil) + if err != nil { + ctx.HandleError(err) + continue + } + + ins, ok := any.(*udb.UDBInstanceSet) + if !ok { + continue + } + + if memory != 0 { + req.MemoryLimit = sdk.Int(memory * 1000) + } else { + req.MemoryLimit = &ins.MemoryLimit + } + if disk != 0 { + req.DiskSpace = &disk + } else { + req.DiskSpace = &ins.DiskSpace + } + + if ins.State == UDB_RUNNING { + ok, err := ctx.Confirm(yes, fmt.Sprintf("Need to shut down udb[%s] before upgrading, whether to continue?", idname)) + if err != nil { + ctx.HandleError(err) + continue + } + if !ok { + continue + } + stopReq := client.NewStopUDBInstanceRequest() + stopReq.ProjectId = req.ProjectId + stopReq.Region = req.Region + stopReq.Zone = req.Zone + stopReq.DBId = req.DBId + stopUdbIns(ctx, stopReq, false, w) + } + _, err = client.ResizeUDBInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + if async { + fmt.Fprintf(w, "udb[%s] is resizing\n", idname) + } else { + text := fmt.Sprintf("udb[%s] is resizing", idname) + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(*req.DBId, text, []string{UDB_RUNNING, UDB_SHUTOFF, UDB_FAIL, UDB_UPGRADE_FAIL}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "resize", Status: "Resizing"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to restart") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.IntVar(&memory, "memory-size-gb", 0, "Optional. Memory size of udb instance. From 1 to 128. Unit GB") + flags.IntVar(&disk, "disk-size-gb", 0, "Optional. Disk size of udb instance. From 20 to 3000 according to memory size. Unit GB. Step 10GB") + flags.StringVar(&diskType, "disk-type", "", fmt.Sprintf("Optional. Disk type of udb instance. Accept values:%s", strings.Join(diskTypes, ", "))) + req.StartAfterUpgrade = flags.Bool("start-after-upgrade", true, "Optional. Automatic start the UDB instances after upgrade") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation") + + command.SetFlagValues(cmd, "disk-type", diskTypes...) + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "", *req.ProjectId, *req.Region, *req.Zone) + }) + + cmd.MarkFlagRequired("udb-id") + + return cmd +} diff --git a/products/mysql/internal/mysql/restart.go b/products/mysql/internal/mysql/restart.go new file mode 100644 index 0000000000..79ca578014 --- /dev/null +++ b/products/mysql/internal/mysql/restart.go @@ -0,0 +1,61 @@ +package mysql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestart ucloud udb restart +func newRestart(ctx *cli.Context) *cobra.Command { + var async bool + var idNames []string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewRestartUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart MySQL instances by udb-id", + Long: "Restart MySQL instances by udb-id", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.DBId = &id + _, err := client.RestartUDBInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + if async { + fmt.Fprintf(w, "udb[%s] is restarting\n", idname) + } else { + text := fmt.Sprintf("udb[%s] is restarting", idname) + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(*req.DBId, text, []string{UDB_RUNNING, UDB_FAIL}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "restart", Status: "Restarting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to restart") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + + cmd.MarkFlagRequired("udb-id") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "", *req.ProjectId, *req.Region, *req.Zone) + }) + return cmd +} diff --git a/products/mysql/internal/mysql/restore.go b/products/mysql/internal/mysql/restore.go new file mode 100644 index 0000000000..b06886a3b3 --- /dev/null +++ b/products/mysql/internal/mysql/restore.go @@ -0,0 +1,90 @@ +package mysql + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestore ucloud udb restore +func newRestore(ctx *cli.Context) *cobra.Command { + var datetime, diskType string + var async bool + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewCreateUDBInstanceByRecoveryRequest() + cmd := &cobra.Command{ + Use: "restore", + Short: "Create MySQL instance and restore the newly created db to the specified DB at a specified point in time", + Long: "Create MySQL instance and restore the newly created db to the specified DB at a specified point in time", + Run: func(c *cobra.Command, args []string) { + t, err := time.Parse(time.RFC3339, datetime) + if err != nil { + ctx.HandleError(err) + return + } + req.RecoveryTime = sdk.Int(int(t.Unix())) + req.SrcDBId = sdk.String(ctx.PickResourceID(*req.SrcDBId)) + if diskType == "" { + any, err := describeUdbByID(ctx)(*req.SrcDBId, nil) + if err != nil { + ctx.HandleError(err) + return + } + ins, ok := any.(*udb.UDBInstanceSet) + if !ok { + ctx.HandleError(fmt.Errorf("fetch udb[%s] instance", *req.SrcDBId)) + return + } + req.UseSSD = &ins.UseSSD + } else if diskType == "normal" { + req.UseSSD = sdk.Bool(false) + } else if diskType == "ssd" { + req.UseSSD = sdk.Bool(true) + } + resp, err := client.CreateUDBInstanceByRecovery(req) + if err != nil { + ctx.HandleError(err) + return + } + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "udb[%s] is restorting from udb[%s] at time point %s", resp.DBId, *req.SrcDBId, datetime) + } else { + text := fmt.Sprintf("udb[%s] is restorting from udb[%s] at time point %s", resp.DBId, *req.SrcDBId, datetime) + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(resp.DBId, text, []string{UDB_RUNNING, UDB_RECOVER_FAIL, UDB_FAIL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.DBId, Action: "restore", Status: "Restoring"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.Name = flags.String("name", "", "Required. Name of UDB instance to create") + req.SrcDBId = flags.String("src-udb-id", "", "Required. Resource ID of source UDB") + flags.StringVar(&datetime, "restore-to-time", "", "Required. The date and time to restore the DB to. Value must be a time in Universal Coordinated Time (UTC) format.Example: 2019-02-23T23:45:00Z") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.StringVar(&diskType, "disk-type", "", "Optional. Disk type. The default is to be consistent with the source database. Accept values: normal, ssd") + ctx.BindChargeType(cmd, req) + ctx.BindQuantity(cmd, req) + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("src-udb-id") + cmd.MarkFlagRequired("restore-to-time") + + command.SetFlagValues(cmd, "disk-type", "noraml", "ssd") + command.SetCompletion(cmd, "src-udb-id", func() []string { + return getUDBIDList(ctx, nil, "sql", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/internal/mysql/rows.go b/products/mysql/internal/mysql/rows.go new file mode 100644 index 0000000000..7a4c3be224 --- /dev/null +++ b/products/mysql/internal/mysql/rows.go @@ -0,0 +1,27 @@ +package mysql + +// MachineTypeRow 计算规格表格行 +type MachineTypeRow struct { + ID string + Description string + Cpu int + Memory int + Group string +} + +// UDBMysqlRow 表格行 +type UDBMysqlRow struct { + Name string + ResourceID string + Role string + Status string + Config string + Mode string + DiskType string + IP string + Group string + Zone string + VPC string + Subnet string + // CreateTime string +} diff --git a/products/mysql/internal/mysql/start.go b/products/mysql/internal/mysql/start.go new file mode 100644 index 0000000000..4ef2356646 --- /dev/null +++ b/products/mysql/internal/mysql/start.go @@ -0,0 +1,62 @@ +package mysql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStart ucloud udb start +func newStart(ctx *cli.Context) *cobra.Command { + var async bool + var idNames []string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewStartUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "start", + Short: "Start MySQL instances by udb-id", + Long: "Start MySQL instances by udb-id", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.DBId = &id + _, err := client.StartUDBInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + if async { + fmt.Fprintf(w, "udb[%s] is starting\n", idname) + } else { + text := fmt.Sprintf("udb[%s] is starting", idname) + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(*req.DBId, text, []string{UDB_RUNNING, UDB_FAIL}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "start", Status: "Starting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to start") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + + cmd.MarkFlagRequired("udb-id") + + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, []string{UDB_SHUTOFF}, "", *req.ProjectId, *req.Region, *req.Zone) + }) + return cmd +} diff --git a/products/mysql/internal/mysql/status.go b/products/mysql/internal/mysql/status.go new file mode 100644 index 0000000000..73f06a7655 --- /dev/null +++ b/products/mysql/internal/mysql/status.go @@ -0,0 +1,11 @@ +package mysql + +// UDB-domain state constants, product-owned copies (formerly model/status). +const ( + UDB_FAIL = "Fail" + UDB_RUNNING = "Running" + UDB_SHUTOFF = "Shutoff" + UDB_RECOVER_FAIL = "Recover fail" + UDB_UPGRADE_FAIL = "UpgradeFail" + UDB_TOBE_SWITCH = "WaitForSwitch" +) diff --git a/products/mysql/internal/mysql/stop.go b/products/mysql/internal/mysql/stop.go new file mode 100644 index 0000000000..693607790c --- /dev/null +++ b/products/mysql/internal/mysql/stop.go @@ -0,0 +1,56 @@ +package mysql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStop ucloud udb stop +func newStop(ctx *cli.Context) *cobra.Command { + var idNames []string + var async bool + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewStopUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop MySQL instances by udb-id", + Long: "Stop MySQL instances by udb-id", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.DBId = sdk.String(id) + if err := stopUdbIns(ctx, req, async, w); err != nil { + continue + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "stop", Status: "Stopping"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to stop") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + req.ForceToKill = flags.Bool("force", false, "Optional. Stop UDB instances by force or not") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + + cmd.MarkFlagRequired("udb-id") + + command.SetFlagValues(cmd, "force", "true", "false") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, []string{UDB_RUNNING}, "", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/mysql/product.go b/products/mysql/product.go new file mode 100644 index 0000000000..037ca2264a --- /dev/null +++ b/products/mysql/product.go @@ -0,0 +1,20 @@ +package mysql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalmysql "github.com/ucloud/ucloud-cli/products/mysql/internal/mysql" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "mysql", Commands: []string{"mysql"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalmysql.NewCommand(ctx)} +} diff --git a/products/mysql/product.yaml b/products/mysql/product.yaml new file mode 100644 index 0000000000..bb30480aee --- /dev/null +++ b/products/mysql/product.yaml @@ -0,0 +1,6 @@ +name: mysql +owners: + - Episkey-G +commands: + - mysql +enabled: true diff --git a/products/mysql/testdata/cmdtree.golden b/products/mysql/testdata/cmdtree.golden new file mode 100644 index 0000000000..417d0a4243 --- /dev/null +++ b/products/mysql/testdata/cmdtree.golden @@ -0,0 +1,235 @@ +ucloud mysql use=mysql short=Manipulate MySQL on UCloud platform +ucloud mysql backup use=backup short=List and manipulate backups of MySQL instance +ucloud mysql backup create use=create short=Create backups for MySQL instance manually + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default= required=true + flag=zone short= default= required= +ucloud mysql backup delete use=delete short=Delete backups of MySQL instance + flag=backup-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud mysql backup download use=download short=Display download url of backup + flag=backup-id short= default=-1 required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default= required=true + flag=zone short= default= required= +ucloud mysql backup list use=list short=List backups of MySQL instance + flag=backup-id short= default= required= + flag=backup-type short= default= required= + flag=begin-time short= default= required= + flag=db-type short= default= required= + flag=end-time short= default= required= + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default= required= + flag=zone short= default= required= +ucloud mysql conf use=conf short=List and manipulate configuration files of MySQL instances +ucloud mysql conf apply use=apply short=Apply configuration for UDB instances + flag=async short=a default=false required= + flag=conf-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=restart-after-apply short= default=true required= + flag=udb-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud mysql conf clone use=clone short=Create configuration file by cloning existed configuration + flag=db-version short= default= required= + flag=description short= default= required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=src-conf-id short= default= required=true + flag=zone short= default= required= +ucloud mysql conf delete use=delete short=Delete configuration of udb by conf-id + flag=conf-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud mysql conf describe use=describe short=Display details about a configuration file of MySQL instance + flag=conf-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud mysql conf download use=download short=Download UDB configuration + flag=conf-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud mysql conf list use=list short=List configuartion files of MySQL instances + flag=conf-id short= default=0 required= + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud mysql conf update use=update short=Update parameters of DB's configuration + flag=conf-id short= default= required=true + flag=file short= default= required= + flag=key short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=value short= default= required= + flag=zone short= default= required= +ucloud mysql conf upload use=upload short=Create configuration file by uploading local DB configuration file + flag=conf-file short= default= required=true + flag=db-version short= default= required=true + flag=description short= default= required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud mysql db use=db short=Manange MySQL instances +ucloud mysql db create use=create short=Create MySQL instance on UCloud platform + flag=alarm-template-id short= default= required= + flag=async short= default=false required= + flag=backup-count short= default=7 required= + flag=backup-duration short= default=24 required= + flag=backup-id short= default=-1 required= + flag=backup-time short= default=1 required= + flag=backup-url short= default= required= + flag=backup-zone short= default= required= + flag=case-sensitivity short= default=-1 required= + flag=charge-type short= default=Month required= + flag=coupon-id short= default= required= + flag=db-sub-version short= default= required= + flag=disable-semisync short= default=false required= + flag=disk-size-gb short= default=20 required= + flag=label short= default=[] required= + flag=machine-type short= default= required=true + flag=mode short= default=HA required= + flag=name short= default= required=true + flag=param-group-id short= default= required= + flag=password short= default= required=true + flag=port short= default=3306 required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=semisync-flag short= default=0 required= + flag=spec-class short= default=O required= + flag=storage-class short= default=CLOUD_RSSD required= + flag=subnet-id short= default= required= + flag=tag short= default= required= + flag=version short= default= required=true + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud mysql db create-slave use=create-slave short=Create slave database + flag=async short= default=false required= + flag=disk-type short= default=Normal required= + flag=is-lock short= default=false required= + flag=master-udb-id short= default= required=true + flag=memory-size-gb short= default=1 required= + flag=name short= default= required=true + flag=port short= default=3306 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud mysql db delete use=delete short=Delete MySQL instances by udb-id + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud mysql db list use=list short=List MySQL instances + flag=include-slaves short= default=false required= + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default= required= + flag=zone short= default= required= +ucloud mysql db list-machine-type use=list-machine-type short=List available MySQL machine types + flag=mode short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud mysql db promote-slave use=promote-slave short=Promote slave db to master + flag=is-force short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default=[] required=true + flag=zone short= default= required= +ucloud mysql db reset-password use=reset-password short=Reset password of MySQL instances + flag=password short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default=[] required=true + flag=zone short= default= required= +ucloud mysql db resize use=resize short=Reszie MySQL instances, such as memory size, disk size and disk type + flag=async short=a default=false required= + flag=disk-size-gb short= default=0 required= + flag=disk-type short= default= required= + flag=memory-size-gb short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=start-after-upgrade short= default=true required= + flag=udb-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud mysql db restart use=restart short=Restart MySQL instances by udb-id + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default=[] required=true + flag=zone short= default= required= +ucloud mysql db restore use=restore short=Create MySQL instance and restore the newly created db to the specified DB at a specified point in time + flag=async short=a default=false required= + flag=charge-type short= default=Month required= + flag=disk-type short= default= required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=restore-to-time short= default= required=true + flag=src-udb-id short= default= required=true + flag=zone short= default= required= +ucloud mysql db start use=start short=Start MySQL instances by udb-id + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default=[] required=true + flag=zone short= default= required= +ucloud mysql db stop use=stop short=Stop MySQL instances by udb-id + flag=async short=a default=false required= + flag=force short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default=[] required=true + flag=zone short= default= required= +ucloud mysql logs use=logs short=List and manipulate logs of MySQL instance +ucloud mysql logs archive use=archive short=Archive the log of mysql as a compressed file + flag=begin-time short= default= required= + flag=end-time short= default= required= + flag=log-type short= default= required=true + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default= required=true + flag=zone short= default= required= +ucloud mysql logs delete use=delete short=Delete log archives(log files) + flag=archive-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud mysql logs download use=download short=Display url of an archive(log file) + flag=archive-id short= default=0 required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default= required=true + flag=zone short= default= required= +ucloud mysql logs list use=list short=List mysql log archives(log files) + flag=begin-time short= default= required= + flag=end-time short= default= required= + flag=limit short= default=100 required= + flag=log-type short= default=[] required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default= required= + flag=zone short= default= required= diff --git a/products/mysql/testdata/completion.golden b/products/mysql/testdata/completion.golden new file mode 100644 index 0000000000..8ecffae1ec --- /dev/null +++ b/products/mysql/testdata/completion.golden @@ -0,0 +1,131 @@ +ucloud mysql backup create project-id dynamic +ucloud mysql backup create region dynamic +ucloud mysql backup create udb-id dynamic +ucloud mysql backup create zone dynamic +ucloud mysql backup delete project-id dynamic +ucloud mysql backup delete region dynamic +ucloud mysql backup delete zone dynamic +ucloud mysql backup download project-id dynamic +ucloud mysql backup download region dynamic +ucloud mysql backup download udb-id dynamic +ucloud mysql backup download zone dynamic +ucloud mysql backup list backup-type static auto,manual +ucloud mysql backup list db-type static mongodb,mysql,postgresql,sqlserver +ucloud mysql backup list project-id dynamic +ucloud mysql backup list region dynamic +ucloud mysql backup list udb-id dynamic +ucloud mysql backup list zone dynamic +ucloud mysql conf apply conf-id dynamic +ucloud mysql conf apply project-id dynamic +ucloud mysql conf apply region dynamic +ucloud mysql conf apply udb-id dynamic +ucloud mysql conf apply zone dynamic +ucloud mysql conf clone db-version static mysql-5.7,mysql-8.0,mysql-8.4,percona-5.7 +ucloud mysql conf clone project-id dynamic +ucloud mysql conf clone region dynamic +ucloud mysql conf clone src-conf-id dynamic +ucloud mysql conf clone zone dynamic +ucloud mysql conf delete conf-id dynamic +ucloud mysql conf delete project-id dynamic +ucloud mysql conf delete region dynamic +ucloud mysql conf delete zone dynamic +ucloud mysql conf describe conf-id dynamic +ucloud mysql conf describe project-id dynamic +ucloud mysql conf describe region dynamic +ucloud mysql conf describe zone dynamic +ucloud mysql conf download conf-id dynamic +ucloud mysql conf download project-id dynamic +ucloud mysql conf download region dynamic +ucloud mysql conf download zone dynamic +ucloud mysql conf list conf-id dynamic +ucloud mysql conf list project-id dynamic +ucloud mysql conf list region dynamic +ucloud mysql conf list zone dynamic +ucloud mysql conf update conf-id dynamic +ucloud mysql conf update file static +ucloud mysql conf update project-id dynamic +ucloud mysql conf update region dynamic +ucloud mysql conf update zone dynamic +ucloud mysql conf upload conf-file static +ucloud mysql conf upload db-version static mysql-5.7,mysql-8.0,mysql-8.4,percona-5.7 +ucloud mysql conf upload project-id dynamic +ucloud mysql conf upload region dynamic +ucloud mysql conf upload zone dynamic +ucloud mysql db create charge-type static Dynamic,Month,Year +ucloud mysql db create machine-type static o.mysql2m.12xlarge,o.mysql2m.16xlarge,o.mysql2m.2xlarge,o.mysql2m.4xlarge,o.mysql2m.8xlarge,o.mysql2m.medium,o.mysql2m.small,o.mysql2m.xlarge,o.mysql4m.16xlarge,o.mysql4m.2xlarge,o.mysql4m.4xlarge,o.mysql4m.8xlarge,o.mysql4m.medium,o.mysql4m.xlarge,o.mysql8m.16xlarge,o.mysql8m.2xlarge,o.mysql8m.4xlarge,o.mysql8m.8xlarge,o.mysql8m.medium,o.mysql8m.xlarge +ucloud mysql db create mode static HA,Normal +ucloud mysql db create param-group-id dynamic +ucloud mysql db create project-id dynamic +ucloud mysql db create region dynamic +ucloud mysql db create spec-class static O,O2 +ucloud mysql db create storage-class static CLOUD_RSSD +ucloud mysql db create subnet-id dynamic +ucloud mysql db create version static mysql-5.7,mysql-8.0,mysql-8.4,percona-5.7 +ucloud mysql db create vpc-id dynamic +ucloud mysql db create zone dynamic +ucloud mysql db create-slave disk-type static normal,pcie_ssd,sata_ssd +ucloud mysql db create-slave master-udb-id dynamic +ucloud mysql db create-slave project-id dynamic +ucloud mysql db create-slave region dynamic +ucloud mysql db create-slave zone dynamic +ucloud mysql db delete project-id dynamic +ucloud mysql db delete region dynamic +ucloud mysql db delete udb-id dynamic +ucloud mysql db delete zone dynamic +ucloud mysql db list include-slaves static false,true +ucloud mysql db list project-id dynamic +ucloud mysql db list region dynamic +ucloud mysql db list udb-id dynamic +ucloud mysql db list zone dynamic +ucloud mysql db list-machine-type mode static HA,Normal +ucloud mysql db list-machine-type project-id dynamic +ucloud mysql db list-machine-type region dynamic +ucloud mysql db list-machine-type zone dynamic +ucloud mysql db promote-slave project-id dynamic +ucloud mysql db promote-slave region dynamic +ucloud mysql db promote-slave zone dynamic +ucloud mysql db reset-password project-id dynamic +ucloud mysql db reset-password region dynamic +ucloud mysql db reset-password udb-id dynamic +ucloud mysql db reset-password zone dynamic +ucloud mysql db resize disk-type static normal,normal_volume,pcie_ssd,pcie_ssd_volume,sata_ssd,sata_ssd_volume +ucloud mysql db resize project-id dynamic +ucloud mysql db resize region dynamic +ucloud mysql db resize udb-id dynamic +ucloud mysql db resize zone dynamic +ucloud mysql db restart project-id dynamic +ucloud mysql db restart region dynamic +ucloud mysql db restart udb-id dynamic +ucloud mysql db restart zone dynamic +ucloud mysql db restore charge-type static Dynamic,Month,Year +ucloud mysql db restore disk-type static noraml,ssd +ucloud mysql db restore project-id dynamic +ucloud mysql db restore region dynamic +ucloud mysql db restore src-udb-id dynamic +ucloud mysql db restore zone dynamic +ucloud mysql db start project-id dynamic +ucloud mysql db start region dynamic +ucloud mysql db start udb-id dynamic +ucloud mysql db start zone dynamic +ucloud mysql db stop force static false,true +ucloud mysql db stop project-id dynamic +ucloud mysql db stop region dynamic +ucloud mysql db stop udb-id dynamic +ucloud mysql db stop zone dynamic +ucloud mysql logs archive log-type static error,slow_query +ucloud mysql logs archive project-id dynamic +ucloud mysql logs archive region dynamic +ucloud mysql logs archive udb-id dynamic +ucloud mysql logs archive zone dynamic +ucloud mysql logs delete project-id dynamic +ucloud mysql logs delete region dynamic +ucloud mysql logs delete zone dynamic +ucloud mysql logs download project-id dynamic +ucloud mysql logs download region dynamic +ucloud mysql logs download udb-id dynamic +ucloud mysql logs download zone dynamic +ucloud mysql logs list log-type static binlog,error,slow_query +ucloud mysql logs list project-id dynamic +ucloud mysql logs list region dynamic +ucloud mysql logs list udb-id dynamic +ucloud mysql logs list zone dynamic diff --git a/products/nlb/internal/nlb/cmd.go b/products/nlb/internal/nlb/cmd.go new file mode 100644 index 0000000000..06fc8227e2 --- /dev/null +++ b/products/nlb/internal/nlb/cmd.go @@ -0,0 +1,28 @@ +package nlb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand assembles the `nlb` command tree. This aggregator only constructs +// the top-level command and AddCommand's one constructor per verb / sub-group +// (§2.2 file-layout convention): no business logic lives here. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "nlb", + Short: "List and manipulate NLB (Network Load Balancer) instances", + Long: "List and manipulate NLB (Network Load Balancer) instances", + } + + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newUpdate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newListener(ctx)) + cmd.AddCommand(newTarget(ctx)) + + return cmd +} diff --git a/products/nlb/internal/nlb/completion.go b/products/nlb/internal/nlb/completion.go new file mode 100644 index 0000000000..b959c16a5a --- /dev/null +++ b/products/nlb/internal/nlb/completion.go @@ -0,0 +1,157 @@ +package nlb + +import ( + "fmt" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// productName is the single source of truth for the product's command name and +// its resource-id flag (`--nlb-id`). +const productName = "nlb" + +// resourceIDFlag is the NLB instance resource-id flag, named after the product. +const resourceIDFlag = productName + "-id" // "nlb-id" + +// getAllNLB returns every NLB instance in the active region/project, paging +// through the DescribeNetworkLoadBalancers result set. +func getAllNLB(ctx *cli.Context, project, region string) ([]nlbsdk.NetworkLoadBalancer, error) { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDescribeNetworkLoadBalancersRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + + list := []nlbsdk.NetworkLoadBalancer{} + for offset, limit := 0, 100; ; offset += limit { + req.Offset = sdk.Int(offset) + req.Limit = sdk.Int(limit) + resp, err := client.DescribeNetworkLoadBalancers(req) + if err != nil { + return nil, err + } + list = append(list, resp.NLBs...) + if offset+limit >= resp.TotalCount { + break + } + } + return list, nil +} + +// getAllNLBIDNames returns "nlbId/name" completion candidates for --nlb-id. +func getAllNLBIDNames(ctx *cli.Context, project, region string) []string { + list, err := getAllNLB(ctx, project, region) + if err != nil { + return nil + } + idNames := make([]string, 0, len(list)) + for _, n := range list { + idNames = append(idNames, fmt.Sprintf("%s/%s", n.NLBId, n.Name)) + } + return idNames +} + +// getAllListeners returns the listeners of a given NLB instance. +func getAllListeners(ctx *cli.Context, nlbID, project, region string) ([]nlbsdk.Listener, error) { + if nlbID == "" { + return nil, fmt.Errorf("nlb-id can't be empty") + } + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDescribeNLBListenersRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + req.NLBId = sdk.String(cli.PickResourceID(nlbID)) + resp, err := client.DescribeNLBListeners(req) + if err != nil { + return nil, err + } + return resp.Listeners, nil +} + +// getAllListenerIDNames returns "listenerId/name" completion candidates. +func getAllListenerIDNames(ctx *cli.Context, nlbID, project, region string) []string { + listeners, err := getAllListeners(ctx, nlbID, project, region) + if err != nil { + return nil + } + idNames := make([]string, 0, len(listeners)) + for _, l := range listeners { + idNames = append(idNames, fmt.Sprintf("%s/%s", l.ListenerId, l.Name)) + } + return idNames +} + +// getAllTargetIDNames returns the target ids attached to a listener, in +// "targetId/resource" form for --target-id completion. +func getAllTargetIDNames(ctx *cli.Context, nlbID, listenerID, project, region string) []string { + listeners, err := getAllListeners(ctx, nlbID, project, region) + if err != nil { + return nil + } + wantListener := cli.PickResourceID(listenerID) + idNames := []string{} + for _, l := range listeners { + if wantListener != "" && l.ListenerId != wantListener { + continue + } + for _, t := range l.Targets { + label := t.ResourceId + if label == "" { + label = t.ResourceIP + } + idNames = append(idNames, fmt.Sprintf("%s/%s", t.Id, label)) + } + } + return idNames +} + +// getAllVPCIDNames returns "vpcId/name" candidates via the VPC SDK service +// package. Cross-product completion uses the peer SDK directly, never imports +// products/vpc (§8, check-product rule 1). +func getAllVPCIDNames(ctx *cli.Context, project, region string) []string { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + resp, err := client.DescribeVPC(req) + if err != nil { + return nil + } + idNames := make([]string, 0, len(resp.DataSet)) + for _, v := range resp.DataSet { + idNames = append(idNames, fmt.Sprintf("%s/%s", v.VPCId, v.Name)) + } + return idNames +} + +// getAllSubnetIDNames returns "subnetId/name" candidates, optionally scoped to +// a VPC, via the VPC SDK service package. +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + if vpcID != "" { + req.VPCId = sdk.String(cli.PickResourceID(vpcID)) + } + resp, err := client.DescribeSubnet(req) + if err != nil { + return nil + } + idNames := make([]string, 0, len(resp.DataSet)) + for _, s := range resp.DataSet { + idNames = append(idNames, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return idNames +} + +// derefStr safely dereferences a *string bound by a flag. +func derefStr(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/products/nlb/internal/nlb/create.go b/products/nlb/internal/nlb/create.go new file mode 100644 index 0000000000..6811a56565 --- /dev/null +++ b/products/nlb/internal/nlb/create.go @@ -0,0 +1,73 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate implements `nlb create`. +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewCreateNetworkLoadBalancerRequest() + + var couponID string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an NLB instance", + Long: "Create an NLB (Network Load Balancer) instance in the specified VPC and subnet.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId)) + if c.Flags().Changed("coupon-id") { + req.CouponId = &couponID + } + + resp, err := client.CreateNetworkLoadBalancer(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb[%s] created\n", resp.NLBId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.NLBId, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + req.VPCId = flags.String("vpc-id", "", "Required. Resource ID of the VPC the NLB belongs to. See 'ucloud vpc list'.") + req.SubnetId = flags.String("subnet-id", "", "Required. Resource ID of the subnet the NLB belongs to. See 'ucloud subnet list'.") + req.Name = flags.String("name", "", "Optional. NLB instance name, 1-255 chars.") + req.IPVersion = flags.String("ip-version", "IPv4", "Optional. IP protocol version: IPv4/IPv6/DualStack.") + req.ChargeType = flags.String("charge-type", "Dynamic", "Optional. Charge type: Dynamic (by hour), Month, Year.") + req.Quantity = flags.Int("quantity", 1, "Optional. Purchase duration. For Month with value 0 means until end of month.") + req.Tag = flags.String("group", "Default", "Optional. Business group.") + req.Remark = flags.String("remark", "", "Optional. Remark of the NLB instance.") + flags.StringVar(&couponID, "coupon-id", "", "Optional. Coupon ID.") + + command.SetFlagValues(cmd, "ip-version", "IPv4", "IPv6", "DualStack") + command.SetFlagValues(cmd, "charge-type", "Dynamic", "Month", "Year") + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, derefStr(req.VPCId), derefStr(req.ProjectId), derefStr(req.Region)) + }) + + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("subnet-id") + + return cmd +} diff --git a/products/nlb/internal/nlb/delete.go b/products/nlb/internal/nlb/delete.go new file mode 100644 index 0000000000..d819a53bda --- /dev/null +++ b/products/nlb/internal/nlb/delete.go @@ -0,0 +1,68 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete implements `nlb delete`. +func newDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDeleteNetworkLoadBalancerRequest() + + var idNames []string + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete NLB instances by resource ID", + Long: "Delete one or more NLB instances by resource ID.", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the NLB instance(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, idName := range idNames { + id := ctx.PickResourceID(idName) + req.NLBId = sdk.String(id) + if _, err := client.DeleteNetworkLoadBalancer(req); err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringSliceVar(&idNames, resourceIDFlag, nil, "Required. Resource ID(s) of the NLB instances to delete.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + req.ReleaseEIP = flags.Bool("release-eip", false, "Optional. Release the EIP bound to the NLB when deleting.") + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/describe.go b/products/nlb/internal/nlb/describe.go new file mode 100644 index 0000000000..a5a4ea31a6 --- /dev/null +++ b/products/nlb/internal/nlb/describe.go @@ -0,0 +1,152 @@ +package nlb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDescribe implements `nlb describe`. +func newDescribe(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDescribeNetworkLoadBalancersRequest() + + var nlbID string + + cmd := &cobra.Command{ + Use: "describe", + Short: "Show details of one NLB instance", + Long: "Show the full attribute/value detail of a single NLB instance, along with its listeners and each listener's backend targets.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + id := ctx.PickResourceID(nlbID) + req.NLBIds = []string{id} + req.ShowDetail = sdk.Bool(true) + + resp, err := client.DescribeNetworkLoadBalancers(req) + if err != nil { + ctx.HandleError(err) + return + } + if len(resp.NLBs) == 0 { + ctx.HandleError(fmt.Errorf("NLB instance %q not found", id)) + return + } + n := resp.NLBs[0] + + ips := make([]string, 0, len(n.IPInfos)) + for _, ip := range n.IPInfos { + direction := "Forward" + if ip.Type == 2 { + direction = "Backward" + } + ips = append(ips, fmt.Sprintf("%s(%s,%s)", ip.IP, ip.AddressType, direction)) + } + + rows := []cli.DescribeRow{ + {Attribute: "ResourceID", Content: n.NLBId}, + {Attribute: "Name", Content: n.Name}, + {Attribute: "Status", Content: n.Status}, + {Attribute: "VPC", Content: n.VPCId}, + {Attribute: "Subnet", Content: n.SubnetId}, + {Attribute: "IPVersion", Content: n.IPVersion}, + {Attribute: "IP", Content: strings.Join(ips, ",")}, + {Attribute: "ForwardingMode", Content: n.ForwardingMode}, + {Attribute: "ChargeType", Content: n.ChargeType}, + {Attribute: "AutoRenew", Content: strconv.FormatBool(n.AutoRenewEnabled)}, + {Attribute: "PurchaseValue", Content: common.FormatDate(n.PurchaseValue)}, + {Attribute: "Group", Content: n.Tag}, + {Attribute: "Remark", Content: n.Remark}, + {Attribute: "ListenerCount", Content: fmt.Sprintf("%d", len(n.Listeners))}, + {Attribute: "CreationTime", Content: common.FormatDate(n.CreateTime)}, + } + printDescribe(ctx, rows) + + if len(n.Listeners) > 0 { + fmt.Fprintln(ctx.ProgressWriter(), "\nListeners:") + details := make([]ListenerDetailRow, 0, len(n.Listeners)) + for _, l := range n.Listeners { + details = append(details, ListenerDetailRow{ + ListenerID: l.ListenerId, + Name: l.Name, + Protocol: l.Protocol, + PortRange: fmt.Sprintf("%d-%d", l.StartPort, l.EndPort), + Scheduler: l.Scheduler, + ForwardSrcIPMethod: l.ForwardSrcIPMethod, + StickinessTimeout: l.StickinessTimeout, + State: l.State, + HealthCheckType: l.HealthCheckConfig.Type, + HealthCheckPort: l.HealthCheckConfig.Port, + HealthCheckReqMsg: l.HealthCheckConfig.ReqMsg, + HealthCheckResMsg: l.HealthCheckConfig.ResMsg, + TargetCount: len(l.Targets), + }) + } + ctx.PrintList(details) + + for _, l := range n.Listeners { + fmt.Fprintf(ctx.ProgressWriter(), "\nTargets of %s(%s):\n", l.ListenerId, l.Name) + targets := make([]TargetRow, 0, len(l.Targets)) + for _, t := range l.Targets { + resourceID := t.ResourceId + if resourceID == "" { + resourceID = t.ResourceIP // IP-type targets carry the address here + } + targets = append(targets, TargetRow{ + TargetID: t.Id, + Name: t.ResourceName, + ResourceType: t.ResourceType, + ResourceID: resourceID, + Port: t.Port, + Weight: t.Weight, + Enabled: t.Enabled, + State: t.State, + }) + } + ctx.PrintList(targets) + } + } + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance to describe.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} + +// printDescribe renders describe rows without column headers in table mode, +// printing each attribute/content pair as an aligned key-value row. +func printDescribe(ctx *cli.Context, rows []cli.DescribeRow) { + if ctx.Format() != cli.OutputTable { + ctx.PrintList(rows) + return + } + maxWidth := 0 + for _, r := range rows { + if len(r.Attribute) > maxWidth { + maxWidth = len(r.Attribute) + } + } + for _, r := range rows { + fmt.Fprintf(ctx.Out(), "%-*s %s\n", maxWidth, r.Attribute, r.Content) + } +} diff --git a/products/nlb/internal/nlb/list.go b/products/nlb/internal/nlb/list.go new file mode 100644 index 0000000000..f1ac03f268 --- /dev/null +++ b/products/nlb/internal/nlb/list.go @@ -0,0 +1,81 @@ +package nlb + +import ( + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList implements `nlb list`. +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDescribeNetworkLoadBalancersRequest() + + var nlbID string + + cmd := &cobra.Command{ + Use: "list", + Short: "List NLB instances", + Long: "List NLB instances in the active region/project.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + if id := ctx.PickResourceID(nlbID); id != "" { + req.NLBIds = []string{id} + } + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId)) + + resp, err := client.DescribeNetworkLoadBalancers(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]NLBRow, 0, len(resp.NLBs)) + for _, n := range resp.NLBs { + rows = append(rows, NLBRow{ + ResourceID: n.NLBId, + Name: n.Name, + Status: n.Status, + VPC: n.VPCId, + Subnet: n.SubnetId, + IPVersion: n.IPVersion, + ForwardingMode: n.ForwardingMode, + AutoRenewEnabled: n.AutoRenewEnabled, + PurchaseValue: common.FormatDate(n.PurchaseValue), + Group: n.Tag, + CreationTime: common.FormatDate(n.CreateTime), + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Optional. List only the specified NLB instance.") + req.VPCId = flags.String("vpc-id", "", "Optional. List only NLB instances in the specified VPC.") + req.SubnetId = flags.String("subnet-id", "", "Optional. List only NLB instances in the specified subnet.") + req.Offset = flags.Int("offset", 0, "Optional. Offset.") + req.Limit = flags.Int("limit", 100, "Optional. Limit.") + + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, derefStr(req.VPCId), derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/listener.go b/products/nlb/internal/nlb/listener.go new file mode 100644 index 0000000000..2c9a5ca69f --- /dev/null +++ b/products/nlb/internal/nlb/listener.go @@ -0,0 +1,21 @@ +package nlb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListener assembles the `nlb listener` sub-tree. +func newListener(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "listener", + Short: "List and manipulate NLB listeners", + Long: "List and manipulate NLB listeners", + } + cmd.AddCommand(newListenerList(ctx)) + cmd.AddCommand(newListenerCreate(ctx)) + cmd.AddCommand(newListenerUpdate(ctx)) + cmd.AddCommand(newListenerDelete(ctx)) + return cmd +} diff --git a/products/nlb/internal/nlb/listener_create.go b/products/nlb/internal/nlb/listener_create.go new file mode 100644 index 0000000000..9a8f8cbe02 --- /dev/null +++ b/products/nlb/internal/nlb/listener_create.go @@ -0,0 +1,87 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListenerCreate implements `nlb listener create`. +func newListenerCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewCreateNLBListenerRequest() + + var nlbID string + var healthCheckPort int + var healthCheckType, healthCheckReqMsg, healthCheckResMsg string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an NLB listener", + Long: "Create a listener on the specified NLB instance.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + hc := &nlbsdk.CreateNLBListenerParamHealthCheckConfig{ + Enabled: sdk.Bool(true), + Type: sdk.String(healthCheckType), + Port: sdk.Int(healthCheckPort), + } + if healthCheckReqMsg != "" { + hc.ReqMsg = sdk.String(healthCheckReqMsg) + } + if healthCheckResMsg != "" { + hc.ResMsg = sdk.String(healthCheckResMsg) + } + req.HealthCheckConfig = hc + + resp, err := client.CreateNLBListener(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb-listener[%s] created\n", resp.ListenerId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ListenerId, Action: "create-listener", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance to add the listener to.") + req.Protocol = flags.String("protocol", "", "Required. Listen protocol: TCP/UDP.") + req.Name = flags.String("name", "", "Optional. Listener name, 1-255 chars.") + req.Scheduler = flags.String("scheduler", "RoundRobin", "Optional. Load balancing algorithm: RoundRobin/SourceHash/LeastConn/WeightLeastConn/WeightRoundRobin.") + req.StartPort = flags.Int("start-port", 1, "Required. Start port of the listen port range.") + req.EndPort = flags.Int("end-port", 65535, "Required. End port of the listen port range.") + req.StickinessTimeout = flags.Int("stickiness-timeout", 0, "Optional. Session stickiness timeout in seconds, [60-900], 0 disables it.") + req.ForwardSrcIPMethod = flags.String("forward-src-ip-method", "", "Optional. Source IP passthrough method: \"\"/None/Toa/ProxyProto.") + flags.IntVar(&healthCheckPort, "health-check-port", 0, "Optional. Health check probe port, [1-65535] (0 allowed for Ping).") + flags.StringVar(&healthCheckType, "health-check-type", "Port", "Optional. Health check method: Port/UDP/Ping.") + flags.StringVar(&healthCheckReqMsg, "health-check-req-msg", "", "Optional. UDP health check request string.") + flags.StringVar(&healthCheckResMsg, "health-check-res-msg", "", "Optional. UDP health check expected response string.") + + command.SetFlagValues(cmd, "protocol", "TCP", "UDP") + command.SetFlagValues(cmd, "scheduler", "RoundRobin", "SourceHash", "LeastConn", "WeightLeastConn", "WeightRoundRobin") + command.SetFlagValues(cmd, "forward-src-ip-method", "", "None", "Toa", "ProxyProto") + command.SetFlagValues(cmd, "health-check-type", "Port", "UDP", "Ping") + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("protocol") + cmd.MarkFlagRequired("start-port") + cmd.MarkFlagRequired("end-port") + + return cmd +} diff --git a/products/nlb/internal/nlb/listener_delete.go b/products/nlb/internal/nlb/listener_delete.go new file mode 100644 index 0000000000..ca6434f2f6 --- /dev/null +++ b/products/nlb/internal/nlb/listener_delete.go @@ -0,0 +1,74 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListenerDelete implements `nlb listener delete`. +func newListenerDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDeleteNLBListenerRequest() + + var nlbID string + var listenerIDs []string + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete NLB listeners by resource ID", + Long: "Delete one or more listeners of an NLB instance.", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the listener(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + results := []cli.OpResultRow{} + for _, idName := range listenerIDs { + id := ctx.PickResourceID(idName) + req.ListenerId = sdk.String(id) + if _, err := client.DeleteNLBListener(req); err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb-listener[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete-listener", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringSliceVar(&listenerIDs, "listener-id", nil, "Required. Resource ID(s) of the listeners to delete.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("listener-id") + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/listener_list.go b/products/nlb/internal/nlb/listener_list.go new file mode 100644 index 0000000000..3743cbe09f --- /dev/null +++ b/products/nlb/internal/nlb/listener_list.go @@ -0,0 +1,100 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListenerList implements `nlb listener list`. +func newListenerList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDescribeNLBListenersRequest() + + var nlbID, listenerID string + + cmd := &cobra.Command{ + Use: "list", + Short: "List listeners of an NLB instance", + Long: "List the listeners of the specified NLB instance, along with each listener's backend targets.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + if id := ctx.PickResourceID(listenerID); id != "" { + req.ListenerId = sdk.String(id) + } + resp, err := client.DescribeNLBListeners(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]ListenerRow, 0, len(resp.Listeners)) + for _, l := range resp.Listeners { + rows = append(rows, ListenerRow{ + ListenerID: l.ListenerId, + Name: l.Name, + Protocol: l.Protocol, + Scheduler: l.Scheduler, + PortRange: fmt.Sprintf("%d-%d", l.StartPort, l.EndPort), + ForwardSrcIPMethod: l.ForwardSrcIPMethod, + State: l.State, + StickinessTimeout: l.StickinessTimeout, + HealthCheckType: l.HealthCheckConfig.Type, + HealthCheckPort: l.HealthCheckConfig.Port, + HealthCheckReqMsg: l.HealthCheckConfig.ReqMsg, + HealthCheckResMsg: l.HealthCheckConfig.ResMsg, + }) + } + ctx.PrintList(rows) + + for _, l := range resp.Listeners { + fmt.Fprintf(ctx.ProgressWriter(), "\nTargets of %s(%s):\n", l.ListenerId, l.Name) + targets := make([]TargetRow, 0, len(l.Targets)) + for _, t := range l.Targets { + resourceID := t.ResourceId + if resourceID == "" { + resourceID = t.ResourceIP // IP-type targets carry the address here + } + targets = append(targets, TargetRow{ + TargetID: t.Id, + Name: t.ResourceName, + ResourceType: t.ResourceType, + ResourceID: resourceID, + Port: t.Port, + Weight: t.Weight, + Enabled: t.Enabled, + State: t.State, + }) + } + ctx.PrintList(targets) + } + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringVar(&listenerID, "listener-id", "", "Optional. List only the specified listener.") + req.Offset = flags.Int("offset", 0, "Optional. Offset.") + req.Limit = flags.Int("limit", 100, "Optional. Limit.") + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/listener_update.go b/products/nlb/internal/nlb/listener_update.go new file mode 100644 index 0000000000..4840753f2f --- /dev/null +++ b/products/nlb/internal/nlb/listener_update.go @@ -0,0 +1,123 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListenerUpdate implements `nlb listener update`. +func newListenerUpdate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewUpdateNLBListenerAttributeRequest() + + var nlbID, listenerID string + var name, remark, scheduler string + var forwardSrcIPMethod, healthCheckType string + var healthCheckReqMsg, healthCheckResMsg string + var startPort, endPort, healthCheckPort int + + cmd := &cobra.Command{ + Use: "update", + Short: "Update an NLB listener", + Long: "Update attributes of an NLB listener.", + Run: func(c *cobra.Command, args []string) { + flags := c.Flags() + changedHealthCheckType := flags.Changed("health-check-type") + changedHealthCheckPort := flags.Changed("health-check-port") + changedHealthCheckReqMsg := flags.Changed("health-check-req-msg") + changedHealthCheckResMsg := flags.Changed("health-check-res-msg") + changed := name != "" || remark != "" || scheduler != "" || + flags.Changed("forward-src-ip-method") || flags.Changed("start-port") || flags.Changed("end-port") || + changedHealthCheckType || changedHealthCheckPort || + changedHealthCheckReqMsg || changedHealthCheckResMsg + if !changed { + ctx.HandleError(fmt.Errorf("nothing to update: set at least one of --name/--remark/--scheduler/--forward-src-ip-method/--start-port/--end-port/--health-check-type/--health-check-port/--health-check-req-msg/--health-check-res-msg")) + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + req.ListenerId = sdk.String(ctx.PickResourceID(listenerID)) + if name != "" { + req.Name = &name + } + if remark != "" { + req.Remark = &remark + } + if scheduler != "" { + req.Scheduler = &scheduler + } + if flags.Changed("forward-src-ip-method") { + req.ForwardSrcIPMethod = &forwardSrcIPMethod + } + if flags.Changed("start-port") { + req.StartPort = sdk.Int(startPort) + } + if flags.Changed("end-port") { + req.EndPort = sdk.Int(endPort) + } + if changedHealthCheckType || changedHealthCheckPort || changedHealthCheckReqMsg || changedHealthCheckResMsg { + hc := &nlbsdk.UpdateNLBListenerAttributeParamHealthCheckConfig{} + if changedHealthCheckType { + hc.Type = &healthCheckType + } + if changedHealthCheckPort { + hc.Port = sdk.Int(healthCheckPort) + } + if changedHealthCheckReqMsg { + hc.ReqMsg = &healthCheckReqMsg + } + if changedHealthCheckResMsg { + hc.ResMsg = &healthCheckResMsg + } + req.HealthCheckConfig = hc + } + if _, err := client.UpdateNLBListenerAttribute(req); err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb-listener[%s] updated\n", *req.ListenerId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.ListenerId, Action: "update-listener", Status: "Updated"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringVar(&listenerID, "listener-id", "", "Required. Resource ID of the listener to update.") + flags.StringVar(&name, "name", "", "Optional. New listener name.") + flags.StringVar(&remark, "remark", "", "Optional. New remark.") + flags.StringVar(&scheduler, "scheduler", "", "Optional. New load balancing algorithm: RoundRobin/SourceHash/LeastConn/WeightLeastConn/WeightRoundRobin.") + flags.StringVar(&forwardSrcIPMethod, "forward-src-ip-method", "", "Optional. Source IP passthrough method: \"\"/None/Toa/ProxyProto.") + flags.IntVar(&startPort, "start-port", 1, "Optional. New start port of the listen port range (full-port-range listeners only).") + flags.IntVar(&endPort, "end-port", 65535, "Optional. New end port of the listen port range (full-port-range listeners only).") + flags.StringVar(&healthCheckType, "health-check-type", "", "Optional. Health check type: Port/UDP/Ping.") + flags.IntVar(&healthCheckPort, "health-check-port", 0, "Optional. Health check probe port, [1-65535] (0 allowed for Ping).") + flags.StringVar(&healthCheckReqMsg, "health-check-req-msg", "", "Optional. UDP health check request string.") + flags.StringVar(&healthCheckResMsg, "health-check-res-msg", "", "Optional. UDP health check expected response string.") + + command.SetFlagValues(cmd, "scheduler", "RoundRobin", "SourceHash", "LeastConn", "WeightLeastConn", "WeightRoundRobin") + command.SetFlagValues(cmd, "forward-src-ip-method", "", "None", "Toa", "ProxyProto") + command.SetFlagValues(cmd, "health-check-type", "Port", "UDP", "Ping") + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("listener-id") + + return cmd +} diff --git a/products/nlb/internal/nlb/rows.go b/products/nlb/internal/nlb/rows.go new file mode 100644 index 0000000000..a2a9f2e20e --- /dev/null +++ b/products/nlb/internal/nlb/rows.go @@ -0,0 +1,71 @@ +package nlb + +// NLBRow is the output row for `nlb list`. Field declaration order is the +// table column order. +type NLBRow struct { + ResourceID string + Name string + Status string + VPC string + Subnet string + IPVersion string + ForwardingMode string + AutoRenewEnabled bool + PurchaseValue string + Group string + CreationTime string +} + +// ListenerRow is the output row for `nlb listener list`. +type ListenerRow struct { + ListenerID string + Name string + Protocol string + Scheduler string + PortRange string + ForwardSrcIPMethod string + State string + StickinessTimeout int + HealthCheckType string + HealthCheckPort int + HealthCheckReqMsg string + HealthCheckResMsg string +} + +// ListenerDetailRow is the output row for the "Listeners" detail table +// appended under `nlb describe`. It surfaces the per-listener fields the +// summary "ListenerCount" attribute row can't. HealthCheckType/HealthCheckPort +// give a health-check summary; TargetCount is an at-a-glance count, with the +// full per-target breakdown following right after in the "Targets of ..." +// tables (see TargetRow) — keeping this row itself flat/one-level-deep. +type ListenerDetailRow struct { + ListenerID string + Name string + Protocol string + PortRange string + Scheduler string + ForwardSrcIPMethod string + StickinessTimeout int + State string + HealthCheckType string + HealthCheckPort int + HealthCheckReqMsg string + HealthCheckResMsg string + TargetCount int +} + +// TargetRow is the output row for the per-listener "Targets of ..." tables +// appended under `nlb listener list` and `nlb describe` (Target sits one +// level below Listener — NLB → Listener → Target — so it is surfaced there, +// grouped by listener, rather than via a standalone `nlb target list` +// command). +type TargetRow struct { + TargetID string + Name string + ResourceType string + ResourceID string + Port int + Weight int + Enabled bool + State string +} diff --git a/products/nlb/internal/nlb/target.go b/products/nlb/internal/nlb/target.go new file mode 100644 index 0000000000..9c7a709e5d --- /dev/null +++ b/products/nlb/internal/nlb/target.go @@ -0,0 +1,21 @@ +package nlb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newTarget assembles the `nlb target` sub-tree (backend service nodes attached +// to a listener). +func newTarget(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "target", + Short: "Manage NLB backend targets (service nodes)", + Long: "Add, remove and update the backend targets of an NLB listener.", + } + cmd.AddCommand(newTargetAdd(ctx)) + cmd.AddCommand(newTargetRemove(ctx)) + cmd.AddCommand(newTargetUpdate(ctx)) + return cmd +} diff --git a/products/nlb/internal/nlb/target_add.go b/products/nlb/internal/nlb/target_add.go new file mode 100644 index 0000000000..87dcbae455 --- /dev/null +++ b/products/nlb/internal/nlb/target_add.go @@ -0,0 +1,195 @@ +package nlb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseTarget parses a "--target" value. +// +// Non-IP format: "resourceType:resourceId:port[:weight[:enabled]]" +// +// e.g. "UHost:uhost-abc:80" or "UHost:uhost-abc:80:5:false" +// +// IP format: "IP:ip:port[:weight]:vpcId[:subnetId[:enabled]]" +// +// e.g. "IP:10.0.0.1:80:vpc-xxx" or "IP:10.0.0.1:80:1:vpc-xxx:subnet-yyy:false" +func parseTarget(s string) (nlbsdk.AddNLBTargetsParamTargets, error) { + parts := strings.Split(s, ":") + if len(parts) < 3 || len(parts) > 7 { + return nlbsdk.AddNLBTargetsParamTargets{}, fmt.Errorf( + "invalid --target %q, expected \"resourceType:resourceId:port[:weight[:enabled]]\" or \"IP:ip:port[:weight]:vpcId[:subnetId[:enabled]]\"", s) + } + + parseEnabled := func(idx int) (*bool, error) { + if idx >= len(parts) { + return sdk.Bool(true), nil + } + switch parts[idx] { + case "true", "1": + return sdk.Bool(true), nil + case "false", "0": + return sdk.Bool(false), nil + default: + return nil, fmt.Errorf("invalid enabled value %q in --target %q, expected true/false", parts[idx], s) + } + } + + resourceType := parts[0] + resourceID := parts[1] + port, err := strconv.Atoi(parts[2]) + if err != nil { + return nlbsdk.AddNLBTargetsParamTargets{}, fmt.Errorf("invalid port in --target %q: %w", s, err) + } + + t := nlbsdk.AddNLBTargetsParamTargets{ + Port: sdk.Int(port), + ResourceType: sdk.String(resourceType), + } + + isBool := func(s string) bool { return s == "true" || s == "false" || s == "0" || s == "1" } + + if resourceType == "IP" { + t.ResourceIP = sdk.String(resourceID) + if len(parts) < 4 { + t.Weight = sdk.Int(1) + t.Enabled = sdk.Bool(true) + return t, fmt.Errorf("invalid --target %q: IP type requires vpcId: \"IP:ip:port[:weight]:vpcId[:subnetId[:enabled]]\"", s) + } + // IP format: IP:ip:port [weight?] vpcId [subnetId?] [enabled?] + // If parts[3] is a number → weight at [3], vpcId at [4] + // If parts[3] is not a number → vpcId at [3] (weight=1) + // When no explicit weight and the last part looks like a bool, + // treat it as enabled, not subnetId. + if _, err := strconv.Atoi(parts[3]); err == nil { + // Has explicit weight at [3] + w, _ := strconv.Atoi(parts[3]) + t.Weight = sdk.Int(w) + t.VPCId = sdk.String(parts[4]) + if len(parts) >= 6 { + t.SubnetId = sdk.String(parts[5]) + } + enabled, err := parseEnabled(6) + if err != nil { + return nlbsdk.AddNLBTargetsParamTargets{}, err + } + t.Enabled = enabled + } else { + // No explicit weight, parts[3] is vpcId + t.Weight = sdk.Int(1) + t.VPCId = sdk.String(parts[3]) + remaining := parts[4:] + if len(remaining) == 0 { + t.Enabled = sdk.Bool(true) + } else if len(remaining) == 1 { + if isBool(remaining[0]) { + enabled, _ := parseEnabled(4) // parts[4] is enabled + t.Enabled = enabled + } else { + t.SubnetId = sdk.String(remaining[0]) + t.Enabled = sdk.Bool(true) + } + } else if len(remaining) == 2 { + t.SubnetId = sdk.String(remaining[0]) + enabled, err := parseEnabled(5) + if err != nil { + return nlbsdk.AddNLBTargetsParamTargets{}, err + } + t.Enabled = enabled + } + } + } else { + t.ResourceId = sdk.String(resourceID) + // Non-IP: resourceType:resourceId:port[:weight[:enabled]] + weight := 1 + if len(parts) >= 4 { + weight, err = strconv.Atoi(parts[3]) + if err != nil { + return nlbsdk.AddNLBTargetsParamTargets{}, fmt.Errorf("invalid weight in --target %q: %w", s, err) + } + } + t.Weight = sdk.Int(weight) + enabled, err := parseEnabled(4) + if err != nil { + return nlbsdk.AddNLBTargetsParamTargets{}, err + } + t.Enabled = enabled + } + return t, nil +} + +// newTargetAdd implements `nlb target add`. +func newTargetAdd(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewAddNLBTargetsRequest() + + var nlbID, listenerID string + var targets []string + + cmd := &cobra.Command{ + Use: "add", + Short: "Add backend targets to an NLB listener", + Long: "Add one or more backend service nodes to an NLB listener. Supports mixed resource types in one call.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + req.ListenerId = sdk.String(ctx.PickResourceID(listenerID)) + + parsed := make([]nlbsdk.AddNLBTargetsParamTargets, 0, len(targets)) + for _, t := range targets { + pt, err := parseTarget(t) + if err != nil { + ctx.HandleError(err) + return + } + parsed = append(parsed, pt) + } + req.Targets = parsed + + resp, err := client.AddNLBTargets(req) + if err != nil { + ctx.HandleError(err) + return + } + results := make([]cli.OpResultRow, 0, len(resp.Targets)) + for _, t := range resp.Targets { + fmt.Fprintf(ctx.ProgressWriter(), "nlb-target[%s] added\n", t.Id) + results = append(results, cli.OpResultRow{ResourceID: t.Id, Action: "add-target", Status: "Added"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringVar(&listenerID, "listener-id", "", "Required. Resource ID of the listener to add targets to.") + flags.StringSliceVar(&targets, "target", nil, + "Required. Repeatable. Non-IP: \"resourceType:resourceId:port[:weight[:enabled]]\". IP: \"IP:ip:port[:weight]:vpcId[:subnetId[:enabled]]\".") + + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("listener-id") + cmd.MarkFlagRequired("target") + + return cmd +} diff --git a/products/nlb/internal/nlb/target_remove.go b/products/nlb/internal/nlb/target_remove.go new file mode 100644 index 0000000000..7825d27a9d --- /dev/null +++ b/products/nlb/internal/nlb/target_remove.go @@ -0,0 +1,84 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newTargetRemove implements `nlb target remove`. +func newTargetRemove(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewRemoveNLBTargetsRequest() + + var nlbID, listenerID string + var targetIDs []string + var yes bool + + cmd := &cobra.Command{ + Use: "remove", + Short: "Remove backend targets from an NLB listener", + Long: "Remove one or more backend service nodes from an NLB listener.", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to remove the target(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + req.ListenerId = sdk.String(ctx.PickResourceID(listenerID)) + ids := make([]string, 0, len(targetIDs)) + for _, idName := range targetIDs { + ids = append(ids, ctx.PickResourceID(idName)) + } + req.Ids = ids + + if _, err := client.RemoveNLBTargets(req); err != nil { + ctx.HandleError(err) + return + } + results := make([]cli.OpResultRow, 0, len(ids)) + for _, id := range ids { + fmt.Fprintf(ctx.ProgressWriter(), "nlb-target[%s] removed\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "remove-target", Status: "Removed"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringVar(&listenerID, "listener-id", "", "Required. Resource ID of the listener.") + flags.StringSliceVar(&targetIDs, "target-id", nil, "Required. Target ID(s) to remove (max 40 per request).") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("listener-id") + cmd.MarkFlagRequired("target-id") + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "target-id", func() []string { + return getAllTargetIDNames(ctx, nlbID, listenerID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/target_update.go b/products/nlb/internal/nlb/target_update.go new file mode 100644 index 0000000000..d1d7a6ef3e --- /dev/null +++ b/products/nlb/internal/nlb/target_update.go @@ -0,0 +1,144 @@ +package nlb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseTargetUpdate parses a "--target" value for the update command. +// +// Format: "targetId[:weight|enabled[:enabled]]" +// +// "nrs-abc:5" → weight=5 +// "nrs-abc:false" → enabled=false +// "nrs-abc:5:false" → weight=5, enabled=false +// +// The second field is auto-detected: if it looks like a bool (true/false/0/1) +// it is treated as enabled; otherwise it is treated as weight. +func parseTargetUpdate(s string) (id string, t nlbsdk.UpdateNLBTargetsAttributeParamTargets, err error) { + parts := strings.Split(s, ":") + if len(parts) < 1 || len(parts) > 3 { + return "", t, fmt.Errorf("invalid --target %q, expected \"targetId[:weight|enabled[:enabled]]\"", s) + } + if len(parts) < 2 { + return "", t, fmt.Errorf("invalid --target %q: at least weight or enabled must be provided", s) + } + id = parts[0] + t.Id = sdk.String(id) + + isBool := func(s string) bool { return s == "true" || s == "false" || s == "0" || s == "1" } + + p1 := parts[1] + hasSecondField := len(parts) >= 3 + + if isBool(p1) { + // "nrs-abc:false" or "nrs-abc:false:..." — p1 is enabled + switch p1 { + case "true", "1": + t.Enabled = sdk.Bool(true) + case "false", "0": + t.Enabled = sdk.Bool(false) + } + if hasSecondField { + return "", t, fmt.Errorf("invalid --target %q: too many fields for \"targetId:enabled\"", s) + } + } else { + // "nrs-abc:5" or "nrs-abc:5:false" — p1 is weight + w, err := strconv.Atoi(p1) + if err != nil { + return "", t, fmt.Errorf("invalid weight/enabled %q in --target %q", p1, s) + } + t.Weight = sdk.Int(w) + if hasSecondField { + switch parts[2] { + case "true", "1": + t.Enabled = sdk.Bool(true) + case "false", "0": + t.Enabled = sdk.Bool(false) + default: + return "", t, fmt.Errorf("invalid enabled value %q in --target %q, expected true/false", parts[2], s) + } + } + } + return id, t, nil +} + +// newTargetUpdate implements `nlb target update`. +func newTargetUpdate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewUpdateNLBTargetsAttributeRequest() + + var nlbID, listenerID string + var targets []string + + cmd := &cobra.Command{ + Use: "update", + Short: "Update backend targets of an NLB listener", + Long: "Update the weight and/or enabled state of one or more NLB targets. Each target can have different values.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + req.ListenerId = sdk.String(ctx.PickResourceID(listenerID)) + + updates := make([]nlbsdk.UpdateNLBTargetsAttributeParamTargets, 0, len(targets)) + ids := make([]string, 0, len(targets)) + for _, raw := range targets { + id, u, err := parseTargetUpdate(raw) + if err != nil { + ctx.HandleError(err) + return + } + id = ctx.PickResourceID(id) + u.Id = sdk.String(id) + ids = append(ids, id) + updates = append(updates, u) + } + req.Targets = updates + + if _, err := client.UpdateNLBTargetsAttribute(req); err != nil { + ctx.HandleError(err) + return + } + results := make([]cli.OpResultRow, 0, len(ids)) + for _, id := range ids { + fmt.Fprintf(ctx.ProgressWriter(), "nlb-target[%s] updated\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "update-target", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringVar(&listenerID, "listener-id", "", "Required. Resource ID of the listener.") + flags.StringSliceVar(&targets, "target", nil, "Required. Repeatable. Target as \"targetId:weight|enabled[:enabled]\", e.g. \"nrs-abc:5\" or \"nrs-abc:false\" or \"nrs-abc:5:false\".") + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("listener-id") + cmd.MarkFlagRequired("target") + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "target", func() []string { + return getAllTargetIDNames(ctx, nlbID, listenerID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/update.go b/products/nlb/internal/nlb/update.go new file mode 100644 index 0000000000..24fe901de9 --- /dev/null +++ b/products/nlb/internal/nlb/update.go @@ -0,0 +1,74 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUpdate implements `nlb update`. +func newUpdate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewUpdateNetworkLoadBalancerAttributeRequest() + + var idNames []string + var name, remark, group string + + cmd := &cobra.Command{ + Use: "update", + Short: "Update NLB instance attributes", + Long: "Update the name, remark or business group of one or more NLB instances.", + Run: func(c *cobra.Command, args []string) { + if name == "" && remark == "" && group == "" { + ctx.HandleError(fmt.Errorf("nothing to update: set at least one of --name/--remark/--group")) + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + if name != "" { + req.Name = &name + } + if remark != "" { + req.Remark = &remark + } + if group != "" { + req.Tag = &group + } + results := []cli.OpResultRow{} + for _, idName := range idNames { + id := ctx.PickResourceID(idName) + req.NLBId = sdk.String(id) + if _, err := client.UpdateNetworkLoadBalancerAttribute(req); err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb[%s] updated\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "update", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringSliceVar(&idNames, resourceIDFlag, nil, "Required. Resource ID(s) of the NLB instances to update.") + flags.StringVar(&name, "name", "", "Optional. New NLB instance name.") + flags.StringVar(&remark, "remark", "", "Optional. New remark.") + flags.StringVar(&group, "group", "", "Optional. New business group.") + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/product.go b/products/nlb/product.go new file mode 100644 index 0000000000..a3d4e4ae48 --- /dev/null +++ b/products/nlb/product.go @@ -0,0 +1,21 @@ +package nlb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalnlb "github.com/ucloud/ucloud-cli/products/nlb/internal/nlb" +) + +type product struct{} + +// New returns the nlb product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "nlb", Commands: []string{"nlb"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalnlb.NewCommand(ctx)} +} diff --git a/products/nlb/product.yaml b/products/nlb/product.yaml new file mode 100644 index 0000000000..5adc6e6f1b --- /dev/null +++ b/products/nlb/product.yaml @@ -0,0 +1,7 @@ +# products/nlb/product.yaml — NLB (Network Load Balancer) product metadata. +name: nlb +owners: + - Cavan-xu +commands: + - nlb +enabled: true diff --git a/products/nlb/testdata/cmdtree.golden b/products/nlb/testdata/cmdtree.golden new file mode 100644 index 0000000000..813a7527f4 --- /dev/null +++ b/products/nlb/testdata/cmdtree.golden @@ -0,0 +1,102 @@ +ucloud nlb use=nlb short=List and manipulate NLB (Network Load Balancer) instances +ucloud nlb create use=create short=Create an NLB instance + flag=charge-type short= default=Dynamic required= + flag=coupon-id short= default= required= + flag=group short= default=Default required= + flag=ip-version short= default=IPv4 required= + flag=name short= default= required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=remark short= default= required= + flag=subnet-id short= default= required=true + flag=vpc-id short= default= required=true +ucloud nlb delete use=delete short=Delete NLB instances by resource ID + flag=nlb-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=release-eip short= default=false required= + flag=yes short=y default=false required= +ucloud nlb describe use=describe short=Show details of one NLB instance + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud nlb list use=list short=List NLB instances + flag=limit short= default=100 required= + flag=nlb-id short= default= required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= +ucloud nlb listener use=listener short=List and manipulate NLB listeners +ucloud nlb listener create use=create short=Create an NLB listener + flag=end-port short= default=65535 required=true + flag=forward-src-ip-method short= default= required= + flag=health-check-port short= default=0 required= + flag=health-check-req-msg short= default= required= + flag=health-check-res-msg short= default= required= + flag=health-check-type short= default=Port required= + flag=name short= default= required= + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=protocol short= default= required=true + flag=region short= default= required= + flag=scheduler short= default=RoundRobin required= + flag=start-port short= default=1 required=true + flag=stickiness-timeout short= default=0 required= +ucloud nlb listener delete use=delete short=Delete NLB listeners by resource ID + flag=listener-id short= default=[] required=true + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= +ucloud nlb listener list use=list short=List listeners of an NLB instance + flag=limit short= default=100 required= + flag=listener-id short= default= required= + flag=nlb-id short= default= required=true + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= +ucloud nlb listener update use=update short=Update an NLB listener + flag=end-port short= default=65535 required= + flag=forward-src-ip-method short= default= required= + flag=health-check-port short= default=0 required= + flag=health-check-req-msg short= default= required= + flag=health-check-res-msg short= default= required= + flag=health-check-type short= default= required= + flag=listener-id short= default= required=true + flag=name short= default= required= + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=scheduler short= default= required= + flag=start-port short= default=1 required= +ucloud nlb target use=target short=Manage NLB backend targets (service nodes) +ucloud nlb target add use=add short=Add backend targets to an NLB listener + flag=listener-id short= default= required=true + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=target short= default=[] required=true +ucloud nlb target remove use=remove short=Remove backend targets from an NLB listener + flag=listener-id short= default= required=true + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=target-id short= default=[] required=true + flag=yes short=y default=false required= +ucloud nlb target update use=update short=Update backend targets of an NLB listener + flag=listener-id short= default= required=true + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=target short= default=[] required=true +ucloud nlb update use=update short=Update NLB instance attributes + flag=group short= default= required= + flag=name short= default= required= + flag=nlb-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= diff --git a/products/nlb/testdata/completion.golden b/products/nlb/testdata/completion.golden new file mode 100644 index 0000000000..1d8e24c388 --- /dev/null +++ b/products/nlb/testdata/completion.golden @@ -0,0 +1,56 @@ +ucloud nlb create charge-type static Dynamic,Month,Year +ucloud nlb create ip-version static DualStack,IPv4,IPv6 +ucloud nlb create project-id dynamic +ucloud nlb create region dynamic +ucloud nlb create subnet-id dynamic +ucloud nlb create vpc-id dynamic +ucloud nlb delete nlb-id dynamic +ucloud nlb delete project-id dynamic +ucloud nlb delete region dynamic +ucloud nlb describe nlb-id dynamic +ucloud nlb describe project-id dynamic +ucloud nlb describe region dynamic +ucloud nlb list nlb-id dynamic +ucloud nlb list project-id dynamic +ucloud nlb list region dynamic +ucloud nlb list subnet-id dynamic +ucloud nlb list vpc-id dynamic +ucloud nlb listener create forward-src-ip-method static ,None,ProxyProto,Toa +ucloud nlb listener create health-check-type static Ping,Port,UDP +ucloud nlb listener create nlb-id dynamic +ucloud nlb listener create project-id dynamic +ucloud nlb listener create protocol static TCP,UDP +ucloud nlb listener create region dynamic +ucloud nlb listener create scheduler static LeastConn,RoundRobin,SourceHash,WeightLeastConn,WeightRoundRobin +ucloud nlb listener delete listener-id static +ucloud nlb listener delete nlb-id dynamic +ucloud nlb listener delete project-id dynamic +ucloud nlb listener delete region dynamic +ucloud nlb listener list listener-id static +ucloud nlb listener list nlb-id dynamic +ucloud nlb listener list project-id dynamic +ucloud nlb listener list region dynamic +ucloud nlb listener update forward-src-ip-method static ,None,ProxyProto,Toa +ucloud nlb listener update health-check-type static Ping,Port,UDP +ucloud nlb listener update listener-id static +ucloud nlb listener update nlb-id dynamic +ucloud nlb listener update project-id dynamic +ucloud nlb listener update region dynamic +ucloud nlb listener update scheduler static LeastConn,RoundRobin,SourceHash,WeightLeastConn,WeightRoundRobin +ucloud nlb target add listener-id static +ucloud nlb target add nlb-id dynamic +ucloud nlb target add project-id dynamic +ucloud nlb target add region dynamic +ucloud nlb target remove listener-id static +ucloud nlb target remove nlb-id dynamic +ucloud nlb target remove project-id dynamic +ucloud nlb target remove region dynamic +ucloud nlb target remove target-id static +ucloud nlb target update listener-id static +ucloud nlb target update nlb-id dynamic +ucloud nlb target update project-id dynamic +ucloud nlb target update region dynamic +ucloud nlb target update target static +ucloud nlb update nlb-id dynamic +ucloud nlb update project-id dynamic +ucloud nlb update region dynamic diff --git a/products/pathx/internal/pathx/area.go b/products/pathx/internal/pathx/area.go new file mode 100644 index 0000000000..5d2f5626b3 --- /dev/null +++ b/products/pathx/internal/pathx/area.go @@ -0,0 +1,18 @@ +package pathx + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newArea builds `ucloud pathx area`. +func newArea(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "area", + Short: "List origin area or acceleration area information", + Long: "List origin area or acceleration area information", + } + cmd.AddCommand(newAreaList(ctx)) + return cmd +} diff --git a/products/pathx/internal/pathx/area_list.go b/products/pathx/internal/pathx/area_list.go new file mode 100644 index 0000000000..7826b995a9 --- /dev/null +++ b/products/pathx/internal/pathx/area_list.go @@ -0,0 +1,134 @@ +package pathx + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newAreaList ucloud pathx area list +func newAreaList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + areaGetReq := client.NewDescribeUGA3AreaRequest() + optimizationReq := client.NewDescribeUGA3OptimizationRequest() + var timeRange, accelerationArea, originDomain, originIp string + var noAccel bool + cmd := &cobra.Command{ + Use: "list", + Short: "List origin area or acceleration area information", + Long: "Provide optional flags to get the optional list of global access source stations", + Example: "ucloud pathx area list --origin-ip 0.0.0.0 --origin-domain test.com", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + if len(originDomain) == 0 && len(originIp) == 0 { + response, err := client.DescribeUGA3Area(areaGetReq) + if err != nil { + ctx.HandleError(err) + return + } + forwardAreas := response.AreaSet + if len(forwardAreas) == 0 { + ctx.HandleError(fmt.Errorf("Not found the origin area list")) + return + } + areasGroup := make(map[string][]PathxOptionalAreaRow) + for _, item := range forwardAreas { + areasGroup[item.ContinentCode] = append(areasGroup[item.ContinentCode], PathxOptionalAreaRow{ + AreaCode: item.AreaCode, + Area: item.Area, + CountryCode: item.CountryCode, + FlagUnicode: item.FlagUnicode, + FlagEmoji: item.FlagEmoji, + }) + } + fmt.Fprintln(w, "Origin areas :") + for area, rows := range areasGroup { + fmt.Fprintf(w, "ContinentCode: %s\n", area) + ctx.PrintList(rows) + fmt.Fprintln(w) + } + return + } + areaGetReq.Domain = &originDomain + areaGetReq.IPList = &originIp + response, err := client.DescribeUGA3Area(areaGetReq) + if err != nil { + ctx.HandleError(err) + return + } + forwardAreas := response.AreaSet + if len(forwardAreas) == 0 { + ctx.HandleError(fmt.Errorf("Not found the origin area list")) + return + } + forwardArea := forwardAreas[0] + fmt.Fprintf(w, "Recommend origin area:(%s)\n", forwardArea.ContinentCode) + ctx.PrintList([]PathxOptionalAreaRow{{ + AreaCode: forwardArea.AreaCode, + Area: forwardArea.Area, + CountryCode: forwardArea.CountryCode, + FlagUnicode: forwardArea.FlagUnicode, + FlagEmoji: forwardArea.FlagEmoji, + }}) + fmt.Fprintln(w) + if !noAccel { + areaCode := forwardAreas[0].AreaCode + optimizationReq.AreaCode = &areaCode + optimizationReq.AccelerationArea = &accelerationArea + optimizationReq.TimeRange = &timeRange + optimizationReq.SetProjectIdRef(areaGetReq.GetProjectIdRef()) + optimizationReq.SetRegionRef(areaGetReq.GetRegionRef()) + optimizationReq.SetZoneRef(areaGetReq.GetZoneRef()) + optimizationResponse, err := client.DescribeUGA3Optimization(optimizationReq) + if err != nil { + ctx.HandleError(err) + return + } + accelerationInfos := optimizationResponse.AccelerationInfos + if len(accelerationInfos) == 0 { + ctx.HandleError(fmt.Errorf("Not found the acceleration area information.")) + return + } + fmt.Fprintln(w, "Acceleration areas :") + for _, item := range accelerationInfos { + if len(accelerationArea) == 0 { + fmt.Fprintf(w, "%s(%s):\n", item.AccelerationName, item.AccelerationArea) + } + list := make([]PathxOptimizationRow, 0) + for _, node := range item.NodeInfo { + list = append(list, PathxOptimizationRow{ + Area: node.Area, + AreaCode: node.AreaCode, + CountryCode: node.CountryCode, + FlagUnicode: node.FlagUnicode, + FlagEmoji: node.FlagEmoji, + Latency: fmt.Sprintf("%s%s", strconv.FormatFloat(node.Latency, 'g', 12, 64), "ms"), + LatencyWAN: fmt.Sprintf("%s%s", strconv.FormatFloat(node.LatencyInternet, 'g', 12, 64), "ms"), + LatencyPathX: fmt.Sprintf("%s%s", strconv.FormatFloat(node.LatencyOptimization, 'g', 12, 64), "%"), + Loss: fmt.Sprintf("%s%s", strconv.FormatFloat(node.Loss, 'g', 12, 64), "%"), + LossWAN: fmt.Sprintf("%s%s", strconv.FormatFloat(node.LossInternet, 'g', 12, 64), "%"), + LossPathx: fmt.Sprintf("%s%s", strconv.FormatFloat(node.LossOptimization, 'g', 12, 64), "%"), + }) + } + ctx.PrintList(list) + } + } + }, + } + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindProjectID(cmd, areaGetReq) + ctx.BindRegion(cmd, areaGetReq) + ctx.BindZone(cmd, areaGetReq) + flags.StringVar(&timeRange, "time-range", "", "Optional. The default value is 1 day. Acceptable values:'Hour','Day','Week',and its value is not case sensitive") + flags.StringVar(&accelerationArea, "accel", "", "Optional. The acceleration area,acceptable values:'Global','AP','EU','ME','OA','AF','NA','SA'") + flags.StringVar(&originDomain, "origin-domain", "", "Optional. If you fill in the IP or domain name, a region will be recommended as the first in the return list") + flags.StringVar(&originIp, "origin-ip", "", "Optional. If you fill in the IP or domain name, a region will be recommended as the first IP collection of the source station in the return list, split by ',' example:110.10.10.1,111.100.0.10 ") + flags.BoolVar(&noAccel, "no-accel", false, "Optional. If it is specified,the print result will not be displayed acceleration areas") + return cmd +} diff --git a/products/pathx/internal/pathx/cmd.go b/products/pathx/internal/pathx/cmd.go new file mode 100644 index 0000000000..ed1f4b4802 --- /dev/null +++ b/products/pathx/internal/pathx/cmd.go @@ -0,0 +1,25 @@ +package pathx + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `pathx` root command. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "pathx", + Short: "Manipulate uga and upath instances", + Long: "Manipulate uga and upath instances", + } + cmd.AddCommand(newUGA(ctx)) + cmd.AddCommand(newUpath(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newModify(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newPrice(ctx)) + cmd.AddCommand(newArea(ctx)) + return cmd +} diff --git a/products/pathx/internal/pathx/completion.go b/products/pathx/internal/pathx/completion.go new file mode 100644 index 0000000000..978231fff1 --- /dev/null +++ b/products/pathx/internal/pathx/completion.go @@ -0,0 +1,78 @@ +package pathx + +import ( + "fmt" + + ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func getPathxList(ctx *cli.Context, project, region, zone string) []string { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + req := client.NewDescribeUGA3InstanceRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + req.Limit = sdk.Int(50) + resp, err := client.DescribeUGA3Instance(req) + if err != nil { + ctx.HandleError(err) + return nil + } + list := make([]string, 0) + for _, item := range resp.ForwardInstanceInfos { + list = append(list, item.InstanceId) + } + return list +} + +func getUGAList(ctx *cli.Context, project string) ([]ppathx.UGAAInfo, error) { + client := cli.NewServiceClient(ctx, ppathx.NewClient) + req := client.NewDescribeUGAInstanceRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + resp, err := client.DescribeUGAInstance(req) + if err != nil { + return nil, err + } + return resp.UGAList, nil +} + +func getUGAIDList(ctx *cli.Context, project string) []string { + list, err := getUGAList(ctx, project) + if err != nil { + ctx.LogError(fmt.Sprintf("getUGAIDList failed:%v", err)) + return nil + } + strs := make([]string, 0) + for _, ins := range list { + strs = append(strs, fmt.Sprintf("%s/%s", ins.UGAId, ins.UGAName)) + } + return strs +} + +func getUpathList(ctx *cli.Context, project string) ([]ppathx.UPathInfo, error) { + client := cli.NewServiceClient(ctx, ppathx.NewClient) + req := client.NewDescribeUPathRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + resp, err := client.DescribeUPath(req) + if err != nil { + return nil, err + } + return resp.UPathSet, nil +} + +func getUpathIDList(ctx *cli.Context, project string) []string { + list, err := getUpathList(ctx, project) + if err != nil { + ctx.LogError(fmt.Sprintf("getUpathIDList failed:%v", err)) + return nil + } + strs := make([]string, 0) + for _, ins := range list { + strs = append(strs, fmt.Sprintf("%s/%s", ins.UPathId, ins.Name)) + } + return strs +} diff --git a/products/pathx/internal/pathx/create.go b/products/pathx/internal/pathx/create.go new file mode 100644 index 0000000000..29f7dd93f1 --- /dev/null +++ b/products/pathx/internal/pathx/create.go @@ -0,0 +1,145 @@ +package pathx + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud pathx create +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + createPathxReq := client.NewCreateUGA3InstanceRequest() + createPathxPortReq := client.NewCreateUGA3PortRequest() + var ports, originPorts []string + protocol := "tcp" + createCmd := &cobra.Command{ + Use: "create", + Short: "Create the pathx resource and port", + Long: "Create global unified access acceleration configuration item", + Example: "ucloud pathx create --bandwidth 10 --area-code DXB" + + "--charge-type Month --quantity 4 --accel Global --origin-ip 110.111.111.111" + + "--protocol TCP --port 30654 --origin-port 30564", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + fmt.Fprintln(w, "The pathx resource creating") + if *createPathxReq.OriginIPList == "" && *createPathxReq.OriginDomain == "" { + ctx.HandleError(fmt.Errorf("The origin-ip and origin-domain cannot be empty at the same time")) + return + } + portIntList := make([]int, 0) + originPortIntList := make([]int, 0) + if len(ports) > 0 || len(originPorts) > 0 { + if len(ports) == 0 { + ctx.HandleError(fmt.Errorf("The port cannot be empty.")) + return + } else if len(originPorts) == 0 { + ctx.HandleError(fmt.Errorf("The origin-port cannot be empty.")) + return + } + if strings.EqualFold(protocol, "UDP") { + ctx.HandleError(fmt.Errorf("The udp protocol is temporarily not supported for create")) + return + } else if !strings.EqualFold(protocol, "TCP") && !strings.EqualFold(protocol, "UDP") { + ctx.HandleError(fmt.Errorf("The value of protocol input error,please input 'TCP' or 'UDP',and the value entered is not case sensitive")) + return + } + tcpPortList, err := formatPortList(ports) + if err != nil { + ctx.HandleError(err) + return + } + for _, tcpPort := range tcpPortList { + port, _ := strconv.Atoi(tcpPort) + portIntList = append(portIntList, port) + } + rsTcpPortList, err := formatPortList(originPorts) + if err != nil { + ctx.HandleError(err) + return + } + for _, rsTcpPort := range rsTcpPortList { + rsPort, _ := strconv.Atoi(rsTcpPort) + originPortIntList = append(originPortIntList, rsPort) + } + if len(portIntList) != len(originPortIntList) { + ctx.HandleError(fmt.Errorf("The number of port must be consistent with the number of origin-port.")) + return + } else if len(portIntList) >= 10 { + ctx.HandleError(fmt.Errorf("The number of port cannot greater than or equals to 10")) + return + } + } + if strings.EqualFold(*createPathxReq.ChargeType, "Month") { + *createPathxReq.Quantity = 0 + } else if *createPathxReq.Quantity <= 0 { + ctx.HandleError(fmt.Errorf("If the value of charge-type is 'Year' or 'Hour',the value of quantity must be greater than 0")) + return + } + switch strings.ToLower(*createPathxReq.ChargeType) { + case "hour": + *createPathxReq.ChargeType = "Dynamic" + case "month": + *createPathxReq.ChargeType = "Month" + case "year": + *createPathxReq.ChargeType = "Year" + } + createUGA3InstanceResp, err := client.CreateUGA3Instance(createPathxReq) + if err != nil { + ctx.HandleError(err) + return + } + if createUGA3InstanceResp == nil || createUGA3InstanceResp.InstanceId == "" { + ctx.HandleError(fmt.Errorf("An unknown error occurred and could not be created successfully.")) + return + } + if len(portIntList) > 0 && len(originPortIntList) > 0 { + createPathxPortReq.InstanceId = &createUGA3InstanceResp.InstanceId + createPathxPortReq.SetRegionRef(createPathxReq.GetRegionRef()) + createPathxPortReq.SetProjectIdRef(createPathxReq.GetProjectIdRef()) + createPathxPortReq.SetZoneRef(createPathxReq.GetZoneRef()) + fmt.Fprintln(w, "The pathx port creating") + if strings.EqualFold(protocol, "TCP") { + createPathxPortReq.TCP = portIntList + createPathxPortReq.TCPRS = originPortIntList + } + _, err := client.CreateUGA3Port(createPathxPortReq) + if err != nil { + ctx.HandleError(err) + return + } + } + fmt.Fprintf(w, "The resource is created, and the resource ID is: %s\n", createUGA3InstanceResp.InstanceId) + ctx.EmitResult(cli.OpResultRow{ResourceID: createUGA3InstanceResp.InstanceId, Action: "create", Status: "Created"}) + }, + } + flags := createCmd.Flags() + flags.SortFlags = false + + ctx.BindProjectID(createCmd, createPathxReq) + ctx.BindRegion(createCmd, createPathxReq) + ctx.BindZone(createCmd, createPathxReq) + createPathxReq.Bandwidth = flags.String("bandwidth", "0", "Required. Shared bandwidth of the resource") + createPathxReq.AreaCode = flags.String("area-code", "", "Optional. When it is empty,the nearest zone will be selected based on the origin-domain and origin-ip. Acceptable values:'BKK'(曼谷),'DXB'(迪拜),'FRA'(法兰克福),'SGN'(胡志明市),'HKG'(香港),'CGK'(雅加达),'LOS'(拉各斯),'LHR'(伦敦),'LAX'(洛杉矶),'MNL'(马尼拉),'DME'(莫斯科),'BOM'(孟买),'MSP'(圣保罗),'ICN'(首尔),'PVG'(上海),'SIN'(新加坡),'NRT'(东京),'IAD'(华盛顿),'TPE'(台北)") + createPathxReq.ChargeType = flags.String("charge-type", "", "Optional. Payment method,its value is not case sensitive,acceptable values:'Year',pay yearly;'Month',pay monthly;'Hour', pay hourly") + createPathxReq.Quantity = flags.Int("quantity", 1, "Optional. The duration of the pathx resource, the value cannot be less than or equal to 0. N years/months") + createPathxReq.AccelerationArea = flags.String("accel", "", "Optional. The default value is 'Global'(全球). Other acceptable values:'AP'(亚太);'EU'(欧洲);'ME'(中东);'OA'(大洋洲);'AF'(非洲);'NA'(北美洲);'SA'(南美洲)") + createPathxReq.OriginIPList = flags.String("origin-ip", "", "Optional. But when the origin-domain is empty,it cannot be empty. If multiple values exist,please split by ','. For example '0.0.0.0,110.110.100.100'") + createPathxReq.OriginDomain = flags.String("origin-domain", "", "Optional. But when the origin-ip is empty,it cannot be empty") + flags.StringSliceVar(&ports, "port", nil, "Optional. Disable 65123 port,the port can be multiple,please split by ',' for example 80,3000-3010. The number of port must be consistent with the number of origin-port,and the number cannot greater than or equals to 10") + flags.StringSliceVar(&originPorts, "origin-port", nil, "Optional. The origin-port can be multiple,please split by ',' for example 80,3000-3010.The number of origin-port must be consistent with the number of port") + flags.StringVar(&protocol, "protocol", "TCP", "Its values can be TCP and UDP, but currently only supports TCP") + createCmd.MarkFlagRequired("bandwidth") + command.SetFlagValues(createCmd, "area-code", "BKK", "DXB", "FRA", "SGN", "HKG", "CGK", "LOS", "LHR", "LAX", "MNL", "DME", "BOM", "MSP", "ICN", "PVG", "SIN", "NRT", "IAD", "TPE") + command.SetFlagValues(createCmd, "charge-type", "Month", "Year", "Hour") + command.SetFlagValues(createCmd, "accel", "Global", "AP", "EU", "ME", "OA", "AF", "NA", "SA") + command.SetFlagValues(createCmd, "protocol", "TCP", "UDP") + return createCmd +} diff --git a/products/pathx/internal/pathx/delete.go b/products/pathx/internal/pathx/delete.go new file mode 100644 index 0000000000..97a6efec78 --- /dev/null +++ b/products/pathx/internal/pathx/delete.go @@ -0,0 +1,69 @@ +package pathx + +import ( + "fmt" + + "github.com/spf13/cobra" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete ucloud pathx delete +func newDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + deleteUga3Req := client.NewDeleteUGA3InstanceRequest() + deleteUga3PortReq := client.NewDeleteUGA3PortRequest() + yes := false + var instanceId string + removeCmd := &cobra.Command{ + Use: "delete", + Short: "Delete the pathx resource and port", + Long: "Delete the pathx resource and port", + Example: "ucloud pathx delete --id uga3-xxx", + Run: func(cmd *cobra.Command, args []string) { + if !yes { + ok, err := ctx.Confirm(false, "Are you sure you want to delete this resource ?") + if err != nil { + fmt.Fprintln(ctx.ProgressWriter(), err) + return + } + if !ok { + return + } + } + w := ctx.ProgressWriter() + fmt.Fprintf(w, "Starting delete the pathx[%s] resource port\n", instanceId) + deleteUga3PortReq.InstanceId = &instanceId + _, deletePortErr := client.DeleteUGA3Port(deleteUga3PortReq) + if deletePortErr != nil { + ctx.HandleError(deletePortErr) + return + } + fmt.Fprintf(w, "Starting delete the pathx[%s] resource\n", instanceId) + deleteUga3Req.InstanceId = &instanceId + deleteUga3Req.SetProjectIdRef(deleteUga3PortReq.GetProjectIdRef()) + deleteUga3Req.SetRegionRef(deleteUga3PortReq.GetRegionRef()) + deleteUga3Req.SetZoneRef(deleteUga3PortReq.GetZoneRef()) + _, err := client.DeleteUGA3Instance(deleteUga3Req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceId, Action: "delete", Status: "Deleted"}) + }, + } + flags := removeCmd.Flags() + flags.SortFlags = false + flags.StringVar(&instanceId, "id", "", "Required. It is the resource ID of pathx, and the deletion will be performed according to this") + ctx.BindProjectID(removeCmd, deleteUga3PortReq) + ctx.BindRegion(removeCmd, deleteUga3PortReq) + ctx.BindZone(removeCmd, deleteUga3PortReq) + removeCmd.MarkFlagRequired("id") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.") + ctx.SetCompletion(removeCmd, "id", func() []string { + return getPathxList(ctx, *deleteUga3PortReq.ProjectId, *deleteUga3PortReq.Region, *deleteUga3PortReq.Zone) + }) + return removeCmd +} diff --git a/products/pathx/internal/pathx/format.go b/products/pathx/internal/pathx/format.go new file mode 100644 index 0000000000..e023d01f14 --- /dev/null +++ b/products/pathx/internal/pathx/format.go @@ -0,0 +1,152 @@ +package pathx + +import ( + "fmt" + "io" + "strconv" + "strings" + + ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +var protocols = []string{"tcp", "udp"} + +var regionLabel = map[string]string{ + "cn-bj1": "Beijing1", + "cn-bj2": "Beijing2", + "cn-sh2": "Shanghai2", + "cn-gd": "Guangzhou", + "cn-qz": "Quanzhou", + "hk": "Hongkong", + "us-ca": "LosAngeles", + "us-ws": "Washington", + "ge-fra": "Frankfurt", + "th-bkk": "Bangkok", + "kr-seoul": "Seoul", + "sg": "Singapore", + "tw-kh": "Kaohsiung", + "rus-mosc": "Moscow", + "jpn-tky": "Tokyo", + "tw-tp": "TaiPei", + "uae-dubai": "Dubai", + "idn-jakarta": "Jakarta", + "ind-mumbai": "Bombay", + "bra-saopaulo": "SaoPaulo", + "uk-london": "London", + "afr-nigeria": "Lagos", +} + +func formatPortList(userPorts []string) ([]string, error) { + portList := make([]string, 0) + for _, port := range userPorts { + if strings.Contains(port, "-") { + portRange := strings.Split(port, "-") + if len(portRange) != 2 { + return nil, fmt.Errorf("port %s is invalid, it's pattern should be like 3000-3100", port) + } + min, err := strconv.Atoi(portRange[0]) + if err != nil { + return nil, fmt.Errorf("parse port failed: %v", err) + } + max, err := strconv.Atoi(portRange[1]) + if err != nil { + return nil, fmt.Errorf("parse port failed: %v", err) + } + for i := min; i <= max; i++ { + portList = append(portList, strconv.Itoa(i)) + } + } else { + portList = append(portList, port) + } + } + return portList, nil +} + +func getUpathStr(list []ppathx.UPathSet) string { + paths := make([]string, 0) + for _, p := range list { + paths = append(paths, fmt.Sprintf("%s->%s %dM", p.LineFromName, p.LineToName, p.Bandwidth)) + } + return strings.Join(paths, "\n") +} + +func getOutIPStr(list []ppathx.OutPublicIpInfo) string { + strs := make([]string, 0) + for _, p := range list { + strs = append(strs, fmt.Sprintf("%s %s", p.IP, regionLabel[p.Area])) + } + return strings.Join(strs, "\n") +} + +func getPortStr(list []ppathx.UGAATask) string { + strs := make([]string, 0) + for _, t := range list { + strs = append(strs, fmt.Sprintf("%s %d", t.Protocol, t.Port)) + } + return strings.Join(strs, "\n") +} + +func printPathxDetail(ctx *cli.Context, instanceInfo pathxsdk.ForwardInfo, out io.Writer) { + attrs := []describeRow{ + {Attribute: "ResourceID", Content: instanceInfo.InstanceId}, + {Attribute: "CName", Content: instanceInfo.CName}, + {Attribute: "Name", Content: instanceInfo.Name}, + {Attribute: "AccelerationArea", Content: instanceInfo.AccelerationArea}, + {Attribute: "AccelerationAreaName", Content: instanceInfo.AccelerationAreaName}, + {Attribute: "OriginAreaCode", Content: instanceInfo.OriginAreaCode}, + {Attribute: "OriginArea", Content: instanceInfo.OriginArea}, + {Attribute: "Bandwidth", Content: strconv.Itoa(instanceInfo.Bandwidth)}, + {Attribute: "ChargeType", Content: instanceInfo.ChargeType}, + {Attribute: "IPList", Content: strings.Join(instanceInfo.IPList, ",")}, + {Attribute: "Domain", Content: instanceInfo.Domain}, + {Attribute: "Remark", Content: instanceInfo.Remark}, + {Attribute: "CreateTime", Content: common.FormatDateTime(instanceInfo.CreateTime)}, + {Attribute: "ExpireTime", Content: common.FormatDateTime(instanceInfo.ExpireTime)}, + } + for _, attr := range attrs { + fmt.Fprintf(out, "%-22s: %s\n", attr.Attribute, attr.Content) + } + if len(instanceInfo.AccelerationAreaInfos) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, "Acceleration area list:") + for _, area := range instanceInfo.AccelerationAreaInfos { + fmt.Fprintf(out, "%s:%5s\n", "Area", area.AccelerationArea) + areaList := make([]PathxOptionalAreaRow, 0) + for _, node := range area.AccelerationNodes { + areaList = append(areaList, PathxOptionalAreaRow{ + AreaCode: node.AreaCode, + Area: node.Area, + FlagUnicode: node.FlagUnicode, + FlagEmoji: node.FlagEmoji, + }) + } + ctx.PrintList(areaList) + } + } + if len(instanceInfo.EgressIpList) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, "Egress ip list:") + egressIpList := make([]EgressIpInfoRow, 0) + for _, egressIp := range instanceInfo.EgressIpList { + egressIpList = append(egressIpList, EgressIpInfoRow{IP: egressIp.IP, Area: egressIp.Area}) + } + ctx.PrintList(egressIpList) + } + if len(instanceInfo.PortSets) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, "Port list:") + portList := make([]Uga3PortRow, 0) + for _, portItem := range instanceInfo.PortSets { + portList = append(portList, Uga3PortRow{ + Protocol: portItem.Protocol, + Port: portItem.Port, + RSPort: portItem.RSPort, + }) + } + ctx.PrintList(portList) + } +} diff --git a/products/pathx/internal/pathx/list.go b/products/pathx/internal/pathx/list.go new file mode 100644 index 0000000000..19aab698dd --- /dev/null +++ b/products/pathx/internal/pathx/list.go @@ -0,0 +1,77 @@ +package pathx + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList ucloud pathx list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + getPathxListReq := client.NewDescribeUGA3InstanceRequest() + var instanceId string + var detail bool + listCmd := &cobra.Command{ + Use: "list", + Short: "List all the pathx resource of project", + Long: "List all the pathx resource of project", + Example: "'ucloud pathx list or ucloud pathx list --id uga-xxx or ucloud pathx list --id uga-xxx --detail", + Run: func(cmd *cobra.Command, args []string) { + if len(instanceId) > 0 { + getPathxListReq.InstanceId = &instanceId + } + resp, err := client.DescribeUGA3Instance(getPathxListReq) + if err != nil { + ctx.HandleError(err) + return + } + forwardInfos := resp.ForwardInstanceInfos + if len(forwardInfos) == 0 { + ctx.HandleError(fmt.Errorf("No pathx resource found under the current project.")) + return + } + if detail && len(instanceId) > 0 { + printPathxDetail(ctx, forwardInfos[0], ctx.ProgressWriter()) + return + } + list := make([]Uga3DescribeRow, 0) + for _, item := range forwardInfos { + egressIps := []string{} + for _, egressIp := range item.EgressIpList { + egressIps = append(egressIps, fmt.Sprintf("%s:%s", egressIp.Area, egressIp.IP)) + } + list = append(list, Uga3DescribeRow{ + ResourceID: item.InstanceId, + CName: item.CName, + Name: item.Name, + AccelerationArea: item.AccelerationArea, + Bandwidth: item.Bandwidth, + OriginAreaCode: item.OriginAreaCode, + IPList: strings.Join(item.IPList, ","), + Domain: item.Domain, + CreateTime: common.FormatDate(item.CreateTime), + EgressIpList: strings.Join(egressIps, "|"), + }) + } + ctx.PrintList(list) + }, + } + flags := listCmd.Flags() + flags.SortFlags = false + ctx.BindProjectID(listCmd, getPathxListReq) + ctx.BindRegion(listCmd, getPathxListReq) + ctx.BindZone(listCmd, getPathxListReq) + flags.StringVar(&instanceId, "id", "", "Required. It is the resource ID of pathx resource") + flags.BoolVar(&detail, "detail", false, "Optional. If it is specified,the details will be printed") + ctx.SetCompletion(listCmd, "id", func() []string { + return getPathxList(ctx, *getPathxListReq.ProjectId, *getPathxListReq.Region, *getPathxListReq.Zone) + }) + return listCmd +} diff --git a/products/pathx/internal/pathx/modify.go b/products/pathx/internal/pathx/modify.go new file mode 100644 index 0000000000..3c91e1d7d9 --- /dev/null +++ b/products/pathx/internal/pathx/modify.go @@ -0,0 +1,155 @@ +package pathx + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newModify ucloud pathx modify +func newModify(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + modifyBandwidthReq := client.NewModifyUGA3BandwidthRequest() + modifyOriginInfoReq := client.NewModifyUGA3OriginInfoRequest() + modifyInstanceReq := client.NewModifyUGA3InstanceRequest() + modifyPortReq := client.NewModifyUGA3PortRequest() + var tcpPorts, rsTcpPorts []string + var instanceId string + protocol := "TCP" + modifyCmd := &cobra.Command{ + Use: "modify", + Short: "Modify the pathx associated information. Example bandwidth or origin information or resource information", + Long: "Support modify bandwidth,origin information,resource information,port", + Example: "ucloud pathx modify --id uga3-xxx --bandwidth 1 --origin-ip 127.0.0.1 --name Pathx测试 --remark 加速资源 --protocol TCP --port 30010 --origin-port 39999", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + modifyBandwidthReq.InstanceId = &instanceId + modifyInstanceReq.InstanceId = &instanceId + modifyOriginInfoReq.InstanceId = &instanceId + modifyPortReq.InstanceId = &instanceId + results := []cli.OpResultRow{} + if *modifyBandwidthReq.Bandwidth != 0 { + fmt.Fprintf(w, "Starting modify the pathx[%s] bandwidth\n", instanceId) + if *modifyBandwidthReq.Bandwidth < 1 || *modifyBandwidthReq.Bandwidth > 100 { + ctx.HandleError(fmt.Errorf("The value of bandwidth size cannot be less than 1 and cannot be greater than 100")) + return + } + modifyBandwidthReq.SetProjectIdRef(modifyInstanceReq.GetProjectIdRef()) + modifyBandwidthReq.SetRegionRef(modifyInstanceReq.GetRegionRef()) + modifyBandwidthReq.SetZoneRef(modifyInstanceReq.GetZoneRef()) + _, err := client.ModifyUGA3Bandwidth(modifyBandwidthReq) + if err != nil { + ctx.HandleError(err) + return + } + results = append(results, cli.OpResultRow{ResourceID: instanceId, Action: "modify-bandwidth", Status: "Modified"}) + } + if *modifyOriginInfoReq.OriginIPList != "" || *modifyOriginInfoReq.OriginDomain != "" { + fmt.Fprintf(w, "Starting modify the pathx[%s] origin information\n", instanceId) + modifyOriginInfoReq.SetProjectIdRef(modifyInstanceReq.GetProjectIdRef()) + modifyOriginInfoReq.SetRegionRef(modifyInstanceReq.GetRegionRef()) + modifyOriginInfoReq.SetZoneRef(modifyInstanceReq.GetZoneRef()) + _, err := client.ModifyUGA3OriginInfo(modifyOriginInfoReq) + if err != nil { + ctx.HandleError(err) + return + } + results = append(results, cli.OpResultRow{ResourceID: instanceId, Action: "modify-origin", Status: "Modified"}) + } + if *modifyInstanceReq.Name != "" || *modifyInstanceReq.Remark != "" { + fmt.Fprintf(w, "Starting modify the pathx[%s] resource information\n", instanceId) + _, err := client.ModifyUGA3Instance(modifyInstanceReq) + if err != nil { + ctx.HandleError(err) + return + } + results = append(results, cli.OpResultRow{ResourceID: instanceId, Action: "modify", Status: "Modified"}) + } + tcpPortIntList := make([]int, 0) + rsTcpPortIntList := make([]int, 0) + if len(tcpPorts) > 0 || len(rsTcpPorts) > 0 { + fmt.Fprintf(w, "Starting modify the pathx[%s] port\n", instanceId) + if len(tcpPorts) == 0 { + ctx.HandleError(fmt.Errorf("The port cannot be empty.")) + return + } else if len(rsTcpPorts) == 0 { + ctx.HandleError(fmt.Errorf("The origin-port cannot be empty.")) + return + } + if strings.EqualFold(protocol, "UDP") { + ctx.HandleError(fmt.Errorf("The udp protocol is temporarily not supported for create")) + return + } else if !strings.EqualFold(protocol, "TCP") && !strings.EqualFold(protocol, "UDP") { + ctx.HandleError(fmt.Errorf("The value of protocol input error,please input 'TCP' or 'UDP',and the value entered is not case sensitive")) + return + } + tcpPortList, err := formatPortList(tcpPorts) + if err != nil { + ctx.HandleError(err) + return + } + for _, tcpPort := range tcpPortList { + port, _ := strconv.Atoi(tcpPort) + tcpPortIntList = append(tcpPortIntList, port) + } + rsTcpPortList, err := formatPortList(rsTcpPorts) + if err != nil { + ctx.HandleError(err) + return + } + for _, rsTcpPort := range rsTcpPortList { + rsPort, _ := strconv.Atoi(rsTcpPort) + rsTcpPortIntList = append(rsTcpPortIntList, rsPort) + } + if len(tcpPortIntList) != len(rsTcpPortIntList) { + ctx.HandleError(fmt.Errorf("The number of port must be consistent with the number of origin-port.")) + return + } else if len(tcpPortIntList) >= 10 { + ctx.HandleError(fmt.Errorf("The number of port cannot greater than or equals to 10")) + return + } + } + if len(tcpPortIntList) > 0 && len(rsTcpPortIntList) > 0 { + if strings.EqualFold(protocol, "TCP") { + modifyPortReq.TCP = tcpPortIntList + modifyPortReq.TCPRS = rsTcpPortIntList + } + modifyPortReq.SetProjectIdRef(modifyInstanceReq.GetProjectIdRef()) + modifyPortReq.SetRegionRef(modifyInstanceReq.GetRegionRef()) + modifyPortReq.SetZoneRef(modifyInstanceReq.GetZoneRef()) + _, err := client.ModifyUGA3Port(modifyPortReq) + if err != nil { + ctx.HandleError(err) + return + } + results = append(results, cli.OpResultRow{ResourceID: instanceId, Action: "modify-port", Status: "Modified"}) + } + ctx.EmitResult(results...) + }, + } + flags := modifyCmd.Flags() + flags.SortFlags = false + ctx.BindProjectID(modifyCmd, modifyInstanceReq) + ctx.BindRegion(modifyCmd, modifyInstanceReq) + ctx.BindZone(modifyCmd, modifyInstanceReq) + flags.StringVar(&instanceId, "id", "", "Required. It is the resource ID of the pathx") + modifyBandwidthReq.Bandwidth = flags.Int("bandwidth", 0, "Optional. The bandwidth size. Its value range [1-100],no update if no value is specified") + modifyOriginInfoReq.OriginIPList = flags.String("origin-ip", "", "Optional. Acceleration source IP. If multiple values exist,please split by ','") + modifyOriginInfoReq.OriginDomain = flags.String("origin-domain", "", "Optional. Acceleration source domain name. Only 1 domain is supported") + modifyInstanceReq.Name = flags.String("name", "", "Optional. Accelerate configuration resource name. If its value is not filled in or an empty string is not updated") + modifyInstanceReq.Remark = flags.String("remark", "", "Optional. It will be modified if its value is not empty") + flags.StringSliceVar(&tcpPorts, "port", nil, "Optional. Disable 65123 port,the port can be multiple,please split by ',' for example 80,3000-3010. The number of port must be consistent with the number of origin-port,and the number cannot greater than or equals to 10") + flags.StringSliceVar(&rsTcpPorts, "origin-port", nil, "Optional. The origin-port can be multiple,please split by ',' for example 80,3000-3010.The number of origin-port must be consistent with the number of port") + flags.StringVar(&protocol, "protocol", "TCP", "Its values can be TCP and UDP, but currently only supports TCP") + modifyCmd.MarkFlagRequired("id") + ctx.SetCompletion(modifyCmd, "id", func() []string { + return getPathxList(ctx, *modifyInstanceReq.ProjectId, *modifyInstanceReq.Region, *modifyInstanceReq.Zone) + }) + return modifyCmd +} diff --git a/products/pathx/internal/pathx/price.go b/products/pathx/internal/pathx/price.go new file mode 100644 index 0000000000..0246d2eb7b --- /dev/null +++ b/products/pathx/internal/pathx/price.go @@ -0,0 +1,18 @@ +package pathx + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPrice builds `ucloud pathx price`. +func newPrice(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "price", + Short: "List all the acceleration area price", + Long: "List all the acceleration area price", + } + cmd.AddCommand(newPriceList(ctx)) + return cmd +} diff --git a/products/pathx/internal/pathx/price_list.go b/products/pathx/internal/pathx/price_list.go new file mode 100644 index 0000000000..c4918880c6 --- /dev/null +++ b/products/pathx/internal/pathx/price_list.go @@ -0,0 +1,77 @@ +package pathx + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + pathxsdk "github.com/ucloud/ucloud-sdk-go/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newPriceList ucloud pathx price list +func newPriceList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, pathxsdk.NewClient) + priceReq := client.NewGetUGA3PriceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List all the pathx acceleration area price", + Long: "List all the pathx acceleration area price", + Example: "ucloud pathx price list --bandwidth 10 --area-code BKK --charge-type Month", + Run: func(cmd *cobra.Command, args []string) { + if strings.EqualFold(*priceReq.ChargeType, "Month") { + *priceReq.Quantity = 0 + } else if *priceReq.Quantity <= 0 { + ctx.HandleError(fmt.Errorf("If the value of charge-type is 'Year' or 'Hour',its value must be greater than 0")) + return + } + switch strings.ToLower(*priceReq.ChargeType) { + case "hour": + *priceReq.ChargeType = "Dynamic" + case "month": + *priceReq.ChargeType = "Month" + case "year": + *priceReq.ChargeType = "Year" + } + response, err := client.GetUGA3Price(priceReq) + if err != nil { + ctx.HandleError(err) + return + } + priceList := response.UGA3Price + if len(priceList) == 0 { + ctx.HandleError(fmt.Errorf("Not found acceleration area price information.")) + return + } + list := make([]UGA3PriceRow, 0) + for _, info := range priceList { + list = append(list, UGA3PriceRow{ + AccelerationBandwidthPrice: fmt.Sprintf("%s%s", "¥", strconv.FormatFloat(info.AccelerationBandwidthPrice, 'g', 12, 64)), + AccelerationForwarderPrice: fmt.Sprintf("%s%s", "¥", strconv.FormatFloat(info.AccelerationForwarderPrice, 'g', 12, 64)), + AccelerationArea: info.AccelerationArea, + }) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindProjectID(cmd, priceReq) + ctx.BindRegion(cmd, priceReq) + ctx.BindZone(cmd, priceReq) + priceReq.Bandwidth = flags.Int("bandwidth", 1, "Required. The bandwidth of acceleration area to get price") + priceReq.AreaCode = flags.String("area-code", "", "Required. The area-code of acceleration area to get price") + priceReq.Quantity = flags.Int("quantity", 1, "Optional. When the value of the charge-type is 'Month',its default value is 0,if the value of charge-type is 'Year' or 'Hour',its value must be greater than 0") + priceReq.ChargeType = flags.String("charge-type", "", "Optional. Its value is not case sensitive,acceptable values:'Year',pay yearly;'Month',pay monthly;'Hour',pay hourly") + priceReq.AccelerationArea = flags.String("accel", "", "Optional. The acceleration-area to get price") + _ = cmd.MarkFlagRequired("bandwidth") + _ = cmd.MarkFlagRequired("area-code") + command.SetFlagValues(cmd, "area-code", "BKK", "DXB", "FRA", "SGN", "HKG", "CGK", "LOS", "LHR", "LAX", "MNL", "DME", "BOM", "MSP", "ICN", "PVG", "SIN", "NRT", "IAD", "TPE") + command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Hour") + command.SetFlagValues(cmd, "accel", "Global", "AP", "EU", "ME", "OA", "AF", "NA", "SA") + return cmd +} diff --git a/products/pathx/internal/pathx/rows.go b/products/pathx/internal/pathx/rows.go new file mode 100644 index 0000000000..fad2c1d7aa --- /dev/null +++ b/products/pathx/internal/pathx/rows.go @@ -0,0 +1,88 @@ +package pathx + +type UGA3PriceRow struct { + AccelerationArea string + AccelerationAreaName string + AccelerationForwarderPrice string + AccelerationBandwidthPrice string +} + +type Uga3DescribeRow struct { + ResourceID string + CName string + Name string + AccelerationArea string + AccelerationAreaName string + EgressIpList string + Bandwidth int + Remark string + OriginArea string + OriginAreaCode string + CreateTime string + ExpireTime string + ChargeType string + IPList string + Domain string +} + +type Uga3PortRow struct { + Protocol string + RSPort int + Port int +} + +type PathxUpdatePriceRow struct { + InstanceId string + Bandwidth int + UpdatePrice float64 +} + +type PathxOptimizationRow struct { + AccelerationName string + AccelerationArea string + Area string + AreaCode string + CountryCode string + FlagUnicode string + FlagEmoji string + Latency string + LatencyWAN string + LatencyPathX string + Loss string + LossWAN string + LossPathx string +} + +type PathxOptionalAreaRow struct { + AreaCode string + Area string + CountryCode string + FlagUnicode string + FlagEmoji string + ContinentCode string +} + +type EgressIpInfoRow struct { + IP string + Area string +} + +type upathRow struct { + ResourceID string + UPathName string + AcceleratedPath string + BoundUGA string +} + +type UGARow struct { + ResourceID string + UGAName string + CName string + Origin string + AcceleratedPath string +} + +type describeRow struct { + Attribute string + Content string +} diff --git a/products/pathx/internal/pathx/uga.go b/products/pathx/internal/pathx/uga.go new file mode 100644 index 0000000000..ceb37e19fe --- /dev/null +++ b/products/pathx/internal/pathx/uga.go @@ -0,0 +1,23 @@ +package pathx + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUGA builds `ucloud pathx uga`. +func newUGA(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "uga", + Short: "Create,list,update and delete pathx uga instances", + Long: "Create,list,update and delete pathx uga instances", + } + cmd.AddCommand(newUGAList(ctx)) + cmd.AddCommand(newUGADescribe(ctx)) + cmd.AddCommand(newUGACreate(ctx)) + cmd.AddCommand(newUGADelete(ctx)) + cmd.AddCommand(newUGAAddPort(ctx)) + cmd.AddCommand(newUGARemovePort(ctx)) + return cmd +} diff --git a/products/pathx/internal/pathx/uga_add_port.go b/products/pathx/internal/pathx/uga_add_port.go new file mode 100644 index 0000000000..70060b17bd --- /dev/null +++ b/products/pathx/internal/pathx/uga_add_port.go @@ -0,0 +1,67 @@ +package pathx + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUGAAddPort ucloud pathx uga add-port +func newUGAAddPort(ctx *cli.Context) *cobra.Command { + var ports []string + var protocol string + client := cli.NewServiceClient(ctx, ppathx.NewClient) + req := client.NewAddUGATaskRequest() + cmd := &cobra.Command{ + Use: "add-port", + Short: "Add port for uga instance", + Long: "Add port for uga instance", + Run: func(c *cobra.Command, args []string) { + portList, err := formatPortList(ports) + if err != nil { + ctx.HandleError(err) + return + } + switch strings.ToLower(protocol) { + case "tcp": + req.TCP = portList + case "udp": + req.UDP = portList + case "http": + req.HTTP = portList + case "https": + req.HTTPS = portList + default: + fmt.Fprintf(ctx.ProgressWriter(), "protocol should be one of %s, received:%s\n", strings.Join(protocols, ","), protocol) + } + *req.UGAId = ctx.PickResourceID(*req.UGAId) + _, err = client.AddUGATask(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "port %v added\n", ports) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.UGAId, Action: "add-port", Status: "Added"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindProjectID(cmd, req) + req.UGAId = flags.String("uga-id", "", "Required. Resource ID of uga instance to add port") + flags.StringVar(&protocol, "protocol", "", fmt.Sprintf("Required. accept values: %s", strings.Join(protocols, ","))) + flags.StringSliceVar(&ports, "port", nil, "Required. Single port or port range, separated by ',', for example 80,3000-3010") + cmd.MarkFlagRequired("protocol") + cmd.MarkFlagRequired("uga-id") + cmd.MarkFlagRequired("port") + command.SetFlagValues(cmd, "protocol", protocols...) + ctx.SetCompletion(cmd, "uga-id", func() []string { + return getUGAIDList(ctx, *req.ProjectId) + }) + return cmd +} diff --git a/products/pathx/internal/pathx/uga_create.go b/products/pathx/internal/pathx/uga_create.go new file mode 100644 index 0000000000..b79717d901 --- /dev/null +++ b/products/pathx/internal/pathx/uga_create.go @@ -0,0 +1,98 @@ +package pathx + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUGACreate ucloud pathx uga create +func newUGACreate(ctx *cli.Context) *cobra.Command { + var protocol string + var ports, lines []string + client := cli.NewServiceClient(ctx, ppathx.NewClient) + req := client.NewCreateUGAInstanceRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create uga instance", + Long: "Create uga instance", + Example: "ucloud pathx uga create --name testcli1 --protocol tcp --origin-location 中国 --origin-domain lixiaojun.xyz --upath-id upath-auvfexxx/test_0 --port 80-90,100,110-115", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + if *req.IPList == "" && *req.Domain == "" { + fmt.Fprintln(w, "origin-ip and origin-domain can not be both empty") + return + } + portList, err := formatPortList(ports) + if err != nil { + ctx.HandleError(err) + return + } + switch strings.ToLower(protocol) { + case "tcp": + req.TCP = portList + case "udp": + req.UDP = portList + case "http": + req.HTTP = portList + case "https": + req.HTTPS = portList + default: + fmt.Fprintf(w, "protocol should be one of %s, received:%s\n", strings.Join(protocols, ","), protocol) + } + resp, err := client.CreateUGAInstance(req) + if err != nil { + if uErr, ok := err.(uerr.Error); ok && uErr.Code() == 33756 { + fmt.Fprintf(w, "The number of ports added exceeds the limit(50). We recommend that you could reduce the number of ports, then create an uga instance, \nand then add the remaining ports by executing 'ucloud pathx uga add-port --protocol %s --uga-id --port '\n", protocol) + } + return + } + fmt.Fprintf(w, "uga[%s] created\n", resp.UGAId) + results := []cli.OpResultRow{{ResourceID: resp.UGAId, Action: "create", Status: "Created"}} + for _, path := range lines { + p := ctx.PickResourceID(path) + bindReq := client.NewUGABindUPathRequest() + bindReq.ProjectId = req.ProjectId + bindReq.UGAId = sdk.String(resp.UGAId) + bindReq.UPathId = &p + _, err := client.UGABindUPath(bindReq) + if err != nil { + fmt.Fprintf(w, "bind uga[%s] and upath[%s] failed: %v\n", resp.UGAId, p, err) + } else { + fmt.Fprintf(w, "bound uga[%s] and upath[%s]\n", resp.UGAId, p) + results = append(results, cli.OpResultRow{ResourceID: p, Action: "bind-upath", Status: "Bound"}) + } + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindProjectID(cmd, req) + req.Name = flags.String("name", "", "Required. Name of uga instance to create") + req.IPList = flags.String("origin-ip", "", "Required if origin-domain is empty. IP address of origin. multiple IP address separated by ','") + req.Domain = flags.String("origin-domain", "", "Required if origin-ip is empty.") + req.Location = flags.String("origin-location", "", "Required. Location of origin ip or domain. accpet valeus:'中国','洛杉矶','法兰克福','中国香港','雅加达','孟买','东京','莫斯科','新加坡','曼谷','中国台北','华盛顿','首尔'") + flags.StringVar(&protocol, "protocol", "", fmt.Sprintf("Required. accept values: %s", strings.Join(protocols, ","))) + flags.StringSliceVar(&ports, "port", nil, "Required. Single port or port range, separated by ',', for example 80,3000-3010") + flags.StringSliceVar(&lines, "upath-id", nil, "Required. Accelerated path to bind with the uga instance to create. multiple upath-id separated by ','; see 'ucloud pathx upath list") + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("origin-location") + cmd.MarkFlagRequired("protocol") + cmd.MarkFlagRequired("port") + cmd.MarkFlagRequired("upath-id") + command.SetFlagValues(cmd, "origin-location", "中国", "洛杉矶", "法兰克福", "中国香港", "雅加达", "孟买", "东京", "莫斯科", "新加坡", "曼谷", "中国台北", "华盛顿", "首尔") + command.SetFlagValues(cmd, "protocol", protocols...) + ctx.SetCompletion(cmd, "upath-id", func() []string { + return getUpathIDList(ctx, *req.ProjectId) + }) + return cmd +} diff --git a/products/pathx/internal/pathx/uga_delete.go b/products/pathx/internal/pathx/uga_delete.go new file mode 100644 index 0000000000..aabc5291d0 --- /dev/null +++ b/products/pathx/internal/pathx/uga_delete.go @@ -0,0 +1,47 @@ +package pathx + +import ( + "fmt" + + "github.com/spf13/cobra" + + ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUGADelete ucloud pathx uga delete +func newUGADelete(ctx *cli.Context) *cobra.Command { + idNames := []string{} + client := cli.NewServiceClient(ctx, ppathx.NewClient) + req := client.NewDeleteUGAInstanceRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete uga instances", + Long: "Delete uga instances", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.UGAId = &id + _, err := client.DeleteUGAInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "uga[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindProjectID(cmd, req) + flags.StringSliceVar(&idNames, "uga-id", nil, "Required. Resource ID of uga instances to delete. Multiple resource ids separated by comma") + cmd.MarkFlagRequired("uga-id") + ctx.SetCompletion(cmd, "uga-id", func() []string { + return getUGAIDList(ctx, *req.ProjectId) + }) + return cmd +} diff --git a/products/pathx/internal/pathx/uga_describe.go b/products/pathx/internal/pathx/uga_describe.go new file mode 100644 index 0000000000..6956e9760f --- /dev/null +++ b/products/pathx/internal/pathx/uga_describe.go @@ -0,0 +1,55 @@ +package pathx + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUGADescribe ucloud pathx uga describe +func newUGADescribe(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ppathx.NewClient) + req := client.NewDescribeUGAInstanceRequest() + cmd := &cobra.Command{ + Use: "describe", + Short: "Display detail informations about uga instances", + Long: "Display detail informations about uga instances", + Run: func(c *cobra.Command, args []string) { + *req.UGAId = ctx.PickResourceID(*req.UGAId) + resp, err := client.DescribeUGAInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + if len(resp.UGAList) != 1 { + ctx.HandleError(fmt.Errorf("uga[%s] may not exist", *req.UGAId)) + return + } + ins := resp.UGAList[0] + list := []describeRow{ + {Attribute: "ResourceID", Content: ins.UGAId}, + {Attribute: "UGAName", Content: ins.UGAName}, + {Attribute: "Origin", Content: fmt.Sprintf("%s%s", ins.Domain, strings.Join(ins.IPList, ","))}, + {Attribute: "CName", Content: ins.CName}, + {Attribute: "AcceleratedPath", Content: getUpathStr(ins.UPathSet)}, + {Attribute: "OutIP", Content: getOutIPStr(ins.OutPublicIpList)}, + {Attribute: "Port", Content: getPortStr(ins.TaskSet)}, + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.UGAId = flags.String("uga-id", "", "Required. Resource ID of uga instance") + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("uga-id") + ctx.SetCompletion(cmd, "uga-id", func() []string { + return getUGAIDList(ctx, *req.ProjectId) + }) + return cmd +} diff --git a/products/pathx/internal/pathx/uga_list.go b/products/pathx/internal/pathx/uga_list.go new file mode 100644 index 0000000000..8d2a8b36ba --- /dev/null +++ b/products/pathx/internal/pathx/uga_list.go @@ -0,0 +1,47 @@ +package pathx + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUGAList ucloud pathx uga list +func newUGAList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ppathx.NewClient) + req := client.NewDescribeUGAInstanceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "list uga instances", + Long: "list uga instances", + Run: func(c *cobra.Command, args []string) { + *req.UGAId = ctx.PickResourceID(*req.UGAId) + resp, err := client.DescribeUGAInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + list := make([]UGARow, 0) + for _, ins := range resp.UGAList { + list = append(list, UGARow{ + ResourceID: ins.UGAId, + UGAName: ins.UGAName, + CName: ins.CName, + Origin: fmt.Sprintf("%s%s", strings.Join(ins.IPList, ","), ins.Domain), + AcceleratedPath: getUpathStr(ins.UPathSet), + }) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.UGAId = flags.String("uga-id", "", "Optional. Resource ID of uga instance") + ctx.BindProjectID(cmd, req) + return cmd +} diff --git a/products/pathx/internal/pathx/uga_remove_port.go b/products/pathx/internal/pathx/uga_remove_port.go new file mode 100644 index 0000000000..79c3704be5 --- /dev/null +++ b/products/pathx/internal/pathx/uga_remove_port.go @@ -0,0 +1,67 @@ +package pathx + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUGARemovePort ucloud pathx uga delete-port +func newUGARemovePort(ctx *cli.Context) *cobra.Command { + var ports []string + var protocol string + client := cli.NewServiceClient(ctx, ppathx.NewClient) + req := client.NewDeleteUGATaskRequest() + cmd := &cobra.Command{ + Use: "delete-port", + Short: "Delete port for uga instance", + Long: "Delete port for uga instance", + Run: func(c *cobra.Command, args []string) { + portList, err := formatPortList(ports) + if err != nil { + ctx.HandleError(err) + return + } + switch strings.ToLower(protocol) { + case "tcp": + req.TCP = portList + case "udp": + req.UDP = portList + case "http": + req.HTTP = portList + case "https": + req.HTTPS = portList + default: + fmt.Fprintf(ctx.ProgressWriter(), "protocol should be one of %s, received:%s\n", strings.Join(protocols, ","), protocol) + } + *req.UGAId = ctx.PickResourceID(*req.UGAId) + _, err = client.DeleteUGATask(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "port %v deleted\n", ports) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.UGAId, Action: "delete-port", Status: "Deleted"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindProjectID(cmd, req) + req.UGAId = flags.String("uga-id", "", "Required. Resource ID of uga instance to delete port") + flags.StringVar(&protocol, "protocol", "", fmt.Sprintf("Required. accept values: %s", strings.Join(protocols, ","))) + flags.StringSliceVar(&ports, "port", nil, "Required. Single port or port range, separated by ',', for example 80,3000-3010") + cmd.MarkFlagRequired("protocol") + cmd.MarkFlagRequired("uga-id") + cmd.MarkFlagRequired("port") + command.SetFlagValues(cmd, "protocol", protocols...) + ctx.SetCompletion(cmd, "uga-id", func() []string { + return getUGAIDList(ctx, *req.ProjectId) + }) + return cmd +} diff --git a/products/pathx/internal/pathx/upath.go b/products/pathx/internal/pathx/upath.go new file mode 100644 index 0000000000..b4cdeaa660 --- /dev/null +++ b/products/pathx/internal/pathx/upath.go @@ -0,0 +1,18 @@ +package pathx + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUpath builds `ucloud pathx upath`. +func newUpath(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "upath", + Short: "List pathx upath instances", + Long: "List pathx upath instances", + } + cmd.AddCommand(newUpathList(ctx)) + return cmd +} diff --git a/products/pathx/internal/pathx/upath_list.go b/products/pathx/internal/pathx/upath_list.go new file mode 100644 index 0000000000..b69f97a69a --- /dev/null +++ b/products/pathx/internal/pathx/upath_list.go @@ -0,0 +1,49 @@ +package pathx + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUpathList ucloud pathx upath list +func newUpathList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ppathx.NewClient) + req := client.NewDescribeUPathRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "list upath instances", + Long: "list upath instances", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeUPath(req) + if err != nil { + ctx.HandleError(err) + return + } + list := make([]upathRow, 0) + for _, ins := range resp.UPathSet { + ids := []string{} + for _, ga := range ins.UGAList { + ids = append(ids, ga.UGAId) + } + list = append(list, upathRow{ + ResourceID: ins.UPathId, + UPathName: ins.Name, + AcceleratedPath: fmt.Sprintf("%s->%s %dM", ins.LineFromName, ins.LineToName, ins.Bandwidth), + BoundUGA: strings.Join(ids, ","), + }) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindProjectID(cmd, req) + req.UPathId = flags.String("upath-id", "", "Optional. Resource ID of upath instance to list") + return cmd +} diff --git a/products/pathx/product.go b/products/pathx/product.go new file mode 100644 index 0000000000..4279ee29f1 --- /dev/null +++ b/products/pathx/product.go @@ -0,0 +1,20 @@ +package pathx + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalpathx "github.com/ucloud/ucloud-cli/products/pathx/internal/pathx" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "pathx", Commands: []string{"pathx"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalpathx.NewCommand(ctx)} +} diff --git a/products/pathx/product.yaml b/products/pathx/product.yaml new file mode 100644 index 0000000000..95ee543037 --- /dev/null +++ b/products/pathx/product.yaml @@ -0,0 +1,6 @@ +name: pathx +owners: + - Episkey-G +commands: + - pathx +enabled: true diff --git a/products/pathx/testdata/cmdtree.golden b/products/pathx/testdata/cmdtree.golden new file mode 100644 index 0000000000..d2ea9690b9 --- /dev/null +++ b/products/pathx/testdata/cmdtree.golden @@ -0,0 +1,93 @@ +ucloud pathx use=pathx short=Manipulate uga and upath instances +ucloud pathx area use=area short=List origin area or acceleration area information +ucloud pathx area list use=list short=List origin area or acceleration area information + flag=accel short= default= required= + flag=no-accel short= default=false required= + flag=origin-domain short= default= required= + flag=origin-ip short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=time-range short= default= required= + flag=zone short= default= required= +ucloud pathx create use=create short=Create the pathx resource and port + flag=accel short= default= required= + flag=area-code short= default= required= + flag=bandwidth short= default=0 required=true + flag=charge-type short= default= required= + flag=origin-domain short= default= required= + flag=origin-ip short= default= required= + flag=origin-port short= default=[] required= + flag=port short= default=[] required= + flag=project-id short= default= required= + flag=protocol short= default=TCP required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pathx delete use=delete short=Delete the pathx resource and port + flag=id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud pathx list use=list short=List all the pathx resource of project + flag=detail short= default=false required= + flag=id short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pathx modify use=modify short=Modify the pathx associated information. Example bandwidth or origin information or resource information + flag=bandwidth short= default=0 required= + flag=id short= default= required=true + flag=name short= default= required= + flag=origin-domain short= default= required= + flag=origin-ip short= default= required= + flag=origin-port short= default=[] required= + flag=port short= default=[] required= + flag=project-id short= default= required= + flag=protocol short= default=TCP required= + flag=region short= default= required= + flag=remark short= default= required= + flag=zone short= default= required= +ucloud pathx price use=price short=List all the acceleration area price +ucloud pathx price list use=list short=List all the pathx acceleration area price + flag=accel short= default= required= + flag=area-code short= default= required=true + flag=bandwidth short= default=1 required=true + flag=charge-type short= default= required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pathx uga use=uga short=Create,list,update and delete pathx uga instances +ucloud pathx uga add-port use=add-port short=Add port for uga instance + flag=port short= default=[] required=true + flag=project-id short= default= required= + flag=protocol short= default= required=true + flag=uga-id short= default= required=true +ucloud pathx uga create use=create short=Create uga instance + flag=name short= default= required=true + flag=origin-domain short= default= required= + flag=origin-ip short= default= required= + flag=origin-location short= default= required=true + flag=port short= default=[] required=true + flag=project-id short= default= required= + flag=protocol short= default= required=true + flag=upath-id short= default=[] required=true +ucloud pathx uga delete use=delete short=Delete uga instances + flag=project-id short= default= required= + flag=uga-id short= default=[] required=true +ucloud pathx uga delete-port use=delete-port short=Delete port for uga instance + flag=port short= default=[] required=true + flag=project-id short= default= required= + flag=protocol short= default= required=true + flag=uga-id short= default= required=true +ucloud pathx uga describe use=describe short=Display detail informations about uga instances + flag=project-id short= default= required= + flag=uga-id short= default= required=true +ucloud pathx uga list use=list short=list uga instances + flag=project-id short= default= required= + flag=uga-id short= default= required= +ucloud pathx upath use=upath short=List pathx upath instances +ucloud pathx upath list use=list short=list upath instances + flag=project-id short= default= required= + flag=upath-id short= default= required= diff --git a/products/pathx/testdata/completion.golden b/products/pathx/testdata/completion.golden new file mode 100644 index 0000000000..bb3e28fed4 --- /dev/null +++ b/products/pathx/testdata/completion.golden @@ -0,0 +1,44 @@ +ucloud pathx area list project-id dynamic +ucloud pathx area list region dynamic +ucloud pathx area list zone dynamic +ucloud pathx create accel static AF,AP,EU,Global,ME,NA,OA,SA +ucloud pathx create area-code static BKK,BOM,CGK,DME,DXB,FRA,HKG,IAD,ICN,LAX,LHR,LOS,MNL,MSP,NRT,PVG,SGN,SIN,TPE +ucloud pathx create charge-type static Hour,Month,Year +ucloud pathx create project-id dynamic +ucloud pathx create protocol static TCP,UDP +ucloud pathx create region dynamic +ucloud pathx create zone dynamic +ucloud pathx delete id dynamic +ucloud pathx delete project-id dynamic +ucloud pathx delete region dynamic +ucloud pathx delete zone dynamic +ucloud pathx list id dynamic +ucloud pathx list project-id dynamic +ucloud pathx list region dynamic +ucloud pathx list zone dynamic +ucloud pathx modify id dynamic +ucloud pathx modify project-id dynamic +ucloud pathx modify region dynamic +ucloud pathx modify zone dynamic +ucloud pathx price list accel static AF,AP,EU,Global,ME,NA,OA,SA +ucloud pathx price list area-code static BKK,BOM,CGK,DME,DXB,FRA,HKG,IAD,ICN,LAX,LHR,LOS,MNL,MSP,NRT,PVG,SGN,SIN,TPE +ucloud pathx price list charge-type static Hour,Month,Year +ucloud pathx price list project-id dynamic +ucloud pathx price list region dynamic +ucloud pathx price list zone dynamic +ucloud pathx uga add-port project-id dynamic +ucloud pathx uga add-port protocol static tcp,udp +ucloud pathx uga add-port uga-id dynamic +ucloud pathx uga create origin-location static 东京,中国,中国台北,中国香港,华盛顿,孟买,新加坡,曼谷,法兰克福,洛杉矶,莫斯科,雅加达,首尔 +ucloud pathx uga create project-id dynamic +ucloud pathx uga create protocol static tcp,udp +ucloud pathx uga create upath-id dynamic +ucloud pathx uga delete project-id dynamic +ucloud pathx uga delete uga-id dynamic +ucloud pathx uga delete-port project-id dynamic +ucloud pathx uga delete-port protocol static tcp,udp +ucloud pathx uga delete-port uga-id dynamic +ucloud pathx uga describe project-id dynamic +ucloud pathx uga describe uga-id dynamic +ucloud pathx uga list project-id dynamic +ucloud pathx upath list project-id dynamic diff --git a/products/pgsql/internal/pgsql/backup.go b/products/pgsql/internal/pgsql/backup.go new file mode 100644 index 0000000000..a512003271 --- /dev/null +++ b/products/pgsql/internal/pgsql/backup.go @@ -0,0 +1,21 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPgsqlBackup ucloud pgsql backup +func newPgsqlBackup(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "backup", + Short: "List and manipulate backups of UPgSQL instances", + Long: "List and manipulate backups of UPgSQL instances", + } + cmd.AddCommand(newBackupList(ctx)) + cmd.AddCommand(newBackupDownload(ctx)) + cmd.AddCommand(newBackupStrategy(ctx)) + cmd.AddCommand(newBackupUpdateStrategy(ctx)) + return cmd +} diff --git a/products/pgsql/internal/pgsql/backup_download.go b/products/pgsql/internal/pgsql/backup_download.go new file mode 100644 index 0000000000..df71da3c4d --- /dev/null +++ b/products/pgsql/internal/pgsql/backup_download.go @@ -0,0 +1,51 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBackupDownload ucloud pgsql backup download +func newBackupDownload(ctx *cli.Context) *cobra.Command { + client := newUPgSQLClient(ctx) + req := client.NewGetUPgSQLBackupURLRequest() + cmd := &cobra.Command{ + Use: "download", + Short: "Display download URLs of a UPgSQL backup", + Long: "Display the public and inner download URLs of a UPgSQL backup", + Run: func(c *cobra.Command, args []string) { + *req.InstanceID = ctx.PickResourceID(*req.InstanceID) + resp, err := client.GetUPgSQLBackupURL(req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.PrintList([]PgsqlBackupURLRow{{ + BackupPath: resp.BackupPath, + InnerBackupPath: resp.InnerBackupPath, + }}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance") + req.BackupID = flags.String("backup-id", "", "Required. Backup ID of the backup to download") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("backup-id") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + command.SetCompletion(cmd, "backup-id", func() []string { + return getBackupIDList(ctx, *req.InstanceID, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/backup_list.go b/products/pgsql/internal/pgsql/backup_list.go new file mode 100644 index 0000000000..27e94ce69e --- /dev/null +++ b/products/pgsql/internal/pgsql/backup_list.go @@ -0,0 +1,80 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +var pgsqlBackupTypeMap = map[string]int{ + "auto": 1, + "manual": 2, +} +var pgsqlReverseBackupTypeMap = map[int]string{ + 1: "auto", + 2: "manual", + 0: "all", +} + +// newBackupList ucloud pgsql backup list +func newBackupList(ctx *cli.Context) *cobra.Command { + var bpType string + client := newUPgSQLClient(ctx) + req := client.NewListUPgSQLBackupRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List backups of a UPgSQL instance", + Long: "List backups of a UPgSQL instance", + Run: func(c *cobra.Command, args []string) { + *req.InstanceID = ctx.PickResourceID(*req.InstanceID) + if v, ok := pgsqlBackupTypeMap[bpType]; ok { + req.BackupType = sdk.Int(v) + } + resp, err := client.ListUPgSQLBackup(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []PgsqlBackupRow{} + for _, b := range resp.DataSet { + list = append(list, PgsqlBackupRow{ + BackupID: b.BackupID, + BackupName: b.BackupName, + InstanceID: b.InstanceID, + State: b.State, + BackupType: pgsqlReverseBackupTypeMap[b.BackupType], + BackupSize: fmt.Sprintf("%dB", b.BackupSize), + BackupStartTime: common.FormatDateTime(b.BackupStartTime), + BackupEndTime: common.FormatDateTime(b.BackupEndTime), + }) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance") + flags.StringVar(&bpType, "backup-type", "", "Optional. Backup type. Accept values: auto, manual") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindOffset(cmd, req) + ctx.BindLimit(cmd, req) + + command.SetFlagValues(cmd, "backup-type", "auto", "manual") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/backup_strategy.go b/products/pgsql/internal/pgsql/backup_strategy.go new file mode 100644 index 0000000000..adda81ea4d --- /dev/null +++ b/products/pgsql/internal/pgsql/backup_strategy.go @@ -0,0 +1,50 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBackupStrategy ucloud pgsql backup strategy +func newBackupStrategy(ctx *cli.Context) *cobra.Command { + client := newUPgSQLClient(ctx) + req := client.NewGetUPgSQLBackupStrategyRequest() + cmd := &cobra.Command{ + Use: "strategy", + Short: "Display the backup strategy of a UPgSQL instance", + Long: "Display the backup strategy of a UPgSQL instance", + Run: func(c *cobra.Command, args []string) { + *req.InstanceID = ctx.PickResourceID(*req.InstanceID) + resp, err := client.GetUPgSQLBackupStrategy(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintln(ctx.ProgressWriter(), "BackupStrategy:") + ctx.PrintList([]PgsqlBackupStrategyRow{{ + BackupMethod: resp.BackupMethod, + BackupTimeRange: resp.BackupTimeRange, + BackupWeek: resp.BackupWeek, + }}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("instance-id") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/backup_update_strategy.go b/products/pgsql/internal/pgsql/backup_update_strategy.go new file mode 100644 index 0000000000..f301d69f74 --- /dev/null +++ b/products/pgsql/internal/pgsql/backup_update_strategy.go @@ -0,0 +1,49 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBackupUpdateStrategy ucloud pgsql backup update-strategy +func newBackupUpdateStrategy(ctx *cli.Context) *cobra.Command { + client := newUPgSQLClient(ctx) + req := client.NewUpdateUPgSQLBackupStrategyRequest() + cmd := &cobra.Command{ + Use: "update-strategy", + Short: "Update the backup strategy of a UPgSQL instance", + Long: "Update the backup strategy of a UPgSQL instance", + Run: func(c *cobra.Command, args []string) { + *req.InstanceID = ctx.PickResourceID(*req.InstanceID) + _, err := client.UpdateUPgSQLBackupStrategy(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "backup strategy of pgsql[%s] updated\n", *req.InstanceID) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceID, Action: "update-strategy", Status: "Updated"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance") + req.BackupTimeRange = flags.String("backup-time-range", "", "Optional. Auto backup start time range, e.g. (3:00~4:00)") + req.BackupWeek = flags.String("backup-week", "", "Optional. Days of week to start auto backup, e.g. 1,2,3,4,5,6,7") + req.BackupMethod = flags.String("backup-method", "", "Optional. Default backup method") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("instance-id") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/client.go b/products/pgsql/internal/pgsql/client.go new file mode 100644 index 0000000000..29b61a139a --- /dev/null +++ b/products/pgsql/internal/pgsql/client.go @@ -0,0 +1,38 @@ +package pgsql + +import ( + "github.com/ucloud/ucloud-sdk-go/services/upgsql" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUPgSQLClient returns an authed UPgSQL client whose requests are encoded as +// JSON bodies instead of form-urlencoded. +// +// The UPgSQL gateway cannot unmarshal form-urlencoded string values into Go +// int/bool request fields (e.g. ListUPgSQLParamTemplate.Count), returning +// RetCode 214001 "json: cannot unmarshal string into Go struct field ... of +// type int". The SDK default form encoder serializes *int/*bool as the strings +// "100"/"false", which trips this. Switching to NewJSONEncoder keeps numeric +// and boolean fields as JSON numbers/booleans, which the gateway accepts. +// +// The encoder is swapped per-request via an SDK request handler (runs before +// buildHTTPRequest) using the SAME config+credential the default form encoder +// would use, so signing is unchanged. This covers every typed call that goes +// through this client, including the completion helpers. +// +// Note: this works for AK/SK profiles (Signature lives in the signed JSON +// body). OAuth profiles additionally need the platform cred-header injector to +// strip Signature/PublicKey from a JSON body (it currently only strips form +// bodies) — that is a separate platform-layer change; until then OAuth+pgsql +// remains non-functional (as it is today). +func newUPgSQLClient(ctx *cli.Context) *upgsql.UPgSQLClient { + client := cli.NewServiceClient(ctx, upgsql.NewClient) + _ = client.AddRequestHandler(func(c *sdk.Client, req request.Common) (request.Common, error) { + req.SetEncoder(request.NewJSONEncoder(c.GetConfig(), c.GetCredential())) + return req, nil + }) + return client +} diff --git a/products/pgsql/internal/pgsql/cmd.go b/products/pgsql/internal/pgsql/cmd.go new file mode 100644 index 0000000000..36b66b7e57 --- /dev/null +++ b/products/pgsql/internal/pgsql/cmd.go @@ -0,0 +1,22 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `pgsql` root command and mounts the `db` subtree. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "pgsql", + Short: "Manipulate UPgSQL on UCloud platform", + Long: "Manipulate UPgSQL (UCloud PostgreSQL) on UCloud platform", + } + cmd.AddCommand(newPgsqlDB(ctx)) + cmd.AddCommand(newPgsqlConf(ctx)) + cmd.AddCommand(newPgsqlBackup(ctx)) + cmd.AddCommand(newPgsqlLog(ctx)) + cmd.AddCommand(newPgsqlSupabase(ctx)) + return cmd +} diff --git a/products/pgsql/internal/pgsql/completion.go b/products/pgsql/internal/pgsql/completion.go new file mode 100644 index 0000000000..2332eabc03 --- /dev/null +++ b/products/pgsql/internal/pgsql/completion.go @@ -0,0 +1,203 @@ +package pgsql + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/upgsql" + "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +var pgsqlVersionList = []string{"postgresql-10.4", "postgresql-13.4"} + +// getAllVPCIns mirrors products/mysql/internal/mysql/completion.go getAllVPCIns, +// copied here (not imported) so the product stays self-contained per the +// boundary rules (hack/check-product rule1). +func getAllVPCIns(ctx *cli.Context, project, region string) ([]vpc.VPCInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +// getAllVPCIdNames mirrors products/mysql/internal/mysql/completion.go getAllVPCIdNames. +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + vpcInsList, err := getAllVPCIns(ctx, project, region) + list := []string{} + if err != nil { + return nil + } + for _, v := range vpcInsList { + list = append(list, fmt.Sprintf("%s/%s", v.VPCId, v.Name)) + } + return list +} + +// getAllSubnets mirrors products/mysql/internal/mysql/completion.go getAllSubnets. +func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpc.SubnetInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + if vpcID != "" { + req.VPCId = sdk.String(cli.PickResourceID(vpcID)) + } + subnets := []vpc.SubnetInfo{} + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.DescribeSubnet(req) + if err != nil { + ctx.HandleError(err) + return nil, err + } + subnets = append(subnets, resp.DataSet...) + if limit+offset >= resp.TotalCount { + break + } + } + return subnets, nil +} + +// getAllSubnetIDNames mirrors products/mysql/internal/mysql/completion.go getAllSubnetIDNames. +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + subnets, err := getAllSubnets(ctx, vpcID, project, region) + if err != nil { + return nil + } + list := []string{} + for _, s := range subnets { + list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return list +} + +// getUPgSQLList returns all UPgSQL instances for the given project/region/zone. +// ListUPgSQLInstance has no Limit/Offset, so a single call returns the full set. +func getUPgSQLList(ctx *cli.Context, project, region, zone string) ([]upgsql.UDBInstanceSet, error) { + client := newUPgSQLClient(ctx) + req := client.NewListUPgSQLInstanceRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + resp, err := client.ListUPgSQLInstance(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +// getUPgSQLIDList returns "InstanceID/Name" completion candidates for --instance-id. +func getUPgSQLIDList(ctx *cli.Context, project, region, zone string) []string { + instances, err := getUPgSQLList(ctx, project, region, zone) + if err != nil { + return nil + } + list := []string{} + for _, ins := range instances { + list = append(list, fmt.Sprintf("%s/%s", ins.InstanceID, ins.Name)) + } + return list +} + +// listParamTemplates returns the available UPgSQL param templates for the given +// project/region/zone, paginating via item-count (the API exposes no TotalCount). +func listParamTemplates(ctx *cli.Context, project, region, zone string) ([]upgsql.TemplateGroup, error) { + client := newUPgSQLClient(ctx) + req := client.NewListUPgSQLParamTemplateRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + list := []upgsql.TemplateGroup{} + + resp, err := client.ListUPgSQLParamTemplate(req) + if err != nil { + return nil, err + } + list = resp.Data + return list, nil +} + +// getDefaultParamGroupID picks a param template for the given DB version via +// ListUPgSQLParamTemplate. The API has no DBVersion filter, so we match by the +// DBVersion field in the returned templates and fall back to the first one. +func getDefaultParamGroupID(ctx *cli.Context, dbVersion, project, region, zone string) (int, error) { + templates, err := listParamTemplates(ctx, project, region, zone) + if err != nil { + return 0, fmt.Errorf("call ListUPgSQLParamTemplate: %w", err) + } + if len(templates) == 0 { + return 0, fmt.Errorf("no param template found in %s/%s", region, zone) + } + for _, t := range templates { + if t.DBVersion == dbVersion { + return t.GroupID, nil + } + } + return templates[0].GroupID, nil +} + +// listParamTemplateIDNames returns "GroupID/GroupName" candidates for --param-group-id. +func listParamTemplateIDNames(ctx *cli.Context, project, region, zone string) []string { + templates, err := listParamTemplates(ctx, project, region, zone) + if err != nil { + return nil + } + list := []string{} + for _, t := range templates { + list = append(list, fmt.Sprintf("%d/%s", t.GroupID, t.GroupName)) + } + return list +} + +// listMachineTypeIDNames returns "ID/Description" candidates for --machine-type. +func listMachineTypeIDNames(ctx *cli.Context, project, region, zone string) []string { + client := newUPgSQLClient(ctx) + req := client.NewListUPgSQLMachineTypeRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + resp, err := client.ListUPgSQLMachineType(req) + if err != nil { + return nil + } + list := []string{} + for _, mt := range resp.DataSet { + list = append(list, fmt.Sprintf("%s/%s", mt.ID, mt.Description)) + } + return list +} + +// getBackupIDList returns "BackupID/BackupName" candidates for --backup-id, +// paginating via TotalCount (ListUPgSQLBackup exposes TotalCount). +func getBackupIDList(ctx *cli.Context, instanceID, project, region, zone string) []string { + client := newUPgSQLClient(ctx) + req := client.NewListUPgSQLBackupRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + req.InstanceID = sdk.String(instanceID) + list := []string{} + for limit, offset := 100, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.ListUPgSQLBackup(req) + if err != nil { + return nil + } + for _, b := range resp.DataSet { + list = append(list, fmt.Sprintf("%s/%s", b.BackupID, b.BackupName)) + } + if offset+limit >= resp.TotalCount { + break + } + } + return list +} diff --git a/products/pgsql/internal/pgsql/conf.go b/products/pgsql/internal/pgsql/conf.go new file mode 100644 index 0000000000..57952faeea --- /dev/null +++ b/products/pgsql/internal/pgsql/conf.go @@ -0,0 +1,23 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPgsqlConf ucloud pgsql conf +func newPgsqlConf(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "conf", + Short: "List and manipulate parameter templates of UPgSQL instances", + Long: "List and manipulate parameter templates of UPgSQL instances", + } + cmd.AddCommand(newConfList(ctx)) + cmd.AddCommand(newConfDescribe(ctx)) + cmd.AddCommand(newConfCreate(ctx)) + cmd.AddCommand(newConfDelete(ctx)) + cmd.AddCommand(newConfDownload(ctx)) + cmd.AddCommand(newConfUpload(ctx)) + return cmd +} diff --git a/products/pgsql/internal/pgsql/conf_create.go b/products/pgsql/internal/pgsql/conf_create.go new file mode 100644 index 0000000000..9a259b6e78 --- /dev/null +++ b/products/pgsql/internal/pgsql/conf_create.go @@ -0,0 +1,62 @@ +package pgsql + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newConfCreate ucloud pgsql conf create +func newConfCreate(ctx *cli.Context) *cobra.Command { + var srcConfID string + client := newUPgSQLClient(ctx) + req := client.NewCreateUPgSQLParamTemplateRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a UPgSQL parameter template from a base template", + Long: "Create a UPgSQL parameter template from a base template", + Run: func(c *cobra.Command, args []string) { + id, err := strconv.Atoi(ctx.PickResourceID(srcConfID)) + if err != nil { + ctx.HandleError(fmt.Errorf("invalid src-conf-id: %w", err)) + return + } + req.SrcGroupID = sdk.Int(id) + resp, err := client.CreateUPgSQLParamTemplate(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "conf[%d] created\n", resp.GroupID) + ctx.EmitResult(cli.OpResultRow{ResourceID: strconv.Itoa(resp.GroupID), Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.GroupName = flags.String("name", "", "Required. Name of the parameter template") + flags.StringVar(&srcConfID, "src-conf-id", "", "Required. Group ID of the base template to clone from") + req.DBVersion = flags.String("db-version", "", "Required. DB version. Options: postgresql-10.4, postgresql-13.4") + req.Description = flags.String("description", "", "Optional. Description of the parameter template") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + command.SetFlagValues(cmd, "db-version", pgsqlVersionList...) + command.SetCompletion(cmd, "src-conf-id", func() []string { + return listParamTemplateIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("src-conf-id") + cmd.MarkFlagRequired("db-version") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/conf_delete.go b/products/pgsql/internal/pgsql/conf_delete.go new file mode 100644 index 0000000000..4c047731b4 --- /dev/null +++ b/products/pgsql/internal/pgsql/conf_delete.go @@ -0,0 +1,55 @@ +package pgsql + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newConfDelete ucloud pgsql conf delete +func newConfDelete(ctx *cli.Context) *cobra.Command { + var confID string + client := newUPgSQLClient(ctx) + req := client.NewDeleteUPgSQLParamTemplateRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a UPgSQL parameter template", + Long: "Delete a UPgSQL parameter template", + Run: func(c *cobra.Command, args []string) { + id, err := strconv.Atoi(ctx.PickResourceID(confID)) + if err != nil { + ctx.HandleError(fmt.Errorf("invalid conf-id: %w", err)) + return + } + req.GroupID = sdk.Int(id) + _, err = client.DeleteUPgSQLParamTemplate(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "conf[%s] deleted\n", confID) + ctx.EmitResult(cli.OpResultRow{ResourceID: strconv.Itoa(id), Action: "delete", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&confID, "conf-id", "", "Required. Group ID of the parameter template to delete") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("conf-id") + command.SetCompletion(cmd, "conf-id", func() []string { + return listParamTemplateIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/conf_describe.go b/products/pgsql/internal/pgsql/conf_describe.go new file mode 100644 index 0000000000..9e5f8474fe --- /dev/null +++ b/products/pgsql/internal/pgsql/conf_describe.go @@ -0,0 +1,92 @@ +package pgsql + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/upgsql" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newConfDescribe ucloud pgsql conf describe +func newConfDescribe(ctx *cli.Context) *cobra.Command { + var confID string + client := newUPgSQLClient(ctx) + req := client.NewGetUPgSQLParamTemplateRequest() + cmd := &cobra.Command{ + Use: "describe", + Short: "Display details of a UPgSQL parameter template", + Long: "Display details of a UPgSQL parameter template", + Run: func(c *cobra.Command, args []string) { + id, err := strconv.Atoi(ctx.PickResourceID(confID)) + if err != nil { + ctx.HandleError(fmt.Errorf("invalid conf-id: %w", err)) + return + } + req.GroupID = sdk.Int(id) + resp, err := client.GetUPgSQLParamTemplate(req) + if err != nil { + ctx.HandleError(err) + return + } + + // Template metadata comes from ListUPgSQLParamTemplate (GetUPgSQLParamTemplate + // returns only the param list). Fetch and filter by GroupID. + templates, err := listParamTemplates(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + if err != nil { + ctx.HandleError(err) + return + } + var group upgsql.TemplateGroup + for _, t := range templates { + if t.GroupID == id { + group = t + break + } + } + attrs := []cli.DescribeRow{ + {Attribute: "GroupID", Content: strconv.Itoa(group.GroupID)}, + {Attribute: "GroupName", Content: group.GroupName}, + {Attribute: "DBVersion", Content: group.DBVersion}, + {Attribute: "Description", Content: group.Description}, + {Attribute: "Modifiable", Content: strconv.FormatBool(group.Modifiable)}, + } + fmt.Fprintln(ctx.ProgressWriter(), "Attributes:") + ctx.PrintList(attrs) + + params := []PgsqlConfParamRow{} + for _, p := range resp.Data { + if p.Key == "" { + continue + } + params = append(params, PgsqlConfParamRow{ + Key: p.Key, + Value: p.Value, + Modifiable: p.Modifiable, + }) + } + fmt.Fprintln(ctx.ProgressWriter(), "\nParameters:") + ctx.PrintList(params) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&confID, "conf-id", "", "Required. Group ID of the parameter template to describe") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("conf-id") + command.SetCompletion(cmd, "conf-id", func() []string { + return listParamTemplateIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/conf_download.go b/products/pgsql/internal/pgsql/conf_download.go new file mode 100644 index 0000000000..a0d46bd263 --- /dev/null +++ b/products/pgsql/internal/pgsql/conf_download.go @@ -0,0 +1,54 @@ +package pgsql + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newConfDownload ucloud pgsql conf download +func newConfDownload(ctx *cli.Context) *cobra.Command { + var confID string + client := newUPgSQLClient(ctx) + req := client.NewDownloadUPgSQLParamTemplateRequest() + cmd := &cobra.Command{ + Use: "download", + Short: "Download a UPgSQL parameter template (base64 content)", + Long: "Download a UPgSQL parameter template and print its base64 content to stdout", + Run: func(c *cobra.Command, args []string) { + id, err := strconv.Atoi(ctx.PickResourceID(confID)) + if err != nil { + ctx.HandleError(err) + return + } + req.GroupID = sdk.Int(id) + resp, err := client.DownloadUPgSQLParamTemplate(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprint(ctx.Out(), resp.Content) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&confID, "conf-id", "", "Required. Group ID of the parameter template to download") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("conf-id") + command.SetCompletion(cmd, "conf-id", func() []string { + return listParamTemplateIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/conf_list.go b/products/pgsql/internal/pgsql/conf_list.go new file mode 100644 index 0000000000..19af1efa1f --- /dev/null +++ b/products/pgsql/internal/pgsql/conf_list.go @@ -0,0 +1,45 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newConfList ucloud pgsql conf list +func newConfList(ctx *cli.Context) *cobra.Command { + var common request.CommonBase + cmd := &cobra.Command{ + Use: "list", + Short: "List UPgSQL parameter templates", + Long: "List UPgSQL parameter templates", + Run: func(c *cobra.Command, args []string) { + templates, err := listParamTemplates(ctx, common.GetProjectId(), common.GetRegion(), common.GetZone()) + if err != nil { + ctx.HandleError(err) + return + } + rows := []PgsqlConfRow{} + for _, t := range templates { + rows = append(rows, PgsqlConfRow{ + GroupID: t.GroupID, + GroupName: t.GroupName, + DBVersion: t.DBVersion, + Description: t.Description, + Modifiable: t.Modifiable, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindProjectID(cmd, &common) + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/conf_upload.go b/products/pgsql/internal/pgsql/conf_upload.go new file mode 100644 index 0000000000..d4dec6720d --- /dev/null +++ b/products/pgsql/internal/pgsql/conf_upload.go @@ -0,0 +1,65 @@ +package pgsql + +import ( + "encoding/base64" + "fmt" + "strconv" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newConfUpload ucloud pgsql conf upload +func newConfUpload(ctx *cli.Context) *cobra.Command { + var file string + client := newUPgSQLClient(ctx) + req := client.NewUploadUPgSQLParamTemplateRequest() + cmd := &cobra.Command{ + Use: "upload", + Short: "Create a UPgSQL parameter template by uploading a local config file", + Long: "Create a UPgSQL parameter template by uploading a local config file", + Run: func(c *cobra.Command, args []string) { + content, err := cli.ReadFile(file) + if err != nil { + ctx.HandleError(err) + return + } + req.Content = sdk.String(base64.StdEncoding.EncodeToString([]byte(content))) + resp, err := client.UploadUPgSQLParamTemplate(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "conf[%d] uploaded\n", resp.GroupID) + ctx.EmitResult(cli.OpResultRow{ResourceID: strconv.Itoa(resp.GroupID), Action: "upload", Status: "Uploaded"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&file, "conf-file", "", "Required. Path of the local configuration file") + req.GroupName = flags.String("name", "", "Required. Name of the parameter template") + req.DBVersion = flags.String("db-version", "", "Required. DB version. Options: postgresql-10.4, postgresql-13.4") + req.Description = flags.String("description", "", "Optional. Description of the parameter template") + req.ParamGroupType = flags.String("param-group-type", "", "Optional. Parameter group type") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + command.SetFlagValues(cmd, "db-version", pgsqlVersionList...) + command.SetCompletion(cmd, "conf-file", func() []string { + return common.GetFileList("") + }) + + cmd.MarkFlagRequired("conf-file") + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("db-version") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/create.go b/products/pgsql/internal/pgsql/create.go new file mode 100644 index 0000000000..f5fbd6f0b0 --- /dev/null +++ b/products/pgsql/internal/pgsql/create.go @@ -0,0 +1,121 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud pgsql db create +func newCreate(ctx *cli.Context) *cobra.Command { + var paramGroupID int + var async bool + var password string + + client := newUPgSQLClient(ctx) + req := client.NewCreateUPgSQLInstanceRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a UPgSQL instance", + Long: "Create a UPgSQL instance on UCloud platform", + Run: func(c *cobra.Command, args []string) { + if len(*req.Name) < 6 { + ctx.HandleError(fmt.Errorf("name must be at least 6 characters")) + return + } + if password == "" { + ctx.HandleError(fmt.Errorf("admin password is required")) + return + } + + // ParamGroupID: user-provided wins; otherwise auto-fetch a default template. + if c.Flags().Changed("param-group-id") { + req.ParamGroupID = sdk.Int(paramGroupID) + } else { + id, err := getDefaultParamGroupID(ctx, *req.DBVersion, req.GetProjectId(), req.GetRegion(), req.GetZone()) + if err != nil { + ctx.HandleError(err) + return + } + req.ParamGroupID = sdk.Int(id) + } + + // VPCID/SubnetID accept "id/name" form; pick the id. + *req.VPCID = ctx.PickResourceID(*req.VPCID) + *req.SubnetID = ctx.PickResourceID(*req.SubnetID) + req.AdminPassword = sdk.String(password) + + resp, err := client.CreateUPgSQLInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + + instanceID := resp.InstanceID + if instanceID == "" { + ctx.HandleError(fmt.Errorf("empty InstanceID in response")) + return + } + + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "pgsql[%s] is initializing\n", instanceID) + } else { + text := fmt.Sprintf("pgsql[%s] is initializing", instanceID) + ctx.PollerTo(w, describePgsqlByID(ctx)).Spoll(instanceID, text, []string{PGSQL_RUNNING, PGSQL_INIT_FAILED, PGSQL_START_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "create", Status: "Initializing"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + // Required flags + req.Name = flags.String("name", "", "Required. Instance name, at least 6 characters") + flags.StringVar(&password, "password", "", "Required. Admin password") + req.DBVersion = flags.String("version", "", "Required. DB version. Options: postgresql-10.4, postgresql-13.4") + req.MachineType = flags.String("machine-type", "", "Required. Machine type ID, e.g. o.pgsql2m.medium. See 'ucloud pgsql db list-machine-type'") + req.VPCID = flags.String("vpc-id", "", "Required. VPC ID. See 'ucloud vpc list'") + req.SubnetID = flags.String("subnet-id", "", "Required. Subnet ID. See 'ucloud subnet list'") + + // Optional flags + flags.IntVar(¶mGroupID, "param-group-id", 0, "Optional. Param group ID. Auto-fetched if omitted. See 'ucloud pgsql conf list'") + req.DiskSpace = flags.String("disk-size-gb", "100", "Optional. Disk size (GiB), at least 20, default 100") + req.Port = flags.Int("port", 5432, "Optional. Port, default 5432") + req.InstanceMode = flags.String("mode", "Normal", "Optional. Normal / HA") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for creation to finish") + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + command.SetFlagValues(cmd, "version", pgsqlVersionList...) + command.SetFlagValues(cmd, "mode", "Normal", "HA") + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, req.GetProjectId(), req.GetRegion()) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, *req.VPCID, req.GetProjectId(), req.GetRegion()) + }) + command.SetCompletion(cmd, "param-group-id", func() []string { + return listParamTemplateIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + command.SetCompletion(cmd, "machine-type", func() []string { + return listMachineTypeIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("version") + cmd.MarkFlagRequired("machine-type") + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("subnet-id") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/create_readonly.go b/products/pgsql/internal/pgsql/create_readonly.go new file mode 100644 index 0000000000..5925a86027 --- /dev/null +++ b/products/pgsql/internal/pgsql/create_readonly.go @@ -0,0 +1,84 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreateReadonly ucloud pgsql db create-readonly +func newCreateReadonly(ctx *cli.Context) *cobra.Command { + var async bool + client := newUPgSQLClient(ctx) + req := client.NewCreateUPgSQLReadonlyRequest() + cmd := &cobra.Command{ + Use: "create-readonly", + Short: "Create a readonly replica for a UPgSQL instance", + Long: "Create a readonly replica synchronizing from a source UPgSQL instance", + Run: func(c *cobra.Command, args []string) { + *req.SrcInstanceID = ctx.PickResourceID(*req.SrcInstanceID) + if c.Flags().Changed("vpc-id") { + *req.VPCID = ctx.PickResourceID(*req.VPCID) + } + if c.Flags().Changed("subnet-id") { + *req.SubnetID = ctx.PickResourceID(*req.SubnetID) + } + resp, err := client.CreateUPgSQLReadonly(req) + if err != nil { + ctx.HandleError(err) + return + } + instanceID := resp.InstanceID + if instanceID == "" { + ctx.HandleError(fmt.Errorf("empty InstanceID in response")) + return + } + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "pgsql[%s] is initializing\n", instanceID) + } else { + text := fmt.Sprintf("pgsql[%s] is initializing", instanceID) + ctx.PollerTo(w, describePgsqlByID(ctx)).Spoll(instanceID, text, []string{PGSQL_RUNNING, PGSQL_INIT_FAILED, PGSQL_START_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "create-readonly", Status: "Initializing"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.Name = flags.String("name", "", "Required. Name of the readonly replica") + req.SrcInstanceID = flags.String("src-instance-id", "", "Required. Resource ID of the source UPgSQL instance") + req.MachineType = flags.String("machine-type", "", "Required. Machine type ID, e.g. o.pgsql2m.medium. See 'ucloud pgsql db list-machine-type'") + req.DiskSpace = flags.Int("disk-size-gb", 0, "Required. Disk space (GiB)") + req.Port = flags.Int("port", 5432, "Optional. Port of the readonly replica, default 5432") + req.VPCID = flags.String("vpc-id", "", "Optional. VPC ID. Defaults to the source instance's VPC") + req.SubnetID = flags.String("subnet-id", "", "Optional. Subnet ID. Defaults to the source instance's subnet") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + command.SetCompletion(cmd, "src-instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, req.GetProjectId(), req.GetRegion()) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, *req.VPCID, req.GetProjectId(), req.GetRegion()) + }) + command.SetCompletion(cmd, "machine-type", func() []string { + return listMachineTypeIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("src-instance-id") + cmd.MarkFlagRequired("machine-type") + cmd.MarkFlagRequired("disk-size-gb") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/db.go b/products/pgsql/internal/pgsql/db.go new file mode 100644 index 0000000000..3540322bf9 --- /dev/null +++ b/products/pgsql/internal/pgsql/db.go @@ -0,0 +1,36 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPgsqlDB ucloud pgsql db +func newPgsqlDB(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "db", + Short: "Manage UPgSQL instances", + Long: "Manage UPgSQL instances", + } + + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newGet(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newStart(ctx)) + cmd.AddCommand(newStop(ctx)) + cmd.AddCommand(newRestart(ctx)) + cmd.AddCommand(newUpgrade(ctx)) + cmd.AddCommand(newListMachineType(ctx)) + cmd.AddCommand(newListVersion(ctx)) + cmd.AddCommand(newCreateReadonly(ctx)) + cmd.AddCommand(newStopCreatingReadonly(ctx)) + cmd.AddCommand(newUpdateName(ctx)) + cmd.AddCommand(newUpdateRemark(ctx)) + cmd.AddCommand(newResetPassword(ctx)) + cmd.AddCommand(newPrice(ctx)) + cmd.AddCommand(newUpgradePrice(ctx)) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/delete.go b/products/pgsql/internal/pgsql/delete.go new file mode 100644 index 0000000000..dcdac3429a --- /dev/null +++ b/products/pgsql/internal/pgsql/delete.go @@ -0,0 +1,77 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/upgsql" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud pgsql db delete +func newDelete(ctx *cli.Context) *cobra.Command { + var idNames []string + var yes bool + client := newUPgSQLClient(ctx) + req := client.NewDeleteUPgSQLInstanceRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete UPgSQL instances by instance-id", + Long: "Delete UPgSQL instances by instance-id", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the pgsql instance(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + any, err := describePgsqlByID(ctx)(id, nil) + if err != nil { + ctx.HandleError(err) + continue + } + req.InstanceID = &id + ins, ok := any.(*upgsql.UDBInstance) + if ok && ins.State == PGSQL_RUNNING { + stopReq := client.NewStopUPgSQLInstanceRequest() + stopReq.ProjectId = req.ProjectId + stopReq.Region = req.Region + stopReq.Zone = req.Zone + stopReq.InstanceID = req.InstanceID + stopPgsqlIns(ctx, stopReq, false, w) + } + _, err = client.DeleteUPgSQLInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "pgsql[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to delete") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.") + + cmd.MarkFlagRequired("instance-id") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + return cmd +} diff --git a/products/pgsql/internal/pgsql/get.go b/products/pgsql/internal/pgsql/get.go new file mode 100644 index 0000000000..6e8f7f9e3a --- /dev/null +++ b/products/pgsql/internal/pgsql/get.go @@ -0,0 +1,78 @@ +package pgsql + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newGet ucloud pgsql db get +func newGet(ctx *cli.Context) *cobra.Command { + client := newUPgSQLClient(ctx) + req := client.NewGetUPgSQLInstanceRequest() + cmd := &cobra.Command{ + Use: "get", + Short: "Display details of a UPgSQL instance", + Long: "Display details of a UPgSQL instance", + Run: func(c *cobra.Command, args []string) { + *req.InstanceID = ctx.PickResourceID(*req.InstanceID) + resp, err := client.GetUPgSQLInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + ins := resp.DataSet + if ins.InstanceID == "" { + ctx.HandleError(fmt.Errorf("pgsql[%s] may not exist", *req.InstanceID)) + return + } + attrs := []cli.DescribeRow{ + {Attribute: "InstanceID", Content: ins.InstanceID}, + {Attribute: "Name", Content: ins.Name}, + {Attribute: "State", Content: ins.State}, + {Attribute: "Zone", Content: ins.Zone}, + {Attribute: "BackupZone", Content: ins.BackupZone}, + {Attribute: "DBVersion", Content: ins.DBVersion}, + {Attribute: "InstanceMode", Content: ins.InstanceMode}, + {Attribute: "AdminUser", Content: ins.AdminUser}, + {Attribute: "IP", Content: ins.IP}, + {Attribute: "Port", Content: strconv.Itoa(ins.Port)}, + {Attribute: "VPCID", Content: ins.VPCID}, + {Attribute: "SubnetID", Content: ins.SubnetID}, + {Attribute: "ParamGroupID", Content: strconv.Itoa(ins.ParamGroupID)}, + {Attribute: "MemoryLimit", Content: fmt.Sprintf("%dMB", ins.MemoryLimit)}, + {Attribute: "DiskSpace", Content: fmt.Sprintf("%dGB", ins.DiskSpace)}, + {Attribute: "DiskUsedSize", Content: fmt.Sprintf("%.2fGB", ins.DiskUsedSize)}, + {Attribute: "Remark", Content: ins.Remark}, + {Attribute: "BackupCount", Content: strconv.Itoa(ins.BackupCount)}, + {Attribute: "BackupBeginTime", Content: strconv.Itoa(ins.BackupBeginTime)}, + {Attribute: "BackupDate", Content: ins.BackupDate}, + {Attribute: "CreateTime", Content: common.FormatDateTime(ins.CreateTime)}, + {Attribute: "ModifyTime", Content: common.FormatDateTime(ins.ModifyTime)}, + {Attribute: "ExpiredTime", Content: common.FormatDateTime(ins.ExpiredTime)}, + } + fmt.Fprintln(ctx.ProgressWriter(), "Attributes:") + ctx.PrintList(attrs) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("instance-id") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/list.go b/products/pgsql/internal/pgsql/list.go new file mode 100644 index 0000000000..bdd5f86c54 --- /dev/null +++ b/products/pgsql/internal/pgsql/list.go @@ -0,0 +1,81 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/upgsql" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList ucloud pgsql db list +func newList(ctx *cli.Context) *cobra.Command { + var instanceID string + client := newUPgSQLClient(ctx) + req := client.NewListUPgSQLInstanceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List UPgSQL instances", + Long: "List UPgSQL instances", + Run: func(c *cobra.Command, args []string) { + resp, err := client.ListUPgSQLInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []PgsqlInstanceRow{} + for _, ins := range resp.DataSet { + if instanceID != "" && ins.InstanceID != instanceID { + continue + } + list = append(list, toInstanceRow(ins)) + for _, slave := range ins.DataSet { + list = append(list, toInstanceRow(upgsql.UDBInstanceSet{ + Zone: slave.Zone, + InstanceID: slave.InstanceID, + Name: slave.Name, + DBVersion: slave.DBVersion, + InstanceMode: slave.InstanceMode, + State: slave.State, + VPCID: slave.VPCID, + SubnetID: slave.SubnetID, + IP: slave.IP, + })) + } + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&instanceID, "instance-id", "", "Optional. List the specified UPgSQL instance only") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} + +// toInstanceRow maps an SDK UDBInstanceSet to a PgsqlInstanceRow. role is non-empty +// for readonly slaves (prefixed with ⮭ to indent under the master row). +func toInstanceRow(ins upgsql.UDBInstanceSet) PgsqlInstanceRow { + row := PgsqlInstanceRow{ + Name: ins.Name, + InstanceID: ins.InstanceID, + Zone: ins.Zone, + State: ins.State, + IP: ins.IP, + VPC: ins.VPCID, + Subnet: ins.SubnetID, + InstanceMode: ins.InstanceMode, + DBVersion: ins.DBVersion, + } + return row +} diff --git a/products/pgsql/internal/pgsql/list_machine_type.go b/products/pgsql/internal/pgsql/list_machine_type.go new file mode 100644 index 0000000000..4effc4d606 --- /dev/null +++ b/products/pgsql/internal/pgsql/list_machine_type.go @@ -0,0 +1,45 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListMachineType ucloud pgsql db list-machine-type +func newListMachineType(ctx *cli.Context) *cobra.Command { + client := newUPgSQLClient(ctx) + req := client.NewListUPgSQLMachineTypeRequest() + cmd := &cobra.Command{ + Use: "list-machine-type", + Short: "List available UPgSQL machine types", + Long: "List available UPgSQL machine types via ListUPgSQLMachineType API", + Run: func(c *cobra.Command, args []string) { + resp, err := client.ListUPgSQLMachineType(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := []PgsqlMachineTypeRow{} + for _, mt := range resp.DataSet { + rows = append(rows, PgsqlMachineTypeRow{ + ID: mt.ID, + Description: mt.Description, + Cpu: mt.Cpu, + Memory: mt.Memory, + Os: mt.Os, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/list_version.go b/products/pgsql/internal/pgsql/list_version.go new file mode 100644 index 0000000000..70ac39225d --- /dev/null +++ b/products/pgsql/internal/pgsql/list_version.go @@ -0,0 +1,42 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListVersion ucloud pgsql db list-version +func newListVersion(ctx *cli.Context) *cobra.Command { + client := newUPgSQLClient(ctx) + req := client.NewListUPgSQLVersionRequest() + cmd := &cobra.Command{ + Use: "list-version", + Short: "List available UPgSQL versions", + Long: "List available UPgSQL versions via ListUPgSQLVersion API", + Run: func(c *cobra.Command, args []string) { + resp, err := client.ListUPgSQLVersion(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := []PgsqlVersionRow{} + for _, v := range resp.DataSet { + rows = append(rows, PgsqlVersionRow{ + DBVersion: v.DBVersion, + Available: v.Available, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/log.go b/products/pgsql/internal/pgsql/log.go new file mode 100644 index 0000000000..0d9575954f --- /dev/null +++ b/products/pgsql/internal/pgsql/log.go @@ -0,0 +1,19 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPgsqlLog ucloud pgsql log +func newPgsqlLog(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "log", + Short: "List and back up logs of UPgSQL instances", + Long: "List and back up logs of UPgSQL instances", + } + cmd.AddCommand(newLogList(ctx)) + cmd.AddCommand(newLogBackup(ctx)) + return cmd +} diff --git a/products/pgsql/internal/pgsql/log_backup.go b/products/pgsql/internal/pgsql/log_backup.go new file mode 100644 index 0000000000..3e931bf756 --- /dev/null +++ b/products/pgsql/internal/pgsql/log_backup.go @@ -0,0 +1,78 @@ +package pgsql + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +var pgsqlLogTypeList = []string{"slow", "error"} + +// newLogBackup ucloud pgsql log backup +func newLogBackup(ctx *cli.Context) *cobra.Command { + var beginTime, endTime string + client := newUPgSQLClient(ctx) + req := client.NewBackupUPgSQLLogRequest() + cmd := &cobra.Command{ + Use: "backup", + Short: "Back up the log package of a UPgSQL instance", + Long: "Back up the slow/error log package of a UPgSQL instance", + Run: func(c *cobra.Command, args []string) { + *req.InstanceID = ctx.PickResourceID(*req.InstanceID) + if beginTime != "" { + bt, err := time.Parse(common.DateTimeLayout, beginTime) + if err != nil { + ctx.HandleError(fmt.Errorf("invalid begin-time (use %s): %w", common.DateTimeLayout, err)) + return + } + req.BeginTime = sdk.Int(int(bt.Unix())) + } + if endTime != "" { + et, err := time.Parse(common.DateTimeLayout, endTime) + if err != nil { + ctx.HandleError(fmt.Errorf("invalid end-time (use %s): %w", common.DateTimeLayout, err)) + return + } + req.EndTime = sdk.Int(int(et.Unix())) + } + _, err := client.BackupUPgSQLLog(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "log of pgsql[%s] backuped\n", *req.InstanceID) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceID, Action: "log-backup", Status: "Backuped"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance") + req.BackupName = flags.String("name", "", "Required. Name of the exported backup file") + req.BackupFile = flags.String("backup-file", "", "Required. Name of the log query result file") + req.LogType = flags.String("log-type", "", "Optional. Log type. Accept values: slow, error") + flags.StringVar(&beginTime, "begin-time", "", "Optional. Log begin time, e.g. 2019-01-02/15:04:05") + flags.StringVar(&endTime, "end-time", "", "Optional. Log end time, e.g. 2019-01-02/15:04:05") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + command.SetFlagValues(cmd, "log-type", pgsqlLogTypeList...) + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("backup-file") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/log_list.go b/products/pgsql/internal/pgsql/log_list.go new file mode 100644 index 0000000000..5606e335f6 --- /dev/null +++ b/products/pgsql/internal/pgsql/log_list.go @@ -0,0 +1,75 @@ +package pgsql + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newLogList ucloud pgsql log list +func newLogList(ctx *cli.Context) *cobra.Command { + var beginTime, endTime string + client := newUPgSQLClient(ctx) + req := client.NewListUPgSQLLogRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List logs of a UPgSQL instance within a time range", + Long: "List logs of a UPgSQL instance within a time range", + Run: func(c *cobra.Command, args []string) { + *req.InstanceID = ctx.PickResourceID(*req.InstanceID) + bt, err := time.Parse(common.DateTimeLayout, beginTime) + if err != nil { + ctx.HandleError(fmt.Errorf("invalid begin-time (use %s): %w", common.DateTimeLayout, err)) + return + } + req.BeginTime = sdk.Int(int(bt.Unix())) + et, err := time.Parse(common.DateTimeLayout, endTime) + if err != nil { + ctx.HandleError(fmt.Errorf("invalid end-time (use %s): %w", common.DateTimeLayout, err)) + return + } + req.EndTime = sdk.Int(int(et.Unix())) + resp, err := client.ListUPgSQLLog(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []PgsqlLogRow{} + for _, l := range resp.DataSet { + list = append(list, PgsqlLogRow{ + Name: l.Name, + Size: fmt.Sprintf("%dB", l.Size), + BeginTime: common.FormatDateTime(l.BeginTime), + EndTime: common.FormatDateTime(l.EndTime), + }) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance") + flags.StringVar(&beginTime, "begin-time", "", "Required. Begin time, e.g. 2019-01-02/15:04:05") + flags.StringVar(&endTime, "end-time", "", "Required. End time, e.g. 2019-01-02/15:04:05") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("begin-time") + cmd.MarkFlagRequired("end-time") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/poll.go b/products/pgsql/internal/pgsql/poll.go new file mode 100644 index 0000000000..2a04c10d1f --- /dev/null +++ b/products/pgsql/internal/pgsql/poll.go @@ -0,0 +1,54 @@ +package pgsql + +import ( + "fmt" + "io" + + "github.com/ucloud/ucloud-sdk-go/services/upgsql" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// stopPgsqlIns stops the instance and narrates progress to out (the caller passes +// ctx.ProgressWriter(): stdout in table mode, stderr in json/yaml). Returns the +// stop error so callers can decide whether to record a structured result. +// Mirrors products/mysql/internal/mysql/poll.go stopUdbIns. +func stopPgsqlIns(ctx *cli.Context, req *upgsql.StopUPgSQLInstanceRequest, async bool, out io.Writer) error { + client := newUPgSQLClient(ctx) + _, err := client.StopUPgSQLInstance(req) + if err != nil { + ctx.HandleError(err) + return err + } + text := fmt.Sprintf("pgsql[%s] is stopping", *req.InstanceID) + if async { + fmt.Fprintln(out, text) + } else { + ctx.PollerTo(out, describePgsqlByID(ctx)).Spoll(*req.InstanceID, text, []string{PGSQL_STOPPED, PGSQL_SHUTDOWN_FAILED}) + } + return nil +} + +// describePgsqlByID returns the poller's describe func, closing over ctx so it +// can build an authed upgsql client. Mirrors products/mysql/internal/mysql/poll.go +// describeUdbByID, but uses GetUPgSQLInstance (single-instance describe). +func describePgsqlByID(ctx *cli.Context) func(instanceID string, commonBase *request.CommonBase) (interface{}, error) { + return func(instanceID string, commonBase *request.CommonBase) (interface{}, error) { + client := newUPgSQLClient(ctx) + req := client.NewGetUPgSQLInstanceRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.InstanceID = sdk.String(instanceID) + resp, err := client.GetUPgSQLInstance(req) + if err != nil { + return nil, err + } + if resp.DataSet.InstanceID == "" { + return nil, fmt.Errorf("pgsql[%s] may not exist", instanceID) + } + return &resp.DataSet, nil + } +} diff --git a/products/pgsql/internal/pgsql/price.go b/products/pgsql/internal/pgsql/price.go new file mode 100644 index 0000000000..c78d984e59 --- /dev/null +++ b/products/pgsql/internal/pgsql/price.go @@ -0,0 +1,63 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newPrice ucloud pgsql db price +func newPrice(ctx *cli.Context) *cobra.Command { + client := newUPgSQLClient(ctx) + req := client.NewGetUPgSQLInstancePriceRequest() + cmd := &cobra.Command{ + Use: "price", + Short: "Get the price of creating UPgSQL instances", + Long: "Get the price of creating UPgSQL instances", + Run: func(c *cobra.Command, args []string) { + if *req.ChargeType == "Dynamic" { + req.Quantity = sdk.Int(0) + } + resp, err := client.GetUPgSQLInstancePrice(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := []PgsqlPriceRow{} + for _, p := range resp.PriceSet { + rows = append(rows, PgsqlPriceRow{ + ChargeType: p.ChargeType, + Price: p.Price, + OriginalPrice: p.OriginalPrice, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.MachineType = flags.String("machine-type", "", "Required. Machine type ID, e.g. o.pgsql2m.medium. See 'ucloud pgsql db list-machine-type'") + req.DiskSpace = flags.Int("disk-size-gb", 0, "Required. Disk space (GiB)") + req.InstanceMode = flags.String("mode", "Normal", "Required. Normal / HA") + req.ChargeType = flags.String("charge-type", "Month", "Optional. Year / Month / Dynamic") + req.Quantity = flags.Int("quantity", 1, "Optional. Purchase duration. Month: 1-9, 0=until end of month; Dynamic: ignored; Year: years") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic") + command.SetFlagValues(cmd, "mode", "Normal", "HA") + command.SetCompletion(cmd, "machine-type", func() []string { + return listMachineTypeIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + cmd.MarkFlagRequired("machine-type") + cmd.MarkFlagRequired("disk-size-gb") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/reset_password.go b/products/pgsql/internal/pgsql/reset_password.go new file mode 100644 index 0000000000..890e5ac01a --- /dev/null +++ b/products/pgsql/internal/pgsql/reset_password.go @@ -0,0 +1,54 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResetPassword ucloud pgsql db reset-password +func newResetPassword(ctx *cli.Context) *cobra.Command { + var idNames []string + client := newUPgSQLClient(ctx) + req := client.NewUpdateUPgSQLPasswordRequest() + cmd := &cobra.Command{ + Use: "reset-password", + Short: "Reset the admin password of UPgSQL instances", + Long: "Reset the admin password of UPgSQL instances", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.InstanceID = sdk.String(id) + _, err := client.UpdateUPgSQLPassword(req) + if err != nil { + ctx.HandleError(err) + continue + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "reset-password", Status: "PasswordReset"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to reset password") + req.Password = flags.String("password", "", "Required. New password") + req.Name = flags.String("name", "", "Optional. Database user name, default root") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("password") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/restart.go b/products/pgsql/internal/pgsql/restart.go new file mode 100644 index 0000000000..071b50eede --- /dev/null +++ b/products/pgsql/internal/pgsql/restart.go @@ -0,0 +1,66 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestart ucloud pgsql db restart +func newRestart(ctx *cli.Context) *cobra.Command { + var async bool + var idNames []string + client := newUPgSQLClient(ctx) + req := client.NewRestartUPgSQLInstanceRequest() + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart UPgSQL instances by instance-id", + Long: "Restart UPgSQL instances by instance-id", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.InstanceID = sdk.String(id) + _, err := client.RestartUPgSQLInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + if async { + fmt.Fprintf(w, "pgsql[%s] is restarting\n", idname) + } else { + text := fmt.Sprintf("pgsql[%s] is restarting", idname) + ctx.PollerTo(w, describePgsqlByID(ctx)).Spoll(id, text, []string{PGSQL_RUNNING, PGSQL_START_FAILED, PGSQL_SHUTDOWN_FAILED}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "restart", Status: "Restarting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to restart") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + req.ForceToRestart = flags.Bool("force", false, "Optional. Restart UPgSQL instances by force or not") + req.RestartHost = flags.Bool("restart-host", false, "Optional. Restart the host together or not") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + + cmd.MarkFlagRequired("instance-id") + + command.SetFlagValues(cmd, "force", "true", "false") + command.SetFlagValues(cmd, "restart-host", "true", "false") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + return cmd +} diff --git a/products/pgsql/internal/pgsql/rows.go b/products/pgsql/internal/pgsql/rows.go new file mode 100644 index 0000000000..e66e514df2 --- /dev/null +++ b/products/pgsql/internal/pgsql/rows.go @@ -0,0 +1,120 @@ +package pgsql + +// PgsqlInstanceRow is the table row for `pgsql db list`. +type PgsqlInstanceRow struct { + Name string + InstanceID string + Zone string + State string + IP string + VPC string + Subnet string + InstanceMode string + DBVersion string + Port int + DiskSpace int + Memory int +} + +// PgsqlMachineTypeRow is the table row for `pgsql db list-machine-type`. +type PgsqlMachineTypeRow struct { + ID string + Description string + Cpu int + Memory int + Os string +} + +// PgsqlVersionRow is the table row for `pgsql db list-version`. +type PgsqlVersionRow struct { + DBVersion string + Available string +} + +// PgsqlConfRow is the table row for `pgsql conf list`. +type PgsqlConfRow struct { + GroupID int + GroupName string + DBVersion string + Description string + Modifiable bool +} + +// PgsqlConfParamRow is the table row for `pgsql conf describe` parameter list. +type PgsqlConfParamRow struct { + Key string + Value string + Modifiable bool +} + +// PgsqlBackupRow is the table row for `pgsql backup list`. +type PgsqlBackupRow struct { + BackupID string + BackupName string + InstanceID string + State string + BackupType string + BackupSize string + BackupStartTime string + BackupEndTime string +} + +// PgsqlBackupURLRow is the table row for `pgsql backup download`. +type PgsqlBackupURLRow struct { + BackupPath string + InnerBackupPath string +} + +// PgsqlBackupStrategyRow is the table row for `pgsql backup strategy`. +type PgsqlBackupStrategyRow struct { + BackupMethod string + BackupTimeRange string + BackupWeek string +} + +// PgsqlLogRow is the table row for `pgsql log list`. +type PgsqlLogRow struct { + Name string + Size string + BeginTime string + EndTime string +} + +// PgsqlPriceRow is the table row for `pgsql db price` / `upgrade-price`. +type PgsqlPriceRow struct { + ChargeType string + Price float64 + OriginalPrice float64 +} + +// SupabaseInstanceRow is the table row for `pgsql supabase list`. +type SupabaseInstanceRow struct { + USupabaseName string + InstanceID string + UPgSQLID string + Zone string + IntranetAddress string + Port int + State string +} + +// SupabaseStorageConfigRow is the table row for `pgsql supabase get-storage-config`. +type SupabaseStorageConfigRow struct { + Key string + Value string + Description string + Required bool +} + +// SupabaseAPIKeyRow is the table row for `pgsql supabase get-api-key`. +type SupabaseAPIKeyRow struct { + ServiceKey string + AnonKey string +} + +// SupabaseChargeRow is the table row for `pgsql supabase external-price` / +// `bandwidth-upgrade-price`. +type SupabaseChargeRow struct { + ChargeType string + Price int +} diff --git a/products/pgsql/internal/pgsql/start.go b/products/pgsql/internal/pgsql/start.go new file mode 100644 index 0000000000..85568db2c4 --- /dev/null +++ b/products/pgsql/internal/pgsql/start.go @@ -0,0 +1,61 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStart ucloud pgsql db start +func newStart(ctx *cli.Context) *cobra.Command { + var async bool + var idNames []string + client := newUPgSQLClient(ctx) + req := client.NewStartUPgSQLInstanceRequest() + cmd := &cobra.Command{ + Use: "start", + Short: "Start UPgSQL instances by instance-id", + Long: "Start UPgSQL instances by instance-id", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.InstanceID = sdk.String(id) + _, err := client.StartUPgSQLInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + if async { + fmt.Fprintf(w, "pgsql[%s] is starting\n", idname) + } else { + text := fmt.Sprintf("pgsql[%s] is starting", idname) + ctx.PollerTo(w, describePgsqlByID(ctx)).Spoll(id, text, []string{PGSQL_RUNNING, PGSQL_START_FAILED}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "start", Status: "Starting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to start") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + + cmd.MarkFlagRequired("instance-id") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + return cmd +} diff --git a/products/pgsql/internal/pgsql/status.go b/products/pgsql/internal/pgsql/status.go new file mode 100644 index 0000000000..527efe3693 --- /dev/null +++ b/products/pgsql/internal/pgsql/status.go @@ -0,0 +1,47 @@ +package pgsql + +// UPgSQL-domain state constants. +// +// These follow the LIVE API State enum (as observed from ListUPgSQLInstance / +// GetUPgSQLInstance responses): Initing / InitFailed / Starting / Running / +// Stopping / Stopped / Deleted / Upgrading / Promoting / Recovering / +// RecoverFailed / StartFailed / ShutdownFailed / Deleting / DeleteFailed. +// +// NOTE: the published GetUPgSQLInstance doc lists "Shutoff"/"Shutdown"/"Fail" +// but the live API does NOT return those — a stopped instance reports State= +// "Stopped" (not "Shutoff"). Using the doc values caused the stop poller to +// never match its target and spin until the 10m timeout. Trust the live enum. +const ( + PGSQL_RUNNING = "Running" + PGSQL_STOPPING = "Stopping" + PGSQL_STOPPED = "Stopped" + PGSQL_INITING = "Initing" + PGSQL_INIT_FAILED = "InitFailed" + PGSQL_STARTING = "Starting" + PGSQL_START_FAILED = "StartFailed" + PGSQL_SHUTDOWN_FAILED = "ShutdownFailed" + PGSQL_DELETING = "Deleting" + PGSQL_DELETED = "Deleted" + PGSQL_DELETE_FAILED = "DeleteFailed" + PGSQL_UPGRADING = "Upgrading" + PGSQL_PROMOTING = "Promoting" + PGSQL_RECOVERING = "Recovering" + PGSQL_RECOVER_FAILED = "RecoverFailed" +) + +// Backup state constants, from ListUPgSQLBackup.UPgSQLBackup.State enum values +// (Backuping / Success / Failed / Expired). Display-only; backup ops are not polled. +const ( + PGSQL_BACKUP_SUCCESS = "Success" + PGSQL_BACKUP_FAILED = "Failed" +) + +// USupabase state constants (live-observed from ListUSupabaseInstance / +// DescribeUSupabase responses). Used as poll targets for start/stop/restart. +// As with the pgsql enum, the published doc is untrusted; these are confirmed +// live (Running confirmed; Stopped pending a live stop verification). +const ( + SUPABASE_RUNNING = "Running" + SUPABASE_STOPPED = "Stopped" + SUPABASE_FAIL = "Fail" +) diff --git a/products/pgsql/internal/pgsql/stop.go b/products/pgsql/internal/pgsql/stop.go new file mode 100644 index 0000000000..ad2feff783 --- /dev/null +++ b/products/pgsql/internal/pgsql/stop.go @@ -0,0 +1,58 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStop ucloud pgsql db stop +func newStop(ctx *cli.Context) *cobra.Command { + var idNames []string + var async bool + client := newUPgSQLClient(ctx) + req := client.NewStopUPgSQLInstanceRequest() + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop UPgSQL instances by instance-id", + Long: "Stop UPgSQL instances by instance-id", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.InstanceID = sdk.String(id) + if err := stopPgsqlIns(ctx, req, async, w); err != nil { + ctx.HandleError(err) + continue + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "stop", Status: "Stopping"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to stop") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + req.ForceToStop = flags.Bool("force", false, "Optional. Stop UPgSQL instances by force or not") + req.StopHost = flags.Bool("stop-host", false, "Optional. Stop the host together or not") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + + cmd.MarkFlagRequired("instance-id") + + command.SetFlagValues(cmd, "force", "true", "false") + command.SetFlagValues(cmd, "stop-host", "true", "false") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/stop_creating_readonly.go b/products/pgsql/internal/pgsql/stop_creating_readonly.go new file mode 100644 index 0000000000..c49b67dd53 --- /dev/null +++ b/products/pgsql/internal/pgsql/stop_creating_readonly.go @@ -0,0 +1,51 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStopCreatingReadonly ucloud pgsql db stop-creating-readonly +func newStopCreatingReadonly(ctx *cli.Context) *cobra.Command { + var idNames []string + client := newUPgSQLClient(ctx) + req := client.NewStopUPgSQLCreatingReadonlyRequest() + cmd := &cobra.Command{ + Use: "stop-creating-readonly", + Short: "Stop readonly replicas that are still being created", + Long: "Stop readonly replicas of a UPgSQL instance that are still being created", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.InstanceID = sdk.String(id) + _, err := client.StopUPgSQLCreatingReadonly(req) + if err != nil { + ctx.HandleError(err) + continue + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "stop-creating-readonly", Status: "Stopped"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of the readonly replicas to stop creating") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("instance-id") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase.go b/products/pgsql/internal/pgsql/supabase.go new file mode 100644 index 0000000000..0974adeb5e --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase.go @@ -0,0 +1,39 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPgsqlSupabase ucloud pgsql supabase +// +// USupabase is a pgsql-attached enhancement product (a community Supabase stack +// deployed onto a UPgSQL host). Its gateway actions are not in ucloud-sdk-go, so +// they are invoked generically (see supabase_client.go). The MemoryDB (AI memory) +// variant is the same backend toggled by --memory-db; create has its own action. +func newPgsqlSupabase(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "supabase", + Short: "Manage USupabase instances (and the MemoryDB AI-memory variant)", + Long: "Manage USupabase instances (and the MemoryDB AI-memory variant)", + } + cmd.AddCommand(newSupabaseList(ctx)) + cmd.AddCommand(newSupabaseDescribe(ctx)) + cmd.AddCommand(newSupabaseCreate(ctx)) + cmd.AddCommand(newSupabaseCreateMemoryDB(ctx)) + cmd.AddCommand(newSupabaseDelete(ctx)) + cmd.AddCommand(newSupabaseStart(ctx)) + cmd.AddCommand(newSupabaseStop(ctx)) + cmd.AddCommand(newSupabaseRestart(ctx)) + cmd.AddCommand(newSupabaseResetPassword(ctx)) + cmd.AddCommand(newSupabaseGetAPIKey(ctx)) + cmd.AddCommand(newSupabaseGetStorageConfig(ctx)) + cmd.AddCommand(newSupabaseSetStorageConfig(ctx)) + cmd.AddCommand(newSupabaseEnableExternal(ctx)) + cmd.AddCommand(newSupabaseDisableExternal(ctx)) + cmd.AddCommand(newSupabaseModifyExternal(ctx)) + cmd.AddCommand(newSupabaseExternalPrice(ctx)) + cmd.AddCommand(newSupabaseBandwidthUpgradePrice(ctx)) + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_bandwidth_upgrade_price.go b/products/pgsql/internal/pgsql/supabase_bandwidth_upgrade_price.go new file mode 100644 index 0000000000..29be42dd2b --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_bandwidth_upgrade_price.go @@ -0,0 +1,51 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseBandwidthUpgradePrice ucloud pgsql supabase bandwidth-upgrade-price +func newSupabaseBandwidthUpgradePrice(ctx *cli.Context) *cobra.Command { + var instanceID string + var bandwidth int + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "bandwidth-upgrade-price", + Short: "Get the price of upgrading external-access bandwidth of a USupabase instance", + Long: "Get the price of upgrading external-access bandwidth of a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + params["Bandwidth"] = bandwidth + payload, err := invokeSupabase(ctx, "DescribeUSupabaseBandwithUpgradePrice", params) + if err != nil { + ctx.HandleError(err) + return + } + rows := []SupabaseChargeRow{} + if ds, ok := payload["DataSet"].([]interface{}); ok { + for _, item := range ds { + m, _ := item.(map[string]interface{}) + rows = append(rows, SupabaseChargeRow{ + ChargeType: getString(m, "ChargeType"), + Price: getInt(m, "Price"), + }) + } + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + flags.IntVar(&bandwidth, "bandwidth", 0, "Required. Target bandwidth (Mbps)") + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("bandwidth") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_client.go b/products/pgsql/internal/pgsql/supabase_client.go new file mode 100644 index 0000000000..2a876627e9 --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_client.go @@ -0,0 +1,58 @@ +package pgsql + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUSupabaseClient returns an authed generic SDK client whose requests are +// JSON-encoded, reusing the platform credential/handlers (so AK/SK and OAuth +// profiles both sign correctly, and project-id normalization / OAuth retry +// apply uniformly). ucloud-sdk-go has no typed USupabase methods, so supabase +// actions are invoked generically: a simple map payload signed and POSTed as a +// JSON body. JSON (not form) is mandatory — the USupabase gateway does +// json.Unmarshal on the request and fails on string-into-int fields (same +// 214001 bug as UPgSQL). +func newUSupabaseClient(ctx *cli.Context) *uaccount.UAccountClient { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + _ = client.AddRequestHandler(func(c *sdk.Client, req request.Common) (request.Common, error) { + req.SetEncoder(request.NewJSONEncoder(c.GetConfig(), c.GetCredential())) + return req, nil + }) + return client +} + +// invokeSupabase calls a USupabase action with a simple map payload and returns +// the response payload map. Region/Zone/ProjectId/IsMemoryDB and business fields +// are all part of params; the caller is responsible for putting them in. The +// SDK signs the map (cred.Apply adds PublicKey + Signature) and the JSONEncoder +// marshals it with native types. A non-zero RetCode is returned as an error by +// GenericInvoke (RetCodePatcher), so callers forward it to ctx.HandleError. +func invokeSupabase(ctx *cli.Context, action string, params map[string]interface{}) (map[string]interface{}, error) { + client := newUSupabaseClient(ctx) + req := client.NewGenericRequest() + payload := make(map[string]interface{}, len(params)+1) + payload["Action"] = action + for k, v := range params { + payload[k] = v + } + if err := req.SetPayload(payload); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, err + } + return resp.GetPayload(), nil +} + +// supabaseState is a typed view over a generic response's State field, used to +// avoid repeated map[string]interface{} casts in command code. +type supabaseState struct { + State string +} diff --git a/products/pgsql/internal/pgsql/supabase_create.go b/products/pgsql/internal/pgsql/supabase_create.go new file mode 100644 index 0000000000..06b9bc900e --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_create.go @@ -0,0 +1,126 @@ +package pgsql + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// supabaseCreateFlags holds the shared business flags for CreateUSupabase and +// CreateUMemoryDB (the two requests have identical business fields). +type supabaseCreateFlags struct { + common *supabaseCommon + instanceName, dashboardName, dashboardPassword, upgsqlUserName, dbVersion string + upgsqlPassword, machineType, subnetID, vpcID, instanceMode, chargeType string + usupabasePort, paramGroupID, upgsqlPort, diskSpace, quantity int + labels []string +} + +func bindSupabaseCreate(cmd *cobra.Command, ctx *cli.Context) *supabaseCreateFlags { + f := &supabaseCreateFlags{} + f.common = bindSupabaseCommon(cmd, ctx) + flags := cmd.Flags() + flags.StringVar(&f.instanceName, "name", "", "Required. Supabase instance name") + flags.StringVar(&f.dashboardName, "dashboard-name", "", "Required. Dashboard user name") + flags.StringVar(&f.dashboardPassword, "dashboard-password", "", "Required. Dashboard password") + flags.IntVar(&f.usupabasePort, "supabase-port", 8000, "Optional. Supabase service port, default 8000") + flags.StringVar(&f.upgsqlUserName, "pgsql-user", "", "Required. UPgSQL user name") + flags.StringVar(&f.dbVersion, "db-version", "", "Required. UPgSQL version, e.g. postgresql-13.4") + flags.IntVar(&f.paramGroupID, "param-group-id", 0, "Required. UPgSQL param group ID") + flags.StringVar(&f.upgsqlPassword, "pgsql-password", "", "Required. UPgSQL password") + flags.IntVar(&f.upgsqlPort, "pgsql-port", 5432, "Optional. UPgSQL port, default 5432") + flags.IntVar(&f.diskSpace, "disk-size-gb", 0, "Required. Disk space (GiB)") + flags.StringVar(&f.machineType, "machine-type", "", "Required. Machine type, e.g. o.pgsql2m.medium. See 'ucloud pgsql db list-machine-type'") + flags.StringVar(&f.subnetID, "subnet-id", "", "Required. Subnet ID") + flags.StringVar(&f.vpcID, "vpc-id", "", "Required. VPC ID") + flags.StringVar(&f.instanceMode, "mode", "Normal", "Optional. Normal / HA") + flags.StringVar(&f.chargeType, "charge-type", "Month", "Optional. Year / Month / Dynamic") + flags.IntVar(&f.quantity, "quantity", 1, "Optional. Purchase duration") + flags.StringSliceVar(&f.labels, "label", nil, "Optional. Resource label key=value (repeatable)") + command.SetFlagValues(cmd, "db-version", pgsqlVersionList...) + command.SetFlagValues(cmd, "mode", "Normal", "HA") + command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic") + return f +} + +func (f *supabaseCreateFlags) params() map[string]interface{} { + p := f.common.params() + p["InstanceName"] = f.instanceName + p["DashboardName"] = f.dashboardName + p["DashboardPassword"] = f.dashboardPassword + p["USupabasePort"] = f.usupabasePort + p["UPgSQLUserName"] = f.upgsqlUserName + p["DBVersion"] = f.dbVersion + p["ParamGroupID"] = f.paramGroupID + p["UPgSQLPassword"] = f.upgsqlPassword + p["UPgSQLPort"] = f.upgsqlPort + p["DiskSpace"] = f.diskSpace + p["MachineType"] = f.machineType + p["SubnetID"] = f.subnetID + p["VPCID"] = f.vpcID + p["InstanceMode"] = f.instanceMode + p["ChargeType"] = f.chargeType + p["Quantity"] = f.quantity + labels := []map[string]interface{}{} + for _, l := range f.labels { + parts := strings.SplitN(l, "=", 2) + if len(parts) == 2 { + labels = append(labels, map[string]interface{}{"Key": parts[0], "Value": parts[1]}) + } + } + p["Labels"] = labels + return p +} + +// runSupabaseCreate is shared by create and create-memory-db: invoke the action, +// poll the returned InstanceID to Running, emit the result. +func runSupabaseCreate(ctx *cli.Context, action string, f *supabaseCreateFlags, async bool) { + params := f.params() + payload, err := invokeSupabase(ctx, action, params) + if err != nil { + ctx.HandleError(err) + return + } + instanceID := getString(payload, "InstanceID") + if instanceID == "" { + ctx.HandleError(fmt.Errorf("empty InstanceID in response")) + return + } + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "supabase[%s] is initializing\n", instanceID) + } else { + text := fmt.Sprintf("supabase[%s] is initializing", instanceID) + ctx.PollerTo(w, describeSupabaseByID(ctx, f.common.region, f.common.zone, f.common.projectID, f.common.memoryDB)). + Spoll(instanceID, text, []string{SUPABASE_RUNNING, SUPABASE_FAIL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "create", Status: "Initializing"}) +} + +// newSupabaseCreate ucloud pgsql supabase create +func newSupabaseCreate(ctx *cli.Context) *cobra.Command { + var async bool + var f *supabaseCreateFlags + cmd := &cobra.Command{ + Use: "create", + Short: "Create a USupabase instance", + Long: "Create a USupabase instance (deploys a Supabase stack onto a UPgSQL host)", + Run: func(c *cobra.Command, args []string) { + runSupabaseCreate(ctx, "CreateUSupabase", f, async) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + f = bindSupabaseCreate(cmd, ctx) + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish") + + for _, req := range []string{"name", "dashboard-name", "dashboard-password", "pgsql-user", "db-version", "param-group-id", "pgsql-password", "disk-size-gb", "machine-type", "subnet-id", "vpc-id"} { + cmd.MarkFlagRequired(req) + } + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_create_memory_db.go b/products/pgsql/internal/pgsql/supabase_create_memory_db.go new file mode 100644 index 0000000000..1c693782b1 --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_create_memory_db.go @@ -0,0 +1,35 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseCreateMemoryDB ucloud pgsql supabase create-memory-db +// +// The MemoryDB (AI memory) variant uses the same business fields as create but +// the dedicated CreateUMemoryDB action (CreateUSupabaseRequest has no IsMemoryDB +// field, so the two are distinguished by action name, not a flag). +func newSupabaseCreateMemoryDB(ctx *cli.Context) *cobra.Command { + var async bool + var f *supabaseCreateFlags + cmd := &cobra.Command{ + Use: "create-memory-db", + Short: "Create a UMemoryDB (AI memory) instance", + Long: "Create a UMemoryDB (AI memory) instance — the Supabase-based AI memory variant", + Run: func(c *cobra.Command, args []string) { + runSupabaseCreate(ctx, "CreateUMemoryDB", f, async) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + f = bindSupabaseCreate(cmd, ctx) + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish") + + for _, req := range []string{"name", "dashboard-name", "dashboard-password", "pgsql-user", "db-version", "param-group-id", "pgsql-password", "disk-size-gb", "machine-type", "subnet-id", "vpc-id"} { + cmd.MarkFlagRequired(req) + } + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_delete.go b/products/pgsql/internal/pgsql/supabase_delete.go new file mode 100644 index 0000000000..cac85e3322 --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_delete.go @@ -0,0 +1,49 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseDelete ucloud pgsql supabase delete +func newSupabaseDelete(ctx *cli.Context) *cobra.Command { + var instanceID string + var yes bool + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a USupabase instance", + Long: "Delete a USupabase instance", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure you want to delete supabase[%s]?", instanceID)) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + params := common.params() + params["InstanceID"] = instanceID + if _, err := invokeSupabase(ctx, "DeleteUSupabase", params); err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] deleted\n", instanceID) + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "delete", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation") + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_describe.go b/products/pgsql/internal/pgsql/supabase_describe.go new file mode 100644 index 0000000000..85cc74cc0a --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_describe.go @@ -0,0 +1,62 @@ +package pgsql + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseDescribe ucloud pgsql supabase describe +func newSupabaseDescribe(ctx *cli.Context) *cobra.Command { + var instanceID string + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "describe", + Short: "Display details of a USupabase instance", + Long: "Display details of a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + payload, err := invokeSupabase(ctx, "DescribeUSupabase", params) + if err != nil { + ctx.HandleError(err) + return + } + ds, ok := payload["DataSet"].(map[string]interface{}) + if !ok { + ctx.HandleError(fmt.Errorf("pgsql supabase[%s] may not exist", instanceID)) + return + } + attrs := []cli.DescribeRow{ + {Attribute: "InstanceID", Content: getString(ds, "InstanceID")}, + {Attribute: "USupabaseName", Content: getString(ds, "USupabaseName")}, + {Attribute: "State", Content: getString(ds, "State")}, + {Attribute: "Zone", Content: getString(ds, "Zone")}, + {Attribute: "UPgSQLID", Content: getString(ds, "UPgSQLID")}, + {Attribute: "VPCID", Content: getString(ds, "VPCID")}, + {Attribute: "SubnetID", Content: getString(ds, "SubnetID")}, + {Attribute: "IntranetAddress", Content: getString(ds, "IntranetAddress")}, + {Attribute: "Port", Content: strconv.Itoa(getInt(ds, "Port"))}, + {Attribute: "ExternalNetworkStatus", Content: getString(ds, "ExternalNetworkStatus")}, + {Attribute: "ExternalNetworkAddress", Content: getString(ds, "ExternalNetworkAddress")}, + {Attribute: "ExternalNetworkPort", Content: strconv.Itoa(getInt(ds, "ExternalNetworkPort"))}, + {Attribute: "Bandwidth", Content: strconv.Itoa(getInt(ds, "Bandwidth"))}, + {Attribute: "WhiteList", Content: getString(ds, "WhiteList")}, + } + fmt.Fprintln(ctx.ProgressWriter(), "Attributes:") + ctx.PrintList(attrs) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_disable_external.go b/products/pgsql/internal/pgsql/supabase_disable_external.go new file mode 100644 index 0000000000..3e9426e45d --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_disable_external.go @@ -0,0 +1,39 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseDisableExternal ucloud pgsql supabase disable-external-access +func newSupabaseDisableExternal(ctx *cli.Context) *cobra.Command { + var instanceID string + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "disable-external-access", + Short: "Disable external access for a USupabase instance", + Long: "Disable external network access for a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + if _, err := invokeSupabase(ctx, "DisableUSupabaseExternalAccess", params); err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] external access disabled\n", instanceID) + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "disable-external-access", Status: "Disabled"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_enable_external.go b/products/pgsql/internal/pgsql/supabase_enable_external.go new file mode 100644 index 0000000000..2915d39206 --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_enable_external.go @@ -0,0 +1,43 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseEnableExternal ucloud pgsql supabase enable-external-access +func newSupabaseEnableExternal(ctx *cli.Context) *cobra.Command { + var instanceID string + var bandwidth int + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "enable-external-access", + Short: "Enable external access for a USupabase instance", + Long: "Enable external network access for a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + params["Bandwidth"] = bandwidth + if _, err := invokeSupabase(ctx, "EnableUSupabaseExternalAccess", params); err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] external access enabled\n", instanceID) + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "enable-external-access", Status: "Enabled"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + flags.IntVar(&bandwidth, "bandwidth", 0, "Required. Bandwidth (Mbps)") + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("bandwidth") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_external_price.go b/products/pgsql/internal/pgsql/supabase_external_price.go new file mode 100644 index 0000000000..8131b87bb2 --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_external_price.go @@ -0,0 +1,51 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseExternalPrice ucloud pgsql supabase external-price +func newSupabaseExternalPrice(ctx *cli.Context) *cobra.Command { + var instanceID string + var bandwidth int + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "external-price", + Short: "Get the price of enabling external access for a USupabase instance", + Long: "Get the price of enabling external access for a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + params["BandWidth"] = bandwidth + payload, err := invokeSupabase(ctx, "DescribeUSupabaseExternalPrice", params) + if err != nil { + ctx.HandleError(err) + return + } + rows := []SupabaseChargeRow{} + if ds, ok := payload["DataSet"].([]interface{}); ok { + for _, item := range ds { + m, _ := item.(map[string]interface{}) + rows = append(rows, SupabaseChargeRow{ + ChargeType: getString(m, "ChargeType"), + Price: getInt(m, "Price"), + }) + } + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + flags.IntVar(&bandwidth, "bandwidth", 0, "Required. Bandwidth (Mbps)") + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("bandwidth") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_get_api_key.go b/products/pgsql/internal/pgsql/supabase_get_api_key.go new file mode 100644 index 0000000000..2f1347bbbf --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_get_api_key.go @@ -0,0 +1,41 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseGetAPIKey ucloud pgsql supabase get-api-key +func newSupabaseGetAPIKey(ctx *cli.Context) *cobra.Command { + var instanceID string + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "get-api-key", + Short: "Display the API keys (service key + anon key) of a USupabase instance", + Long: "Display the API keys (service key + anon key) of a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + payload, err := invokeSupabase(ctx, "GetUSupabaseAPIKey", params) + if err != nil { + ctx.HandleError(err) + return + } + key, _ := payload["Key"].(map[string]interface{}) + ctx.PrintList([]SupabaseAPIKeyRow{{ + ServiceKey: getString(key, "ServiceKey"), + AnonKey: getString(key, "AnonKey"), + }}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_get_storage_config.go b/products/pgsql/internal/pgsql/supabase_get_storage_config.go new file mode 100644 index 0000000000..566549b7dc --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_get_storage_config.go @@ -0,0 +1,49 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseGetStorageConfig ucloud pgsql supabase get-storage-config +func newSupabaseGetStorageConfig(ctx *cli.Context) *cobra.Command { + var instanceID string + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "get-storage-config", + Short: "Display the storage configuration of a USupabase instance", + Long: "Display the storage configuration of a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + payload, err := invokeSupabase(ctx, "GetUSupabaseStorageConfig", params) + if err != nil { + ctx.HandleError(err) + return + } + rows := []SupabaseStorageConfigRow{} + if ds, ok := payload["DataSet"].([]interface{}); ok { + for _, item := range ds { + m, _ := item.(map[string]interface{}) + rows = append(rows, SupabaseStorageConfigRow{ + Key: getString(m, "Key"), + Value: getString(m, "Value"), + Description: getString(m, "Description"), + Required: getBool(m, "Required"), + }) + } + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_list.go b/products/pgsql/internal/pgsql/supabase_list.go new file mode 100644 index 0000000000..9a77036b1b --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_list.go @@ -0,0 +1,133 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// supabaseCommon holds the region/zone/project-id/memory-db flags shared by +// every supabase command. Bound via bindSupabaseCommon so flag order stays +// consistent across verbs (the completion golden depends on it). +type supabaseCommon struct { + region string + zone string + projectID string + memoryDB bool +} + +// bindSupabaseCommon registers --region/--zone/--project-id/--memory-db with +// ctx defaults + completion. ProjectId is ALWAYS bound: USupabase IAM checks +// require the ProjectId context, so every action carries it. +func bindSupabaseCommon(cmd *cobra.Command, ctx *cli.Context) *supabaseCommon { + c := &supabaseCommon{} + flags := cmd.Flags() + flags.StringVar(&c.region, "region", ctx.DefaultRegion(), "Optional. Override default region, see 'ucloud region'") + flags.StringVar(&c.zone, "zone", ctx.DefaultZone(), "Optional. Override default zone, see 'ucloud region'") + flags.StringVar(&c.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id, see 'ucloud project list'") + flags.BoolVar(&c.memoryDB, "memory-db", false, "Optional. Operate on the MemoryDB (AI memory) variant") + command.SetCompletion(cmd, "region", func() []string { return ctx.RegionList() }) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(c.region) }) + command.SetCompletion(cmd, "project-id", func() []string { return ctx.ProjectList() }) + return c +} + +// params builds the map payload common to every supabase action: Region/Zone/ +// ProjectId/IsMemoryDB. ProjectId is mandatory (IAM context). Business fields +// are added by the caller. +func (c *supabaseCommon) params() map[string]interface{} { + p := map[string]interface{}{ + "Region": c.region, + "Zone": c.zone, + "ProjectId": c.projectID, + } + if c.memoryDB { + p["IsMemoryDB"] = true + } + return p +} + +// newSupabaseList ucloud pgsql supabase list +func newSupabaseList(ctx *cli.Context) *cobra.Command { + var instanceID string + var limit, offset int + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "list", + Short: "List USupabase instances", + Long: "List USupabase instances (or MemoryDB instances with --memory-db)", + Run: func(c *cobra.Command, args []string) { + params := common.params() + if instanceID != "" { + params["InstanceID"] = instanceID + } + if limit > 0 { + params["Limit"] = limit + } + if offset > 0 { + params["Offset"] = offset + } + payload, err := invokeSupabase(ctx, "ListUSupabaseInstance", params) + if err != nil { + ctx.HandleError(err) + return + } + rows := []SupabaseInstanceRow{} + if ds, ok := payload["DataSet"].([]interface{}); ok { + for _, item := range ds { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, SupabaseInstanceRow{ + USupabaseName: getString(m, "USupabaseName"), + InstanceID: getString(m, "InstanceID"), + UPgSQLID: getString(m, "UPgSQLID"), + Zone: getString(m, "Zone"), + IntranetAddress: getString(m, "IntranetAddress"), + Port: getInt(m, "Port"), + State: getString(m, "State"), + }) + } + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Optional. List only the specified USupabase instance") + flags.IntVar(&limit, "limit", 0, "Optional. Max instances per page (0 = default)") + flags.IntVar(&offset, "offset", 0, "Optional. Offset") + + return cmd +} + +// getString / getInt are tiny helpers over a generic response map (JSON numbers +// unmarshal as float64). +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func getInt(m map[string]interface{}, key string) int { + switch v := m[key].(type) { + case float64: + return int(v) + case int: + return v + } + return 0 +} + +func getBool(m map[string]interface{}, key string) bool { + if v, ok := m[key].(bool); ok { + return v + } + return false +} diff --git a/products/pgsql/internal/pgsql/supabase_modify_external.go b/products/pgsql/internal/pgsql/supabase_modify_external.go new file mode 100644 index 0000000000..f9d2515def --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_modify_external.go @@ -0,0 +1,52 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseModifyExternal ucloud pgsql supabase modify-external-access +func newSupabaseModifyExternal(ctx *cli.Context) *cobra.Command { + var instanceID, whiteList string + var bandwidth, port int + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "modify-external-access", + Short: "Modify external-access settings of a USupabase instance", + Long: "Modify external-access bandwidth/port/whitelist of a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + if c.Flags().Changed("bandwidth") { + params["Bandwidth"] = bandwidth + } + if c.Flags().Changed("port") { + params["Port"] = port + } + if c.Flags().Changed("white-list") { + params["WhiteList"] = whiteList + } + if _, err := invokeSupabase(ctx, "ModifyUSupabaseExternalAccessInfo", params); err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] external access modified\n", instanceID) + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "modify-external-access", Status: "Modified"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + flags.IntVar(&bandwidth, "bandwidth", 0, "Optional. New bandwidth (Mbps)") + flags.IntVar(&port, "port", 0, "Optional. New external port") + flags.StringVar(&whiteList, "white-list", "", "Optional. New IP whitelist") + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_poll.go b/products/pgsql/internal/pgsql/supabase_poll.go new file mode 100644 index 0000000000..26df50bb4d --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_poll.go @@ -0,0 +1,36 @@ +package pgsql + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// describeSupabaseByID returns the poller's describe func: DescribeUSupabase → State. +// region/zone/projectID/memoryDB are captured from the originating command's flags so +// the poll uses the same scope (USupabase IAM requires the ProjectId context). +// Signature matches pkg/cli.Poller: func(string, *request.CommonBase) (interface{}, error). +func describeSupabaseByID(ctx *cli.Context, region, zone, projectID string, memoryDB bool) func(instanceID string, _ *request.CommonBase) (interface{}, error) { + return func(instanceID string, _ *request.CommonBase) (interface{}, error) { + params := map[string]interface{}{ + "Region": region, + "Zone": zone, + "ProjectId": projectID, + "InstanceID": instanceID, + } + if memoryDB { + params["IsMemoryDB"] = true + } + payload, err := invokeSupabase(ctx, "DescribeUSupabase", params) + if err != nil { + return nil, err + } + ds, ok := payload["DataSet"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("pgsql supabase[%s] may not exist", instanceID) + } + return &supabaseState{State: getString(ds, "State")}, nil + } +} diff --git a/products/pgsql/internal/pgsql/supabase_reset_password.go b/products/pgsql/internal/pgsql/supabase_reset_password.go new file mode 100644 index 0000000000..df4322b114 --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_reset_password.go @@ -0,0 +1,42 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseResetPassword ucloud pgsql supabase reset-password +func newSupabaseResetPassword(ctx *cli.Context) *cobra.Command { + var instanceID, password string + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "reset-password", + Short: "Reset the dashboard password of a USupabase instance", + Long: "Reset the dashboard password of a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + params["Password"] = password + if _, err := invokeSupabase(ctx, "ResetUSupabasePassword", params); err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] password reset\n", instanceID) + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "reset-password", Status: "PasswordReset"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + flags.StringVar(&password, "password", "", "Required. New dashboard password") + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("password") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_restart.go b/products/pgsql/internal/pgsql/supabase_restart.go new file mode 100644 index 0000000000..7eb9038538 --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_restart.go @@ -0,0 +1,48 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseRestart ucloud pgsql supabase restart +func newSupabaseRestart(ctx *cli.Context) *cobra.Command { + var instanceID string + var async bool + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart a USupabase instance", + Long: "Restart a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + if _, err := invokeSupabase(ctx, "RestartUSupabase", params); err != nil { + ctx.HandleError(err) + return + } + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "supabase[%s] is restarting\n", instanceID) + } else { + text := fmt.Sprintf("supabase[%s] is restarting", instanceID) + ctx.PollerTo(w, describeSupabaseByID(ctx, common.region, common.zone, common.projectID, common.memoryDB)). + Spoll(instanceID, text, []string{SUPABASE_RUNNING, SUPABASE_FAIL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "restart", Status: "Restarting"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish") + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_set_storage_config.go b/products/pgsql/internal/pgsql/supabase_set_storage_config.go new file mode 100644 index 0000000000..e62258a213 --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_set_storage_config.go @@ -0,0 +1,56 @@ +package pgsql + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseSetStorageConfig ucloud pgsql supabase set-storage-config +func newSupabaseSetStorageConfig(ctx *cli.Context) *cobra.Command { + var instanceID string + var configs []string + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "set-storage-config", + Short: "Set storage configuration entries of a USupabase instance", + Long: "Set storage configuration entries of a USupabase instance (use --config key=value, repeatable)", + Run: func(c *cobra.Command, args []string) { + entries := []map[string]interface{}{} + for _, kv := range configs { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + ctx.HandleError(fmt.Errorf("invalid --config %q, expected key=value", kv)) + return + } + entries = append(entries, map[string]interface{}{ + "Key": parts[0], + "Value": parts[1], + }) + } + params := common.params() + params["InstanceID"] = instanceID + params["ConfigEntry"] = entries + if _, err := invokeSupabase(ctx, "SetUSupabaseStorageConfig", params); err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] storage config set\n", instanceID) + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "set-storage-config", Status: "Set"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + flags.StringSliceVar(&configs, "config", nil, "Required. Storage config entry, format key=value (repeatable)") + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("config") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_start.go b/products/pgsql/internal/pgsql/supabase_start.go new file mode 100644 index 0000000000..78ed02c65f --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_start.go @@ -0,0 +1,48 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseStart ucloud pgsql supabase start +func newSupabaseStart(ctx *cli.Context) *cobra.Command { + var instanceID string + var async bool + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "start", + Short: "Start a USupabase instance", + Long: "Start a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + if _, err := invokeSupabase(ctx, "StartUSupabase", params); err != nil { + ctx.HandleError(err) + return + } + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "supabase[%s] is starting\n", instanceID) + } else { + text := fmt.Sprintf("supabase[%s] is starting", instanceID) + ctx.PollerTo(w, describeSupabaseByID(ctx, common.region, common.zone, common.projectID, common.memoryDB)). + Spoll(instanceID, text, []string{SUPABASE_RUNNING, SUPABASE_FAIL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "start", Status: "Starting"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish") + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/supabase_stop.go b/products/pgsql/internal/pgsql/supabase_stop.go new file mode 100644 index 0000000000..1ecd55c809 --- /dev/null +++ b/products/pgsql/internal/pgsql/supabase_stop.go @@ -0,0 +1,48 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSupabaseStop ucloud pgsql supabase stop +func newSupabaseStop(ctx *cli.Context) *cobra.Command { + var instanceID string + var async bool + var common *supabaseCommon + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop a USupabase instance", + Long: "Stop a USupabase instance", + Run: func(c *cobra.Command, args []string) { + params := common.params() + params["InstanceID"] = instanceID + if _, err := invokeSupabase(ctx, "StopUSupabase", params); err != nil { + ctx.HandleError(err) + return + } + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "supabase[%s] is stopping\n", instanceID) + } else { + text := fmt.Sprintf("supabase[%s] is stopping", instanceID) + ctx.PollerTo(w, describeSupabaseByID(ctx, common.region, common.zone, common.projectID, common.memoryDB)). + Spoll(instanceID, text, []string{SUPABASE_STOPPED, SUPABASE_FAIL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "stop", Status: "Stopping"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + common = bindSupabaseCommon(cmd, ctx) + flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish") + cmd.MarkFlagRequired("instance-id") + + return cmd +} diff --git a/products/pgsql/internal/pgsql/update_name.go b/products/pgsql/internal/pgsql/update_name.go new file mode 100644 index 0000000000..f353d6aa9c --- /dev/null +++ b/products/pgsql/internal/pgsql/update_name.go @@ -0,0 +1,45 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUpdateName ucloud pgsql db update-name +func newUpdateName(ctx *cli.Context) *cobra.Command { + client := newUPgSQLClient(ctx) + req := client.NewUpdateUPgSQLAttributeRequest() + cmd := &cobra.Command{ + Use: "update-name", + Short: "Update the name of a UPgSQL instance", + Long: "Update the name of a UPgSQL instance", + Run: func(c *cobra.Command, args []string) { + *req.InstanceID = ctx.PickResourceID(*req.InstanceID) + _, err := client.UpdateUPgSQLAttribute(req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceID, Action: "update-name", Status: "Updated"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance") + req.Name = flags.String("name", "", "Required. New instance name") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("name") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/update_remark.go b/products/pgsql/internal/pgsql/update_remark.go new file mode 100644 index 0000000000..46585d0bf4 --- /dev/null +++ b/products/pgsql/internal/pgsql/update_remark.go @@ -0,0 +1,64 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/upgsql" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUpdateRemark ucloud pgsql db update-remark +func newUpdateRemark(ctx *cli.Context) *cobra.Command { + client := newUPgSQLClient(ctx) + req := client.NewUpdateUPgSQLAttributeRequest() + cmd := &cobra.Command{ + Use: "update-remark", + Short: "Update the remark of a UPgSQL instance", + Long: "Update the remark of a UPgSQL instance", + Run: func(c *cobra.Command, args []string) { + *req.InstanceID = ctx.PickResourceID(*req.InstanceID) + // UpdateUPgSQLAttribute requires Name to be present even when only the + // remark changes — omitting it returns RetCode 230 "Params [Name] not + // available". Fetch the current name and re-send it alongside Remark. + any, err := describePgsqlByID(ctx)(*req.InstanceID, nil) + if err != nil { + ctx.HandleError(err) + return + } + ins, ok := any.(*upgsql.UDBInstance) + if !ok { + ctx.HandleError(fmt.Errorf("fetch pgsql[%s] instance", *req.InstanceID)) + return + } + req.Name = sdk.String(ins.Name) + _, err = client.UpdateUPgSQLAttribute(req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceID, Action: "update-remark", Status: "Updated"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance") + req.Remark = flags.String("remark", "", "Required. New remark") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("remark") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/upgrade.go b/products/pgsql/internal/pgsql/upgrade.go new file mode 100644 index 0000000000..836f08ffec --- /dev/null +++ b/products/pgsql/internal/pgsql/upgrade.go @@ -0,0 +1,80 @@ +package pgsql + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUpgrade ucloud pgsql db upgrade +func newUpgrade(ctx *cli.Context) *cobra.Command { + var async bool + var idNames []string + var machineType string + var diskSpace int + client := newUPgSQLClient(ctx) + req := client.NewUpgradeUPgSQLInstanceRequest() + cmd := &cobra.Command{ + Use: "upgrade", + Short: "Upgrade disk space and/or machine type of UPgSQL instances", + Long: "Upgrade disk space and/or machine type of UPgSQL instances", + Run: func(c *cobra.Command, args []string) { + if machineType == "" && diskSpace == 0 { + ctx.HandleError(fmt.Errorf("at least one of --machine-type or --disk-size-gb is required")) + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.InstanceID = sdk.String(id) + if machineType != "" { + req.MachineType = sdk.String(machineType) + } + if diskSpace != 0 { + req.DiskSpace = sdk.Int(diskSpace) + } + _, err := client.UpgradeUPgSQLInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + if async { + fmt.Fprintf(w, "pgsql[%s] is upgrading\n", idname) + } else { + text := fmt.Sprintf("pgsql[%s] is upgrading", idname) + ctx.PollerTo(w, describePgsqlByID(ctx)).Spoll(id, text, []string{PGSQL_RUNNING, PGSQL_INIT_FAILED, PGSQL_START_FAILED}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "upgrade", Status: "Upgrading"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to upgrade") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + flags.StringVar(&machineType, "machine-type", "", "Optional. New machine type ID. See 'ucloud pgsql db list-machine-type'") + flags.IntVar(&diskSpace, "disk-size-gb", 0, "Optional. New disk size (GiB)") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish") + + cmd.MarkFlagRequired("instance-id") + + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + command.SetCompletion(cmd, "machine-type", func() []string { + return listMachineTypeIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/pgsql/internal/pgsql/upgrade_price.go b/products/pgsql/internal/pgsql/upgrade_price.go new file mode 100644 index 0000000000..daf5b38d9e --- /dev/null +++ b/products/pgsql/internal/pgsql/upgrade_price.go @@ -0,0 +1,56 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUpgradePrice ucloud pgsql db upgrade-price +func newUpgradePrice(ctx *cli.Context) *cobra.Command { + client := newUPgSQLClient(ctx) + req := client.NewGetUPgSQLUpgradePriceRequest() + cmd := &cobra.Command{ + Use: "upgrade-price", + Short: "Get the price of upgrading a UPgSQL instance", + Long: "Get the price of upgrading disk space and/or machine type of a UPgSQL instance", + Run: func(c *cobra.Command, args []string) { + *req.InstanceID = ctx.PickResourceID(*req.InstanceID) + resp, err := client.GetUPgSQLUpgradePrice(req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.PrintList([]PgsqlPriceRow{{ + Price: resp.Price, + OriginalPrice: resp.OriginalPrice, + }}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance") + req.MachineType = flags.String("machine-type", "", "Required. New machine type ID. See 'ucloud pgsql db list-machine-type'") + req.DiskSpace = flags.Int("disk-size-gb", 0, "Required. New disk space (GiB)") + req.InstanceMode = flags.String("mode", "Normal", "Optional. Normal / HA") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + command.SetFlagValues(cmd, "mode", "Normal", "HA") + command.SetCompletion(cmd, "instance-id", func() []string { + return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + command.SetCompletion(cmd, "machine-type", func() []string { + return listMachineTypeIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone()) + }) + + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("machine-type") + cmd.MarkFlagRequired("disk-size-gb") + + return cmd +} diff --git a/products/pgsql/product.go b/products/pgsql/product.go new file mode 100644 index 0000000000..faf8234a1d --- /dev/null +++ b/products/pgsql/product.go @@ -0,0 +1,20 @@ +package pgsql + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalpgsql "github.com/ucloud/ucloud-cli/products/pgsql/internal/pgsql" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "pgsql", Commands: []string{"pgsql"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalpgsql.NewCommand(ctx)} +} diff --git a/products/pgsql/product.yaml b/products/pgsql/product.yaml new file mode 100644 index 0000000000..0b3459c914 --- /dev/null +++ b/products/pgsql/product.yaml @@ -0,0 +1,6 @@ +name: pgsql +owners: + - jemy-wang +commands: + - pgsql +enabled: true diff --git a/products/pgsql/testdata/cmdtree.golden b/products/pgsql/testdata/cmdtree.golden new file mode 100644 index 0000000000..c99152d73c --- /dev/null +++ b/products/pgsql/testdata/cmdtree.golden @@ -0,0 +1,358 @@ +ucloud pgsql use=pgsql short=Manipulate UPgSQL on UCloud platform +ucloud pgsql backup use=backup short=List and manipulate backups of UPgSQL instances +ucloud pgsql backup download use=download short=Display download URLs of a UPgSQL backup + flag=backup-id short= default= required=true + flag=instance-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql backup list use=list short=List backups of a UPgSQL instance + flag=backup-type short= default= required= + flag=instance-id short= default= required=true + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql backup strategy use=strategy short=Display the backup strategy of a UPgSQL instance + flag=instance-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql backup update-strategy use=update-strategy short=Update the backup strategy of a UPgSQL instance + flag=backup-method short= default= required= + flag=backup-time-range short= default= required= + flag=backup-week short= default= required= + flag=instance-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql conf use=conf short=List and manipulate parameter templates of UPgSQL instances +ucloud pgsql conf create use=create short=Create a UPgSQL parameter template from a base template + flag=db-version short= default= required=true + flag=description short= default= required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=src-conf-id short= default= required=true + flag=zone short= default= required= +ucloud pgsql conf delete use=delete short=Delete a UPgSQL parameter template + flag=conf-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql conf describe use=describe short=Display details of a UPgSQL parameter template + flag=conf-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql conf download use=download short=Download a UPgSQL parameter template (base64 content) + flag=conf-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql conf list use=list short=List UPgSQL parameter templates + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql conf upload use=upload short=Create a UPgSQL parameter template by uploading a local config file + flag=conf-file short= default= required=true + flag=db-version short= default= required=true + flag=description short= default= required= + flag=name short= default= required=true + flag=param-group-type short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql db use=db short=Manage UPgSQL instances +ucloud pgsql db create use=create short=Create a UPgSQL instance + flag=async short=a default=false required= + flag=disk-size-gb short= default=100 required= + flag=machine-type short= default= required=true + flag=mode short= default=Normal required= + flag=name short= default= required=true + flag=param-group-id short= default=0 required= + flag=password short= default= required=true + flag=port short= default=5432 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=subnet-id short= default= required=true + flag=version short= default= required=true + flag=vpc-id short= default= required=true + flag=zone short= default= required= +ucloud pgsql db create-readonly use=create-readonly short=Create a readonly replica for a UPgSQL instance + flag=async short=a default=false required= + flag=disk-size-gb short= default=0 required=true + flag=machine-type short= default= required=true + flag=name short= default= required=true + flag=port short= default=5432 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=src-instance-id short= default= required=true + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud pgsql db delete use=delete short=Delete UPgSQL instances by instance-id + flag=instance-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud pgsql db get use=get short=Display details of a UPgSQL instance + flag=instance-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql db list use=list short=List UPgSQL instances + flag=instance-id short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql db list-machine-type use=list-machine-type short=List available UPgSQL machine types + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql db list-version use=list-version short=List available UPgSQL versions + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql db price use=price short=Get the price of creating UPgSQL instances + flag=charge-type short= default=Month required= + flag=disk-size-gb short= default=0 required=true + flag=machine-type short= default= required=true + flag=mode short= default=Normal required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql db reset-password use=reset-password short=Reset the admin password of UPgSQL instances + flag=instance-id short= default=[] required=true + flag=name short= default= required= + flag=password short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql db restart use=restart short=Restart UPgSQL instances by instance-id + flag=async short=a default=false required= + flag=force short= default=false required= + flag=instance-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=restart-host short= default=false required= + flag=zone short= default= required= +ucloud pgsql db start use=start short=Start UPgSQL instances by instance-id + flag=async short=a default=false required= + flag=instance-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql db stop use=stop short=Stop UPgSQL instances by instance-id + flag=async short=a default=false required= + flag=force short= default=false required= + flag=instance-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=stop-host short= default=false required= + flag=zone short= default= required= +ucloud pgsql db stop-creating-readonly use=stop-creating-readonly short=Stop readonly replicas that are still being created + flag=instance-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql db update-name use=update-name short=Update the name of a UPgSQL instance + flag=instance-id short= default= required=true + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql db update-remark use=update-remark short=Update the remark of a UPgSQL instance + flag=instance-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required=true + flag=zone short= default= required= +ucloud pgsql db upgrade use=upgrade short=Upgrade disk space and/or machine type of UPgSQL instances + flag=async short=a default=false required= + flag=disk-size-gb short= default=0 required= + flag=instance-id short= default=[] required=true + flag=machine-type short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql db upgrade-price use=upgrade-price short=Get the price of upgrading a UPgSQL instance + flag=disk-size-gb short= default=0 required=true + flag=instance-id short= default= required=true + flag=machine-type short= default= required=true + flag=mode short= default=Normal required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql log use=log short=List and back up logs of UPgSQL instances +ucloud pgsql log backup use=backup short=Back up the log package of a UPgSQL instance + flag=backup-file short= default= required=true + flag=begin-time short= default= required= + flag=end-time short= default= required= + flag=instance-id short= default= required=true + flag=log-type short= default= required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql log list use=list short=List logs of a UPgSQL instance within a time range + flag=begin-time short= default= required=true + flag=end-time short= default= required=true + flag=instance-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase use=supabase short=Manage USupabase instances (and the MemoryDB AI-memory variant) +ucloud pgsql supabase bandwidth-upgrade-price use=bandwidth-upgrade-price short=Get the price of upgrading external-access bandwidth of a USupabase instance + flag=bandwidth short= default=0 required=true + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase create use=create short=Create a USupabase instance + flag=async short=a default=false required= + flag=charge-type short= default=Month required= + flag=dashboard-name short= default= required=true + flag=dashboard-password short= default= required=true + flag=db-version short= default= required=true + flag=disk-size-gb short= default=0 required=true + flag=label short= default=[] required= + flag=machine-type short= default= required=true + flag=memory-db short= default=false required= + flag=mode short= default=Normal required= + flag=name short= default= required=true + flag=param-group-id short= default=0 required=true + flag=pgsql-password short= default= required=true + flag=pgsql-port short= default=5432 required= + flag=pgsql-user short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=subnet-id short= default= required=true + flag=supabase-port short= default=8000 required= + flag=vpc-id short= default= required=true + flag=zone short= default= required= +ucloud pgsql supabase create-memory-db use=create-memory-db short=Create a UMemoryDB (AI memory) instance + flag=async short=a default=false required= + flag=charge-type short= default=Month required= + flag=dashboard-name short= default= required=true + flag=dashboard-password short= default= required=true + flag=db-version short= default= required=true + flag=disk-size-gb short= default=0 required=true + flag=label short= default=[] required= + flag=machine-type short= default= required=true + flag=memory-db short= default=false required= + flag=mode short= default=Normal required= + flag=name short= default= required=true + flag=param-group-id short= default=0 required=true + flag=pgsql-password short= default= required=true + flag=pgsql-port short= default=5432 required= + flag=pgsql-user short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=subnet-id short= default= required=true + flag=supabase-port short= default=8000 required= + flag=vpc-id short= default= required=true + flag=zone short= default= required= +ucloud pgsql supabase delete use=delete short=Delete a USupabase instance + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud pgsql supabase describe use=describe short=Display details of a USupabase instance + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase disable-external-access use=disable-external-access short=Disable external access for a USupabase instance + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase enable-external-access use=enable-external-access short=Enable external access for a USupabase instance + flag=bandwidth short= default=0 required=true + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase external-price use=external-price short=Get the price of enabling external access for a USupabase instance + flag=bandwidth short= default=0 required=true + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase get-api-key use=get-api-key short=Display the API keys (service key + anon key) of a USupabase instance + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase get-storage-config use=get-storage-config short=Display the storage configuration of a USupabase instance + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase list use=list short=List USupabase instances + flag=instance-id short= default= required= + flag=limit short= default=0 required= + flag=memory-db short= default=false required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase modify-external-access use=modify-external-access short=Modify external-access settings of a USupabase instance + flag=bandwidth short= default=0 required= + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=port short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=white-list short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase reset-password use=reset-password short=Reset the dashboard password of a USupabase instance + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=password short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase restart use=restart short=Restart a USupabase instance + flag=async short=a default=false required= + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase set-storage-config use=set-storage-config short=Set storage configuration entries of a USupabase instance + flag=config short= default=[] required=true + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase start use=start short=Start a USupabase instance + flag=async short=a default=false required= + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud pgsql supabase stop use=stop short=Stop a USupabase instance + flag=async short=a default=false required= + flag=instance-id short= default= required=true + flag=memory-db short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= diff --git a/products/pgsql/testdata/completion.golden b/products/pgsql/testdata/completion.golden new file mode 100644 index 0000000000..a1f798a9ef --- /dev/null +++ b/products/pgsql/testdata/completion.golden @@ -0,0 +1,192 @@ +ucloud pgsql backup download backup-id dynamic +ucloud pgsql backup download instance-id dynamic +ucloud pgsql backup download project-id dynamic +ucloud pgsql backup download region dynamic +ucloud pgsql backup download zone dynamic +ucloud pgsql backup list backup-type static auto,manual +ucloud pgsql backup list instance-id dynamic +ucloud pgsql backup list project-id dynamic +ucloud pgsql backup list region dynamic +ucloud pgsql backup list zone dynamic +ucloud pgsql backup strategy instance-id dynamic +ucloud pgsql backup strategy project-id dynamic +ucloud pgsql backup strategy region dynamic +ucloud pgsql backup strategy zone dynamic +ucloud pgsql backup update-strategy instance-id dynamic +ucloud pgsql backup update-strategy project-id dynamic +ucloud pgsql backup update-strategy region dynamic +ucloud pgsql backup update-strategy zone dynamic +ucloud pgsql conf create db-version static postgresql-10.4,postgresql-13.4 +ucloud pgsql conf create project-id dynamic +ucloud pgsql conf create region dynamic +ucloud pgsql conf create src-conf-id dynamic +ucloud pgsql conf create zone dynamic +ucloud pgsql conf delete conf-id dynamic +ucloud pgsql conf delete project-id dynamic +ucloud pgsql conf delete region dynamic +ucloud pgsql conf delete zone dynamic +ucloud pgsql conf describe conf-id dynamic +ucloud pgsql conf describe project-id dynamic +ucloud pgsql conf describe region dynamic +ucloud pgsql conf describe zone dynamic +ucloud pgsql conf download conf-id dynamic +ucloud pgsql conf download project-id dynamic +ucloud pgsql conf download region dynamic +ucloud pgsql conf download zone dynamic +ucloud pgsql conf list project-id dynamic +ucloud pgsql conf list region dynamic +ucloud pgsql conf list zone dynamic +ucloud pgsql conf upload conf-file static +ucloud pgsql conf upload db-version static postgresql-10.4,postgresql-13.4 +ucloud pgsql conf upload project-id dynamic +ucloud pgsql conf upload region dynamic +ucloud pgsql conf upload zone dynamic +ucloud pgsql db create machine-type dynamic +ucloud pgsql db create mode static HA,Normal +ucloud pgsql db create param-group-id dynamic +ucloud pgsql db create project-id dynamic +ucloud pgsql db create region dynamic +ucloud pgsql db create subnet-id dynamic +ucloud pgsql db create version static postgresql-10.4,postgresql-13.4 +ucloud pgsql db create vpc-id dynamic +ucloud pgsql db create zone dynamic +ucloud pgsql db create-readonly machine-type dynamic +ucloud pgsql db create-readonly project-id dynamic +ucloud pgsql db create-readonly region dynamic +ucloud pgsql db create-readonly src-instance-id dynamic +ucloud pgsql db create-readonly subnet-id dynamic +ucloud pgsql db create-readonly vpc-id dynamic +ucloud pgsql db create-readonly zone dynamic +ucloud pgsql db delete instance-id dynamic +ucloud pgsql db delete project-id dynamic +ucloud pgsql db delete region dynamic +ucloud pgsql db delete zone dynamic +ucloud pgsql db get instance-id dynamic +ucloud pgsql db get project-id dynamic +ucloud pgsql db get region dynamic +ucloud pgsql db get zone dynamic +ucloud pgsql db list instance-id dynamic +ucloud pgsql db list project-id dynamic +ucloud pgsql db list region dynamic +ucloud pgsql db list zone dynamic +ucloud pgsql db list-machine-type project-id dynamic +ucloud pgsql db list-machine-type region dynamic +ucloud pgsql db list-machine-type zone dynamic +ucloud pgsql db list-version project-id dynamic +ucloud pgsql db list-version region dynamic +ucloud pgsql db list-version zone dynamic +ucloud pgsql db price charge-type static Dynamic,Month,Year +ucloud pgsql db price machine-type dynamic +ucloud pgsql db price mode static HA,Normal +ucloud pgsql db price project-id dynamic +ucloud pgsql db price region dynamic +ucloud pgsql db price zone dynamic +ucloud pgsql db reset-password instance-id dynamic +ucloud pgsql db reset-password project-id dynamic +ucloud pgsql db reset-password region dynamic +ucloud pgsql db reset-password zone dynamic +ucloud pgsql db restart force static false,true +ucloud pgsql db restart instance-id dynamic +ucloud pgsql db restart project-id dynamic +ucloud pgsql db restart region dynamic +ucloud pgsql db restart restart-host static false,true +ucloud pgsql db restart zone dynamic +ucloud pgsql db start instance-id dynamic +ucloud pgsql db start project-id dynamic +ucloud pgsql db start region dynamic +ucloud pgsql db start zone dynamic +ucloud pgsql db stop force static false,true +ucloud pgsql db stop instance-id dynamic +ucloud pgsql db stop project-id dynamic +ucloud pgsql db stop region dynamic +ucloud pgsql db stop stop-host static false,true +ucloud pgsql db stop zone dynamic +ucloud pgsql db stop-creating-readonly instance-id dynamic +ucloud pgsql db stop-creating-readonly project-id dynamic +ucloud pgsql db stop-creating-readonly region dynamic +ucloud pgsql db stop-creating-readonly zone dynamic +ucloud pgsql db update-name instance-id dynamic +ucloud pgsql db update-name project-id dynamic +ucloud pgsql db update-name region dynamic +ucloud pgsql db update-name zone dynamic +ucloud pgsql db update-remark instance-id dynamic +ucloud pgsql db update-remark project-id dynamic +ucloud pgsql db update-remark region dynamic +ucloud pgsql db update-remark zone dynamic +ucloud pgsql db upgrade instance-id dynamic +ucloud pgsql db upgrade machine-type dynamic +ucloud pgsql db upgrade project-id dynamic +ucloud pgsql db upgrade region dynamic +ucloud pgsql db upgrade zone dynamic +ucloud pgsql db upgrade-price instance-id dynamic +ucloud pgsql db upgrade-price machine-type dynamic +ucloud pgsql db upgrade-price mode static HA,Normal +ucloud pgsql db upgrade-price project-id dynamic +ucloud pgsql db upgrade-price region dynamic +ucloud pgsql db upgrade-price zone dynamic +ucloud pgsql log backup instance-id dynamic +ucloud pgsql log backup log-type static error,slow +ucloud pgsql log backup project-id dynamic +ucloud pgsql log backup region dynamic +ucloud pgsql log backup zone dynamic +ucloud pgsql log list instance-id dynamic +ucloud pgsql log list project-id dynamic +ucloud pgsql log list region dynamic +ucloud pgsql log list zone dynamic +ucloud pgsql supabase bandwidth-upgrade-price project-id dynamic +ucloud pgsql supabase bandwidth-upgrade-price region dynamic +ucloud pgsql supabase bandwidth-upgrade-price zone dynamic +ucloud pgsql supabase create charge-type static Dynamic,Month,Year +ucloud pgsql supabase create db-version static postgresql-10.4,postgresql-13.4 +ucloud pgsql supabase create mode static HA,Normal +ucloud pgsql supabase create project-id dynamic +ucloud pgsql supabase create region dynamic +ucloud pgsql supabase create zone dynamic +ucloud pgsql supabase create-memory-db charge-type static Dynamic,Month,Year +ucloud pgsql supabase create-memory-db db-version static postgresql-10.4,postgresql-13.4 +ucloud pgsql supabase create-memory-db mode static HA,Normal +ucloud pgsql supabase create-memory-db project-id dynamic +ucloud pgsql supabase create-memory-db region dynamic +ucloud pgsql supabase create-memory-db zone dynamic +ucloud pgsql supabase delete project-id dynamic +ucloud pgsql supabase delete region dynamic +ucloud pgsql supabase delete zone dynamic +ucloud pgsql supabase describe project-id dynamic +ucloud pgsql supabase describe region dynamic +ucloud pgsql supabase describe zone dynamic +ucloud pgsql supabase disable-external-access project-id dynamic +ucloud pgsql supabase disable-external-access region dynamic +ucloud pgsql supabase disable-external-access zone dynamic +ucloud pgsql supabase enable-external-access project-id dynamic +ucloud pgsql supabase enable-external-access region dynamic +ucloud pgsql supabase enable-external-access zone dynamic +ucloud pgsql supabase external-price project-id dynamic +ucloud pgsql supabase external-price region dynamic +ucloud pgsql supabase external-price zone dynamic +ucloud pgsql supabase get-api-key project-id dynamic +ucloud pgsql supabase get-api-key region dynamic +ucloud pgsql supabase get-api-key zone dynamic +ucloud pgsql supabase get-storage-config project-id dynamic +ucloud pgsql supabase get-storage-config region dynamic +ucloud pgsql supabase get-storage-config zone dynamic +ucloud pgsql supabase list project-id dynamic +ucloud pgsql supabase list region dynamic +ucloud pgsql supabase list zone dynamic +ucloud pgsql supabase modify-external-access project-id dynamic +ucloud pgsql supabase modify-external-access region dynamic +ucloud pgsql supabase modify-external-access zone dynamic +ucloud pgsql supabase reset-password project-id dynamic +ucloud pgsql supabase reset-password region dynamic +ucloud pgsql supabase reset-password zone dynamic +ucloud pgsql supabase restart project-id dynamic +ucloud pgsql supabase restart region dynamic +ucloud pgsql supabase restart zone dynamic +ucloud pgsql supabase set-storage-config project-id dynamic +ucloud pgsql supabase set-storage-config region dynamic +ucloud pgsql supabase set-storage-config zone dynamic +ucloud pgsql supabase start project-id dynamic +ucloud pgsql supabase start region dynamic +ucloud pgsql supabase start zone dynamic +ucloud pgsql supabase stop project-id dynamic +ucloud pgsql supabase stop region dynamic +ucloud pgsql supabase stop zone dynamic diff --git a/products/redis/internal/redis/cmd.go b/products/redis/internal/redis/cmd.go new file mode 100644 index 0000000000..0d85760d37 --- /dev/null +++ b/products/redis/internal/redis/cmd.go @@ -0,0 +1,31 @@ +package redis + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand returns the ucloud redis command tree. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "redis", + Short: "List and manipulate redis instances", + Long: "List and manipulate redis instances", + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newRestart(ctx)) + cmd.AddCommand(newModifyName(ctx)) + cmd.AddCommand(newModifyPassword(ctx)) + cmd.AddCommand(newListBlock(ctx)) + cmd.AddCommand(newListProxy(ctx)) + cmd.AddCommand(newFlush(ctx)) + cmd.AddCommand(newIsolation(ctx)) + cmd.AddCommand(newResize(ctx)) + cmd.AddCommand(newCreateProxy(ctx)) + cmd.AddCommand(newDeleteProxy(ctx)) + cmd.AddCommand(newResizeProxy(ctx)) + return cmd +} diff --git a/products/redis/internal/redis/completion.go b/products/redis/internal/redis/completion.go new file mode 100644 index 0000000000..2acbc264ed --- /dev/null +++ b/products/redis/internal/redis/completion.go @@ -0,0 +1,34 @@ +package redis + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func getIDList(ctx *cli.Context, project, region string) []string { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeURedisGroupRequest() + req.ProjectId = &project + req.Region = ®ion + list := []string{} + + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.DescribeURedisGroup(req) + if err != nil { + return nil + } + for _, ins := range resp.DataSet { + list = append(list, fmt.Sprintf("%s/%s", ins.GroupId, ins.Name)) + } + if offset+limit >= resp.TotalCount { + break + } + } + return list +} diff --git a/products/redis/internal/redis/create.go b/products/redis/internal/redis/create.go new file mode 100644 index 0000000000..5f3ef2efe9 --- /dev/null +++ b/products/redis/internal/redis/create.go @@ -0,0 +1,177 @@ +package redis + +import ( + "fmt" + "unicode/utf8" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type createParams struct { + name string + password string + size int + region string + zone string + projectID string + chargeType string + quantity int + group string + vpcID string + subnetID string + version string + blockCnt int + proxySize int +} + +// newCreate returns ucloud redis create. +func newCreate(ctx *cli.Context) *cobra.Command { + var redisType string + var p createParams + cmd := &cobra.Command{ + Use: "create", + Short: "Create redis instance", + Long: "Create redis instance", + Run: func(c *cobra.Command, args []string) { + if l := utf8.RuneCountInString(p.name); l < 6 || l > 63 { + fmt.Fprintln(ctx.ProgressWriter(), "length of name should be between 6 and 63") + return + } + if p.password != "" { + if l := len(p.password); l < 6 || l > 36 { + fmt.Fprintln(ctx.ProgressWriter(), "length of password should be between 6 and 36") + return + } + } + if err := fillDefaultVPCAndSubnet(ctx, &p.vpcID, &p.subnetID, p.projectID, p.region, p.zone); err != nil { + fmt.Fprintln(ctx.ProgressWriter(), err) + return + } + switch redisType { + case "master-replica": + createMasterReplica(ctx, &p) + case "distributed": + createDistributed(ctx, &p) + default: + fmt.Fprintf(ctx.ProgressWriter(), "unknow redis type[%s], it's should be 'master-replica' or 'distributed'\n", redisType) + } + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&p.name, "name", "", "Required. Name of the redis to create. Range of the name length is [6,63]") + flags.StringVar(&redisType, "type", "", "Required. Type of the redis. Accept values:'master-replica','distributed'") + flags.IntVar(&p.size, "size-gb", 2, "Optional. Memory size. Default value 2GB. Unit GB") + flags.StringVar(&p.version, "version", "6.0", "Optional. Version of redis. Accept values: '4.0', '5.0', '6.0', '7.0'") + flags.StringVar(&p.vpcID, "vpc-id", "", "Optional. VPC ID. This field is required under VPC2.0. See 'ucloud vpc list'") + flags.StringVar(&p.subnetID, "subnet-id", "", "Optional. Subnet ID. This field is required under VPC2.0. See 'ucloud subnet list'") + flags.StringVar(&p.password, "password", "", "Optional. Password of redis to create. Range of the password length is [6,36] and the password can only contain letters and numbers") + + flags.IntVar(&p.blockCnt, "block-cnt", 2, "Optional. Block count. Default value 2(for distributed redis type).") + flags.IntVar(&p.proxySize, "proxy-size", 2, "Optional. Proxy size. Default value 2(for distributed redis type) Unit Core") + + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + flags.StringVar(&p.chargeType, "charge-type", "Month", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly; 'Dynamic', pay hourly; 'Trial', free trial(need permission)") + flags.IntVar(&p.quantity, "quantity", 1, "Optional. The duration of the instance. N years/months.") + flags.StringVar(&p.group, "group", "", "Optional. Business group") + + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetFlagValues(cmd, "version", "4.0", "5.0", "6.0", "7.0") + command.SetFlagValues(cmd, "type", "master-replica", "distributed") + command.SetFlagValues(cmd, "charge-type", "Month", "Dynamic", "Year") + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, p.projectID, p.region) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, p.vpcID, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("type") + + return cmd +} + +func createMasterReplica(ctx *cli.Context, p *createParams) { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewCreateURedisGroupRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.Name = &p.name + req.HighAvailability = sdk.String("enable") + req.Size = &p.size + req.Version = &p.version + req.VPCId = &p.vpcID + req.SubnetId = &p.subnetID + req.ChargeType = &p.chargeType + req.Quantity = &p.quantity + req.Tag = &p.group + if p.password != "" { + req.Password = &p.password + } + + resp, err := client.CreateURedisGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] created\n", resp.GroupId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.GroupId, Action: "create", Status: "Created"}) +} + +func createDistributed(ctx *cli.Context, p *createParams) { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewCreateUMemSpaceRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.Name = &p.name + req.Protocol = sdk.String("redis") + + if p.blockCnt <= 0 { + fmt.Fprintln(ctx.ProgressWriter(), "block-cnt should be greater than 0") + return + } + if p.size%p.blockCnt != 0 { + fmt.Fprintf(ctx.ProgressWriter(), "size-gb(%d) should be divisible by block-cnt(%d)\n", p.size, p.blockCnt) + return + } + if p.proxySize%2 != 0 { + fmt.Fprintf(ctx.ProgressWriter(), "proxy-size(%d) should be a multiple of 2\n", p.proxySize) + return + } + + req.BlockCnt = &p.blockCnt + req.ProxySize = &p.proxySize + req.Size = &p.size + req.Version = &p.version + req.VPCId = &p.vpcID + req.SubnetId = &p.subnetID + req.ChargeType = &p.chargeType + req.Quantity = &p.quantity + req.Tag = &p.group + if p.password != "" { + req.Password = &p.password + } + + resp, err := client.CreateUMemSpace(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] created\n", resp.SpaceId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.SpaceId, Action: "create", Status: "Created"}) +} diff --git a/products/redis/internal/redis/create_proxy.go b/products/redis/internal/redis/create_proxy.go new file mode 100644 index 0000000000..81e59f6824 --- /dev/null +++ b/products/redis/internal/redis/create_proxy.go @@ -0,0 +1,53 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreateProxy returns ucloud redis create-proxy. +func newCreateProxy(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewCreateUDRedisUhproxyRequest() + cmd := &cobra.Command{ + Use: "create-proxy", + Short: "Create proxy for distributed redis", + Long: "Create proxy for distributed redis", + Example: "ucloud redis create-proxy --umem-id udb-xxx --cpu 2", + Run: func(c *cobra.Command, args []string) { + resp, err := client.CreateUDRedisUhproxy(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "proxy[%s] created\n", resp.ResourceId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ResourceId, Action: "create-proxy", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.SpaceId = flags.String("umem-id", "", "Required. Resource ID of the distributed redis") + req.CPU = flags.Int("cpu", 2, "Required. CPU cores of the proxy") + req.Port = flags.Int("port", 6379, "Optional. Port of the proxy. Default value 6379") + req.ProxyCnt = flags.Int("proxy-cnt", 1, "Optional. Number of proxies to create. Default value 1") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("cpu") + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/redis/internal/redis/delete.go b/products/redis/internal/redis/delete.go new file mode 100644 index 0000000000..6f8bbc21be --- /dev/null +++ b/products/redis/internal/redis/delete.go @@ -0,0 +1,104 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type deleteParams struct { + region string + zone string + projectID string +} + +// newDelete returns ucloud redis delete. +func newDelete(ctx *cli.Context) *cobra.Command { + var idNames []string + var p deleteParams + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete redis instances", + Long: "Delete redis instances", + Example: "ucloud redis delete --umem-id uredis-rl5xuxx/testcli1,uredis-xsdfa/testcli2", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + switch mode { + case redisModeMasterReplica: + if deleteMasterReplica(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + case redisModeDistributed: + if deleteDistributed(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + default: + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] unknown resource type, skip\n", idname) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to delete") + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("umem-id") + + return cmd +} + +func deleteMasterReplica(ctx *cli.Context, p *deleteParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDeleteURedisGroupRequest() + req.Region = &p.region + req.ProjectId = &p.projectID + req.GroupId = &id + _, err := client.DeleteURedisGroup(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] deleted\n", id) + return true +} + +func deleteDistributed(ctx *cli.Context, p *deleteParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDeleteUMemSpaceRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.SpaceId = &id + _, err := client.DeleteUMemSpace(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] deleted\n", id) + return true +} diff --git a/products/redis/internal/redis/delete_proxy.go b/products/redis/internal/redis/delete_proxy.go new file mode 100644 index 0000000000..507d45b3b1 --- /dev/null +++ b/products/redis/internal/redis/delete_proxy.go @@ -0,0 +1,51 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDeleteProxy returns ucloud redis delete-proxy. +func newDeleteProxy(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDeleteUDRedisProxyRequest() + cmd := &cobra.Command{ + Use: "delete-proxy", + Short: "Delete proxy of distributed redis", + Long: "Delete proxy of distributed redis", + Example: "ucloud redis delete-proxy --umem-id udb-xxx --proxy-id proxy-xxx", + Run: func(c *cobra.Command, args []string) { + _, err := client.DeleteUDRedisProxy(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "proxy[%s] deleted\n", *req.ProxyId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.ProxyId, Action: "delete-proxy", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.SpaceId = flags.String("umem-id", "", "Required. Resource ID of the distributed redis") + req.ProxyId = flags.String("proxy-id", "", "Required. Proxy ID of the proxy to delete") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("proxy-id") + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/redis/internal/redis/flush.go b/products/redis/internal/redis/flush.go new file mode 100644 index 0000000000..b49b1c4c6f --- /dev/null +++ b/products/redis/internal/redis/flush.go @@ -0,0 +1,114 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type flushParams struct { + flushType string + dbNum int + region string + zone string + projectID string +} + +// newFlush returns ucloud redis flush. +func newFlush(ctx *cli.Context) *cobra.Command { + var idNames []string + var p flushParams + cmd := &cobra.Command{ + Use: "flush", + Short: "Clear data of redis instances", + Long: "Clear data of redis instances. Master-replica instances call FlushallURedisGroup, distributed instances call RemoveUDRedisData", + Example: "ucloud redis flush --umem-id uredis-rl5xuxx/testcli1 --flush-type FlushAll", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + switch mode { + case redisModeMasterReplica: + if flushMasterReplica(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "flush", Status: "Flushed"}) + } + case redisModeDistributed: + if flushDistributed(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "flush", Status: "Flushed"}) + } + default: + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] unknown resource type, skip\n", idname) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to flush data") + flags.StringVar(&p.flushType, "flush-type", "FlushAll", "Optional. FlushType of redis flush. Only for master-replica instances. Accept values: 'FlushAll', 'FlushDb'") + flags.IntVar(&p.dbNum, "db-num", 0, "Optional. DbNum to flush. Only used when flush-type is FlushDb for master-replica instances") + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("umem-id") + + return cmd +} + +func flushMasterReplica(ctx *cli.Context, p *flushParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewFlushallURedisGroupRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.GroupId = &id + req.FlushType = &p.flushType + if p.flushType == "FlushDb" { + req.DbNum = sdk.Int(p.dbNum) + } + _, err := client.FlushallURedisGroup(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] data flushed\n", id) + return true +} + +func flushDistributed(ctx *cli.Context, p *flushParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewRemoveUDRedisDataRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.SpaceId = &id + _, err := client.RemoveUDRedisData(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] data flushed\n", id) + return true +} diff --git a/products/redis/internal/redis/isolation.go b/products/redis/internal/redis/isolation.go new file mode 100644 index 0000000000..36722f7b43 --- /dev/null +++ b/products/redis/internal/redis/isolation.go @@ -0,0 +1,95 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type isolationParams struct { + opType string + region string + zone string + projectID string +} + +// newIsolation returns ucloud redis isolation. +func newIsolation(ctx *cli.Context) *cobra.Command { + var idNames []string + var p isolationParams + cmd := &cobra.Command{ + Use: "isolation", + Short: "Open or close redis instances of master-replica type", + Long: "Open or close redis instances of master-replica type. Only master-replica instances are supported. --type open opens redis, --type close closes redis", + Example: "ucloud redis isolation --umem-id uredis-rl5xuxx/testcli1 --type open", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + transformType := "UNBind" + action := "close" + if p.opType == "open" { + transformType = "Bind" + action = "open" + } + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + if mode != redisModeMasterReplica { + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] is not master-replica type, skip\n", idname) + continue + } + if isolationMasterReplica(ctx, &p, id, transformType, action) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: action, Status: "Done"}) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to open or close") + flags.StringVar(&p.opType, "type", "close", "Required. Operation type of redis isolation. Accept values: 'open' or 'close'") + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + + command.SetFlagValues(cmd, "type", "open", "close") + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("type") + + return cmd +} + +func isolationMasterReplica(ctx *cli.Context, p *isolationParams, id, transformType, action string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewISolationURedisGroupRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.GroupId = &id + req.TransformType = &transformType + _, err := client.ISolationURedisGroup(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] %sed\n", id, action) + return true +} diff --git a/products/redis/internal/redis/list.go b/products/redis/internal/redis/list.go new file mode 100644 index 0000000000..677f9e1a46 --- /dev/null +++ b/products/redis/internal/redis/list.go @@ -0,0 +1,88 @@ +package redis + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList returns ucloud redis list. +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeUMemRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List redis instances", + Long: "List redis instances", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeUMem(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []Row{} + for _, ins := range resp.DataSet { + row := Row{ + ResourceID: ins.ResourceId, + Name: ins.Name, + Role: ins.Role, + Type: string(resourceTypeToMode(ins.ResourceType)), + Group: ins.Tag, + Size: fmt.Sprintf("%dGB", ins.Size), + UsedSize: fmt.Sprintf("%dMB", ins.UsedSize), + State: ins.State, + Zone: ins.Zone, + CreateTime: common.FormatDate(ins.CreateTime), + } + addrs := []string{} + for _, addr := range ins.Address { + addrs = append(addrs, fmt.Sprintf("%s:%d", addr.IP, addr.Port)) + } + row.Address = strings.Join(addrs, "|") + list = append(list, row) + for _, slave := range ins.DataSet { + srow := Row{ + ResourceID: slave.GroupId, + Name: slave.Name, + Role: fmt.Sprintf("⮑ %s", slave.Role), + Type: string(resourceTypeToMode(slave.ResourceType)), + Group: slave.Tag, + Size: fmt.Sprintf("%dGB", slave.Size), + UsedSize: fmt.Sprintf("%dMB", slave.UsedSize), + State: slave.State, + Zone: slave.Zone, + Address: fmt.Sprintf("%s:%d", slave.VirtualIP, slave.Port), + CreateTime: common.FormatDate(slave.CreateTime), + } + list = append(list, srow) + } + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ResourceId = flags.String("umem-id", "", "Optional. Resource ID of the redis to list") + ctx.BindRegion(cmd, req) + ctx.BindZoneEmpty(cmd, req) + ctx.BindProjectID(cmd, req) + ctx.BindOffset(cmd, req) + ctx.BindLimit(cmd, req) + req.Protocol = sdk.String("redis") + + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/redis/internal/redis/list_block.go b/products/redis/internal/redis/list_block.go new file mode 100644 index 0000000000..1e1d064027 --- /dev/null +++ b/products/redis/internal/redis/list_block.go @@ -0,0 +1,64 @@ +package redis + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListBlock returns ucloud redis list-block. +func newListBlock(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeUMemBlockInfoRequest() + cmd := &cobra.Command{ + Use: "list-block", + Short: "List block info of distributed redis", + Long: "List block info of distributed redis", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeUMemBlockInfo(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []BlockRow{} + for _, b := range resp.DataSet { + row := BlockRow{ + BlockID: b.BlockId, + BlockName: b.BlockName, + BlockVip: b.BlockVip, + BlockPort: b.BlockPort, + BlockType: b.BlockType, + BlockState: b.BlockState, + BlockSize: b.BlockSize, + UsedSize: b.BlockUsedSize, + SlotBegin: b.BlockSlotBegin, + SlotEnd: b.BlockSlotEnd, + ReadWeight: b.BlockReadWeight, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.SpaceId = flags.String("umem-id", "", "Required. Resource ID of the distributed redis") + req.Limit = sdk.Int(100) + req.Offset = sdk.Int(0) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("umem-id") + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/redis/internal/redis/list_proxy.go b/products/redis/internal/redis/list_proxy.go new file mode 100644 index 0000000000..3202bc8a8a --- /dev/null +++ b/products/redis/internal/redis/list_proxy.go @@ -0,0 +1,54 @@ +package redis + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListProxy returns ucloud redis list-proxy. +func newListProxy(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeUDRedisProxyInfoRequest() + cmd := &cobra.Command{ + Use: "list-proxy", + Short: "List proxy info of distributed redis", + Long: "List proxy info of distributed redis", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeUDRedisProxyInfo(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []ProxyRow{} + for _, p := range resp.DataSet { + row := ProxyRow{ + ProxyID: p.ProxyId, + ResourceID: p.ResourceId, + State: p.State, + Vip: p.Vip, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.SpaceId = flags.String("umem-id", "", "Required. Resource ID of the distributed redis") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("umem-id") + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/redis/internal/redis/modify_name.go b/products/redis/internal/redis/modify_name.go new file mode 100644 index 0000000000..7fc6660f3a --- /dev/null +++ b/products/redis/internal/redis/modify_name.go @@ -0,0 +1,109 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type modifyNameParams struct { + name string + region string + zone string + projectID string +} + +// newModifyName returns ucloud redis modify-name. +func newModifyName(ctx *cli.Context) *cobra.Command { + var idNames []string + var p modifyNameParams + cmd := &cobra.Command{ + Use: "modify-name", + Short: "Modify redis instance name", + Long: "Modify redis instance name", + Example: "ucloud redis modify-name --umem-id uredis-rl5xuxx/testcli1,uredis-xsdfa/testcli2 --name newname", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + switch mode { + case redisModeMasterReplica: + if modifyMasterReplicaName(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "modify-name", Status: "Modified"}) + } + case redisModeDistributed: + if modifyDistributedName(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "modify-name", Status: "Modified"}) + } + default: + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] unknown resource type, skip\n", idname) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to modify name") + flags.StringVar(&p.name, "name", "", "Required. New name of the redis instance") + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("name") + + return cmd +} + +func modifyMasterReplicaName(ctx *cli.Context, p *modifyNameParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewModifyURedisGroupNameRequest() + req.Region = &p.region + req.ProjectId = &p.projectID + req.GroupId = &id + req.Name = &p.name + _, err := client.ModifyURedisGroupName(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] name modified\n", id) + return true +} + +func modifyDistributedName(ctx *cli.Context, p *modifyNameParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewModifyUMemSpaceNameRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.SpaceId = &id + req.Name = &p.name + _, err := client.ModifyUMemSpaceName(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] name modified\n", id) + return true +} diff --git a/products/redis/internal/redis/modify_password.go b/products/redis/internal/redis/modify_password.go new file mode 100644 index 0000000000..ad7e6e3560 --- /dev/null +++ b/products/redis/internal/redis/modify_password.go @@ -0,0 +1,112 @@ +package redis + +import ( + "encoding/base64" + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type modifyPasswordParams struct { + password string + region string + zone string + projectID string +} + +// newModifyPassword returns ucloud redis modify-password. +func newModifyPassword(ctx *cli.Context) *cobra.Command { + var idNames []string + var p modifyPasswordParams + cmd := &cobra.Command{ + Use: "modify-password", + Short: "Modify redis instance password", + Long: "Modify redis instance password", + Example: "ucloud redis modify-password --umem-id uredis-rl5xuxx/testcli1 --password newpassword", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + encodedPassword := base64.StdEncoding.EncodeToString([]byte(p.password)) + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + switch mode { + case redisModeMasterReplica: + if modifyMasterReplicaPassword(ctx, &p, id, encodedPassword) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "modify-password", Status: "Modified"}) + } + case redisModeDistributed: + if modifyDistributedPassword(ctx, &p, id, encodedPassword) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "modify-password", Status: "Modified"}) + } + default: + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] unknown resource type, skip\n", idname) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to modify password") + flags.StringVar(&p.password, "password", "", "Required. New password of the redis instance") + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("password") + + return cmd +} + +func modifyMasterReplicaPassword(ctx *cli.Context, p *modifyPasswordParams, id, encodedPassword string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewModifyURedisGroupPasswordRequest() + req.Region = &p.region + req.ProjectId = &p.projectID + req.Zone = &p.zone + req.GroupId = &id + req.Password = &encodedPassword + _, err := client.ModifyURedisGroupPassword(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] password modified\n", id) + return true +} + +func modifyDistributedPassword(ctx *cli.Context, p *modifyPasswordParams, id, encodedPassword string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewModifyUMemPasswordRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.SpaceId = &id + req.Password = &encodedPassword + _, err := client.ModifyUMemPassword(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] password modified\n", id) + return true +} diff --git a/products/redis/internal/redis/poll.go b/products/redis/internal/redis/poll.go new file mode 100644 index 0000000000..8a81f12322 --- /dev/null +++ b/products/redis/internal/redis/poll.go @@ -0,0 +1,32 @@ +package redis + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func describeByID(ctx *cli.Context) func(string, *request.CommonBase) (interface{}, error) { + return func(redisID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeUMemRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.Protocol = sdk.String("redis") + req.ResourceId = &redisID + + resp, err := client.DescribeUMem(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) < 1 { + return nil, fmt.Errorf("resource [%s] may not exist", redisID) + } + return &resp.DataSet[0], nil + } +} diff --git a/products/redis/internal/redis/resize.go b/products/redis/internal/redis/resize.go new file mode 100644 index 0000000000..5abf35ff41 --- /dev/null +++ b/products/redis/internal/redis/resize.go @@ -0,0 +1,116 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type resizeParams struct { + region string + zone string + projectID string + size int + blockID string +} + +// newResize returns ucloud redis resize. +func newResize(ctx *cli.Context) *cobra.Command { + var idNames []string + var p resizeParams + cmd := &cobra.Command{ + Use: "resize", + Short: "Resize redis instances", + Long: "Resize redis instances. Master-replica instances call ResizeURedisGroup, distributed instances call ResizeUDRedisBlockSize for the specified block", + Example: "ucloud redis resize --umem-id uredis-rl5xuxx/testcli1 --size-gb 4", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + switch mode { + case redisModeMasterReplica: + if resizeMasterReplica(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "resize", Status: "Resized"}) + } + case redisModeDistributed: + if p.blockID == "" { + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] --block-id is required for distributed redis\n", idname) + continue + } + if resizeDistributed(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "resize", Status: "Resized"}) + } + default: + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] unknown resource type, skip\n", idname) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to resize") + flags.IntVar(&p.size, "size-gb", 0, "Required. Target memory size in GB") + flags.StringVar(&p.blockID, "block-id", "", "Required for distributed redis. Block ID to resize") + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("size-gb") + + return cmd +} + +func resizeMasterReplica(ctx *cli.Context, p *resizeParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewResizeURedisGroupRequest() + req.Region = &p.region + req.ProjectId = &p.projectID + req.GroupId = &id + req.Size = &p.size + _, err := client.ResizeURedisGroup(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] resized\n", id) + return true +} + +func resizeDistributed(ctx *cli.Context, p *resizeParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewResizeUDRedisBlockSizeRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.SpaceId = &id + req.BlockId = &p.blockID + req.BlockSize = &p.size + _, err := client.ResizeUDRedisBlockSize(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] resized\n", id) + return true +} diff --git a/products/redis/internal/redis/resize_proxy.go b/products/redis/internal/redis/resize_proxy.go new file mode 100644 index 0000000000..c22d6e7809 --- /dev/null +++ b/products/redis/internal/redis/resize_proxy.go @@ -0,0 +1,53 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResizeProxy returns ucloud redis resize-proxy. +func newResizeProxy(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewResizeUhproxyRequest() + cmd := &cobra.Command{ + Use: "resize-proxy", + Short: "Resize proxy of distributed redis", + Long: "Resize proxy of distributed redis", + Example: "ucloud redis resize-proxy --umem-id udb-xxx --proxy-id proxy-xxx --new-cpu 4", + Run: func(c *cobra.Command, args []string) { + _, err := client.ResizeUhproxy(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "proxy[%s] resized\n", *req.ProxyId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.ProxyId, Action: "resize-proxy", Status: "Resized"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.SpaceId = flags.String("umem-id", "", "Required. Resource ID of the distributed redis") + req.ProxyId = flags.String("proxy-id", "", "Required. Proxy ID of the proxy to resize") + req.NewCPU = flags.Int("new-cpu", 0, "Required. Target CPU cores of the proxy") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("proxy-id") + cmd.MarkFlagRequired("new-cpu") + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/redis/internal/redis/resource_type.go b/products/redis/internal/redis/resource_type.go new file mode 100644 index 0000000000..0e54194551 --- /dev/null +++ b/products/redis/internal/redis/resource_type.go @@ -0,0 +1,43 @@ +package redis + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +type redisMode string + +const ( + redisModeUnknown redisMode = "" + redisModeMasterReplica redisMode = "master-replica" + redisModeDistributed redisMode = "distributed" +) + +func resourceTypeToMode(resourceType string) redisMode { + switch resourceType { + case "g4v6", "single": + return redisModeMasterReplica + case "performance", "cluster": + return redisModeDistributed + } + return redisModeUnknown +} + +func describeRedisMode(ctx *cli.Context, id string) (redisMode, error) { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeUMemRequest() + req.Protocol = sdk.String("redis") + req.ResourceId = &id + resp, err := client.DescribeUMem(req) + if err != nil { + return redisModeUnknown, err + } + if len(resp.DataSet) < 1 { + return redisModeUnknown, fmt.Errorf("resource [%s] may not exist", id) + } + return resourceTypeToMode(resp.DataSet[0].ResourceType), nil +} diff --git a/products/redis/internal/redis/restart.go b/products/redis/internal/redis/restart.go new file mode 100644 index 0000000000..678f4704f1 --- /dev/null +++ b/products/redis/internal/redis/restart.go @@ -0,0 +1,88 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestart returns ucloud redis restart. +func newRestart(ctx *cli.Context) *cobra.Command { + idNames := make([]string, 0) + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewRestartURedisGroupRequest() + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart redis instances of master-replica type", + Long: "Restart redis instances of master-replica type. Only master-replica instances are supported", + Run: func(c *cobra.Command, args []string) { + reqs := make([]request.Common, 0, len(idNames)) + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + if mode != redisModeMasterReplica { + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] is not master-replica type, skip\n", idname) + continue + } + next := *req + next.GroupId = &id + reqs = append(reqs, &next) + } + prog := ctx.NewProgress() + if len(reqs) > 5 { + prog.Disable() + } + ctx.ConcurrentAction(reqs, 10, restart(ctx, client, prog)) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to restart") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + cmd.MarkFlagRequired("umem-id") + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} + +func restart(ctx *cli.Context, client *umem.UMemClient, prog *cli.Progress) func(request.Common) (bool, []string) { + return func(creq request.Common) (bool, []string) { + req := creq.(*umem.RestartURedisGroupRequest) + block := prog.NewBlock() + logs := []string{} + _, err := client.RestartURedisGroup(req) + if err != nil { + msg := fmt.Sprintf("restart redis[%s] failed: %s", *req.GroupId, cli.ParseError(err)) + block.Append(cli.ParseError(err)) + logs = append(logs, msg) + return false, logs + } + text := fmt.Sprintf("redis[%s] is restarting", *req.GroupId) + ret := ctx.PollerTo(ctx.ProgressWriter(), describeByID(ctx)).Sspoll(*req.GroupId, text, []string{UMEM_RUNNING, UMEM_FAIL}, block, nil) + if ret.Err != nil { + block.Append(cli.ParseError(ret.Err)) + logs = append(logs, ret.Err.Error()) + } + if ret.Timeout { + logs = append(logs, fmt.Sprintf("poll redis[%s] timeout", *req.GroupId)) + } + return ret.Done, logs + } +} diff --git a/products/redis/internal/redis/rows.go b/products/redis/internal/redis/rows.go new file mode 100644 index 0000000000..a2a88289e1 --- /dev/null +++ b/products/redis/internal/redis/rows.go @@ -0,0 +1,35 @@ +package redis + +type Row struct { + ResourceID string + Name string + Role string + Type string + Address string + Size string + UsedSize string + State string + Group string + Zone string + CreateTime string +} + +type BlockRow struct { + BlockID string + BlockName string + BlockVip string + BlockPort int + BlockType string + BlockState string + BlockSize int + UsedSize int + SlotBegin int + SlotEnd int + ReadWeight int +} +type ProxyRow struct { + ProxyID string + ResourceID string + State string + Vip string +} diff --git a/products/redis/internal/redis/status.go b/products/redis/internal/redis/status.go new file mode 100644 index 0000000000..737434fdd5 --- /dev/null +++ b/products/redis/internal/redis/status.go @@ -0,0 +1,6 @@ +package redis + +const ( + UMEM_FAIL = "Fail" + UMEM_RUNNING = "Running" +) diff --git a/products/redis/internal/redis/vpc.go b/products/redis/internal/redis/vpc.go new file mode 100644 index 0000000000..027c621733 --- /dev/null +++ b/products/redis/internal/redis/vpc.go @@ -0,0 +1,119 @@ +package redis + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func fillDefaultVPCAndSubnet(ctx *cli.Context, vpcID, subnetID *string, projectID, region, zone string) error { + if *vpcID != "" && *subnetID != "" { + return nil + } + vpcs, err := getAllVPCIns(ctx, projectID, region) + if err != nil { + return fmt.Errorf("failed to get vpc list: %s", err) + } + if len(vpcs) == 0 { + return fmt.Errorf("no vpc found in region[%s], please specify --vpc-id and --subnet-id", region) + } + + var defaultVPC *vpc.VPCInfo + for i := range vpcs { + if vpcs[i].VPCType == "DefaultVPC" { + defaultVPC = &vpcs[i] + break + } + } + if defaultVPC == nil { + defaultVPC = &vpcs[0] + } + + if *vpcID == "" { + *vpcID = defaultVPC.VPCId + } + + if *subnetID == "" { + subnets, err := getAllSubnets(ctx, *vpcID, projectID, region) + if err != nil { + return fmt.Errorf("failed to get subnet list: %s", err) + } + if len(subnets) == 0 { + return fmt.Errorf("no subnet found in vpc[%s], please specify --subnet-id", *vpcID) + } + if zone != "" { + for _, sn := range subnets { + if sn.Zone == zone { + *subnetID = sn.SubnetId + return nil + } + } + } + *subnetID = subnets[0].SubnetId + } + + return nil +} + +func getAllVPCIns(ctx *cli.Context, project, region string) ([]vpc.VPCInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + vpcInsList, err := getAllVPCIns(ctx, project, region) + list := []string{} + if err != nil { + return nil + } + for _, vpc := range vpcInsList { + list = append(list, fmt.Sprintf("%s/%s", vpc.VPCId, vpc.Name)) + } + return list +} + +func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpc.SubnetInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + if vpcID != "" { + req.VPCId = sdk.String(cli.PickResourceID(vpcID)) + } + subnets := []vpc.SubnetInfo{} + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.DescribeSubnet(req) + if err != nil { + return nil, err + } + subnets = append(subnets, resp.DataSet...) + if limit+offset >= resp.TotalCount { + break + } + } + return subnets, nil +} + +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + subnets, err := getAllSubnets(ctx, vpcID, project, region) + if err != nil { + return nil + } + list := []string{} + for _, s := range subnets { + list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return list +} diff --git a/products/redis/product.go b/products/redis/product.go new file mode 100644 index 0000000000..3af6d63a00 --- /dev/null +++ b/products/redis/product.go @@ -0,0 +1,20 @@ +package redis + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalredis "github.com/ucloud/ucloud-cli/products/redis/internal/redis" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "redis", Commands: []string{"redis"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalredis.NewCommand(ctx)} +} diff --git a/products/redis/product.yaml b/products/redis/product.yaml new file mode 100644 index 0000000000..bd536e6aa7 --- /dev/null +++ b/products/redis/product.yaml @@ -0,0 +1,6 @@ +name: redis +owners: + - ucloud-umem-qingpfang +commands: + - redis +enabled: true diff --git a/products/redis/testdata/cmdtree.golden b/products/redis/testdata/cmdtree.golden new file mode 100644 index 0000000000..423f5003b1 --- /dev/null +++ b/products/redis/testdata/cmdtree.golden @@ -0,0 +1,97 @@ +ucloud redis use=redis short=List and manipulate redis instances +ucloud redis create use=create short=Create redis instance + flag=block-cnt short= default=2 required= + flag=charge-type short= default=Month required= + flag=group short= default= required= + flag=name short= default= required=true + flag=password short= default= required= + flag=project-id short= default= required= + flag=proxy-size short= default=2 required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=size-gb short= default=2 required= + flag=subnet-id short= default= required= + flag=type short= default= required=true + flag=version short= default=6.0 required= + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud redis create-proxy use=create-proxy short=Create proxy for distributed redis + flag=cpu short= default=2 required=true + flag=port short= default=6379 required= + flag=project-id short= default= required= + flag=proxy-cnt short= default=1 required= + flag=region short= default= required= + flag=umem-id short= default= required=true + flag=zone short= default= required= +ucloud redis delete use=delete short=Delete redis instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud redis delete-proxy use=delete-proxy short=Delete proxy of distributed redis + flag=project-id short= default= required= + flag=proxy-id short= default= required=true + flag=region short= default= required= + flag=umem-id short= default= required=true + flag=zone short= default= required= +ucloud redis flush use=flush short=Clear data of redis instances + flag=db-num short= default=0 required= + flag=flush-type short= default=FlushAll required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud redis isolation use=isolation short=Open or close redis instances of master-replica type + flag=project-id short= default= required= + flag=region short= default= required= + flag=type short= default=close required=true + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud redis list use=list short=List redis instances + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default= required= + flag=zone short= default= required= +ucloud redis list-block use=list-block short=List block info of distributed redis + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default= required=true + flag=zone short= default= required= +ucloud redis list-proxy use=list-proxy short=List proxy info of distributed redis + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default= required=true + flag=zone short= default= required= +ucloud redis modify-name use=modify-name short=Modify redis instance name + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud redis modify-password use=modify-password short=Modify redis instance password + flag=password short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud redis resize use=resize short=Resize redis instances + flag=block-id short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=size-gb short= default=0 required=true + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud redis resize-proxy use=resize-proxy short=Resize proxy of distributed redis + flag=new-cpu short= default=0 required=true + flag=project-id short= default= required= + flag=proxy-id short= default= required=true + flag=region short= default= required= + flag=umem-id short= default= required=true + flag=zone short= default= required= +ucloud redis restart use=restart short=Restart redis instances of master-replica type + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default=[] required=true + flag=zone short= default= required= diff --git a/products/redis/testdata/completion.golden b/products/redis/testdata/completion.golden new file mode 100644 index 0000000000..ce3b69dd56 --- /dev/null +++ b/products/redis/testdata/completion.golden @@ -0,0 +1,61 @@ +ucloud redis create charge-type static Dynamic,Month,Year +ucloud redis create project-id dynamic +ucloud redis create region dynamic +ucloud redis create subnet-id dynamic +ucloud redis create type static distributed,master-replica +ucloud redis create version static 4.0,5.0,6.0,7.0 +ucloud redis create vpc-id dynamic +ucloud redis create zone dynamic +ucloud redis create-proxy project-id dynamic +ucloud redis create-proxy region dynamic +ucloud redis create-proxy umem-id dynamic +ucloud redis create-proxy zone dynamic +ucloud redis delete project-id dynamic +ucloud redis delete region dynamic +ucloud redis delete umem-id dynamic +ucloud redis delete zone dynamic +ucloud redis delete-proxy project-id dynamic +ucloud redis delete-proxy region dynamic +ucloud redis delete-proxy umem-id dynamic +ucloud redis delete-proxy zone dynamic +ucloud redis flush project-id dynamic +ucloud redis flush region dynamic +ucloud redis flush umem-id dynamic +ucloud redis flush zone dynamic +ucloud redis isolation project-id dynamic +ucloud redis isolation region dynamic +ucloud redis isolation type static close,open +ucloud redis isolation umem-id dynamic +ucloud redis isolation zone dynamic +ucloud redis list project-id dynamic +ucloud redis list region dynamic +ucloud redis list umem-id dynamic +ucloud redis list zone dynamic +ucloud redis list-block project-id dynamic +ucloud redis list-block region dynamic +ucloud redis list-block umem-id dynamic +ucloud redis list-block zone dynamic +ucloud redis list-proxy project-id dynamic +ucloud redis list-proxy region dynamic +ucloud redis list-proxy umem-id dynamic +ucloud redis list-proxy zone dynamic +ucloud redis modify-name project-id dynamic +ucloud redis modify-name region dynamic +ucloud redis modify-name umem-id dynamic +ucloud redis modify-name zone dynamic +ucloud redis modify-password project-id dynamic +ucloud redis modify-password region dynamic +ucloud redis modify-password umem-id dynamic +ucloud redis modify-password zone dynamic +ucloud redis resize project-id dynamic +ucloud redis resize region dynamic +ucloud redis resize umem-id dynamic +ucloud redis resize zone dynamic +ucloud redis resize-proxy project-id dynamic +ucloud redis resize-proxy region dynamic +ucloud redis resize-proxy umem-id dynamic +ucloud redis resize-proxy zone dynamic +ucloud redis restart project-id dynamic +ucloud redis restart region dynamic +ucloud redis restart umem-id dynamic +ucloud redis restart zone dynamic diff --git a/products/sharedbw/internal/bw/cmd.go b/products/sharedbw/internal/bw/cmd.go new file mode 100644 index 0000000000..146025582e --- /dev/null +++ b/products/sharedbw/internal/bw/cmd.go @@ -0,0 +1,19 @@ +package bw + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand returns the ucloud bw command tree. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "bw", + Short: "Manipulate bandwidth package and shared bandwidth", + Long: "Manipulate bandwidth package and shared bandwidth", + } + cmd.AddCommand(newPkg(ctx)) + cmd.AddCommand(newShared(ctx)) + return cmd +} diff --git a/products/sharedbw/internal/bw/completion.go b/products/sharedbw/internal/bw/completion.go new file mode 100644 index 0000000000..7c8c07d06e --- /dev/null +++ b/products/sharedbw/internal/bw/completion.go @@ -0,0 +1,90 @@ +package bw + +import ( + "strings" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func getAllSharedBW(ctx *cli.Context, project, region string) ([]string, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeShareBandwidthRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeShareBandwidth(req) + if err != nil { + return nil, err + } + list := []string{} + for _, item := range resp.DataSet { + list = append(list, item.ShareBandwidthId+"/"+item.Name) + } + return list, nil +} + +func getAllEip(ctx *cli.Context, projectID, region string, states, paymodes []string) []string { + list, err := fetchAllEip(ctx, projectID, region) + if err != nil { + return nil + } + strs := []string{} + for _, item := range list { + rightState := false + if states == nil { + rightState = true + } else { + for _, s := range states { + if item.Status == s { + rightState = true + } + } + } + + rightPayMode := false + if paymodes == nil { + rightPayMode = true + } else { + for _, m := range paymodes { + if item.PayMode == m { + rightPayMode = true + } + } + } + if !rightPayMode || !rightState { + continue + } + + ips := []string{} + for _, ip := range item.EIPAddr { + ips = append(ips, ip.IP) + } + strs = append(strs, item.EIPId+"/"+strings.Join(ips, ",")) + } + return strs +} + +func fetchAllEip(ctx *cli.Context, projectID, region string) ([]unet.UnetEIPSet, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeEIPRequest() + list := []unet.UnetEIPSet{} + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + for offset, step := 0, 100; ; offset += step { + req.Offset = &offset + req.Limit = &step + resp, err := client.DescribeEIP(req) + if err != nil { + return nil, err + } + for i, size := 0, len(resp.EIPSet); i < size; i++ { + list = append(list, resp.EIPSet[i]) + } + if resp.TotalCount <= offset+step { + break + } + } + return list, nil +} diff --git a/products/sharedbw/internal/bw/pkg.go b/products/sharedbw/internal/bw/pkg.go new file mode 100644 index 0000000000..86e52538f0 --- /dev/null +++ b/products/sharedbw/internal/bw/pkg.go @@ -0,0 +1,20 @@ +package bw + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPkg returns ucloud bw pkg. +func newPkg(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "pkg", + Short: "List, create and delete bandwidth package instances", + Long: "List, create and delete bandwidth package instances", + } + cmd.AddCommand(newPkgCreate(ctx)) + cmd.AddCommand(newPkgList(ctx)) + cmd.AddCommand(newPkgDelete(ctx)) + return cmd +} diff --git a/products/sharedbw/internal/bw/pkg_create.go b/products/sharedbw/internal/bw/pkg_create.go new file mode 100644 index 0000000000..1cab191cc9 --- /dev/null +++ b/products/sharedbw/internal/bw/pkg_create.go @@ -0,0 +1,85 @@ +package bw + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newPkgCreate returns ucloud bw pkg create. +func newPkgCreate(ctx *cli.Context) *cobra.Command { + var start, end *string + timeLayout := "2006-01-02/15:04:05" + ids := []string{} + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewCreateBandwidthPackageRequest() + loc, _ := time.LoadLocation("Local") + cmd := &cobra.Command{ + Use: "create", + Short: "Create bandwidth package", + Long: "Create bandwidth package", + Example: "ucloud bw pkg create --eip-id eip-xxx --bandwidth-mb 20 --start-time 2018-12-15/09:20:00 --end-time 2018-12-16/09:20:00", + Run: func(c *cobra.Command, args []string) { + st, err := time.ParseInLocation(timeLayout, *start, loc) + if err != nil { + ctx.HandleError(err) + return + } + et, err := time.ParseInLocation(timeLayout, *end, loc) + if err != nil { + ctx.HandleError(err) + return + } + if st.Sub(time.Now()) < 0 { + fmt.Fprintln(ctx.ProgressWriter(), "start-time must be after the current time") + return + } + du := et.Unix() - st.Unix() + if du <= 0 { + fmt.Fprintln(ctx.ProgressWriter(), "end-time must be after the start-time") + return + } + req.EnableTime = sdk.Int(int(st.Unix())) + req.TimeRange = sdk.Int(int(du)) + + results := []cli.OpResultRow{} + for _, id := range ids { + id = ctx.PickResourceID(id) + req.EIPId = &id + resp, err := client.CreateBandwidthPackage(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "bandwidth package[%s] created for eip[%s]\n", resp.BandwidthPackageId, id) + results = append(results, cli.OpResultRow{ResourceID: resp.BandwidthPackageId, Action: "create", Status: "Created"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&ids, "eip-id", nil, "Required. Resource ID of eip to be bound with created bandwidth package") + start = flags.String("start-time", "", "Required. The time to enable bandwidth package. Local time, for example '2018-12-25/08:30:00'") + end = flags.String("end-time", "", "Required. The time to disable bandwidth package. Local time, for example '2018-12-26/08:30:00'") + req.Bandwidth = flags.Int("bandwidth-mb", 0, "Required. bandwidth of the bandwidth package to create.Range [1,800]. Unit:'Mb'.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetCompletion(cmd, "eip-id", func() []string { + return getAllEip(ctx, *req.ProjectId, *req.Region, []string{EIP_USED}, []string{EIP_CHARGE_BANDWIDTH}) + }) + + cmd.MarkFlagRequired("eip-id") + cmd.MarkFlagRequired("start-time") + cmd.MarkFlagRequired("end-time") + cmd.MarkFlagRequired("bandwidth-mb") + return cmd +} diff --git a/products/sharedbw/internal/bw/pkg_delete.go b/products/sharedbw/internal/bw/pkg_delete.go new file mode 100644 index 0000000000..8755eb69e1 --- /dev/null +++ b/products/sharedbw/internal/bw/pkg_delete.go @@ -0,0 +1,46 @@ +package bw + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPkgDelete returns ucloud bw pkg delete. +func newPkgDelete(ctx *cli.Context) *cobra.Command { + ids := []string{} + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDeleteBandwidthPackageRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete bandwidth packages", + Long: "Delete bandwidth packages", + Example: "ucloud bw pkg delete --resource-id bwpack-xxx", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range ids { + id := ctx.PickResourceID(idname) + req.BandwidthPackageId = &id + _, err := client.DeleteBandwidthPackage(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "bandwidth package[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&ids, "resource-id", nil, "Required, Resource ID of bandwidth package to delete") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + return cmd +} diff --git a/products/sharedbw/internal/bw/pkg_list.go b/products/sharedbw/internal/bw/pkg_list.go new file mode 100644 index 0000000000..7f6cf16aaa --- /dev/null +++ b/products/sharedbw/internal/bw/pkg_list.go @@ -0,0 +1,54 @@ +package bw + +import ( + "strconv" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPkgList returns ucloud bw pkg list. +func newPkgList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeBandwidthPackageRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List bandwidth packages", + Long: "List bandwidth packages", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeBandwidthPackage(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []BandwidthPkgRow{} + for _, bp := range resp.DataSets { + row := BandwidthPkgRow{ + ResourceID: bp.BandwidthPackageId, + Bandwidth: strconv.Itoa(bp.Bandwidth) + "MB", + StartTime: common.FormatDateTime(bp.EnableTime), + EndTime: common.FormatDateTime(bp.DisableTime), + } + eip := bp.EIPId + for _, addr := range bp.EIPAddr { + eip += "/" + addr.IP + "/" + addr.OperatorName + } + row.EIP = eip + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.Offset = flags.Int("offset", 0, "Optional. Offset") + req.Limit = flags.Int("limit", 50, "Optional. Limit range [0,10000000]") + + return cmd +} diff --git a/products/sharedbw/internal/bw/rows.go b/products/sharedbw/internal/bw/rows.go new file mode 100644 index 0000000000..250d867357 --- /dev/null +++ b/products/sharedbw/internal/bw/rows.go @@ -0,0 +1,18 @@ +package bw + +type SharedBWRow struct { + Name string + ResourceID string + ChargeType string + Bandwidth string + EIP string + ExpirationTime string +} + +type BandwidthPkgRow struct { + ResourceID string + EIP string + Bandwidth string + StartTime string + EndTime string +} diff --git a/products/sharedbw/internal/bw/shared.go b/products/sharedbw/internal/bw/shared.go new file mode 100644 index 0000000000..7d4d06323d --- /dev/null +++ b/products/sharedbw/internal/bw/shared.go @@ -0,0 +1,21 @@ +package bw + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newShared returns ucloud bw shared. +func newShared(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "shared", + Short: "Create and manipulate shared bandwidth instances", + Long: "Create and manipulate shared bandwidth instances", + } + cmd.AddCommand(newSharedCreate(ctx)) + cmd.AddCommand(newSharedList(ctx)) + cmd.AddCommand(newSharedResize(ctx)) + cmd.AddCommand(newSharedDelete(ctx)) + return cmd +} diff --git a/products/sharedbw/internal/bw/shared_create.go b/products/sharedbw/internal/bw/shared_create.go new file mode 100644 index 0000000000..0e33c46bb1 --- /dev/null +++ b/products/sharedbw/internal/bw/shared_create.go @@ -0,0 +1,49 @@ +package bw + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newSharedCreate returns ucloud bw shared create. +func newSharedCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewAllocateShareBandwidthRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create shared bandwidth instance", + Long: "Create shared bandwidth instance", + Run: func(c *cobra.Command, args []string) { + if *req.ShareBandwidth < 20 || *req.ShareBandwidth > 5000 { + fmt.Fprintf(ctx.ProgressWriter(), "bandwidth should be between 20 and 5000. received %d\n", *req.ShareBandwidth) + return + } + resp, err := client.AllocateShareBandwidth(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "shared bandwidth[%s] created\n", resp.ShareBandwidthId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ShareBandwidthId, Action: "create", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.Name = flags.String("name", "", "Required. Name of the shared bandwidth instance") + req.ShareBandwidth = flags.Int("bandwidth-mb", 20, "Optional. Unit:Mb. Bandwidth of the shared bandwidth. Range [20,5000]") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.ChargeType = flags.String("charge-type", "Month", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") + req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic") + + cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/products/sharedbw/internal/bw/shared_delete.go b/products/sharedbw/internal/bw/shared_delete.go new file mode 100644 index 0000000000..40a6f5bbf0 --- /dev/null +++ b/products/sharedbw/internal/bw/shared_delete.go @@ -0,0 +1,57 @@ +package bw + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newSharedDelete returns ucloud bw shared delete. +func newSharedDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewReleaseShareBandwidthRequest() + ids := []string{} + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete shared bandwidth instance", + Long: "Delete shared bandwidth instance", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range ids { + id := ctx.PickResourceID(idname) + req.ShareBandwidthId = sdk.String(id) + _, err := client.ReleaseShareBandwidth(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "shared bandwidth[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, "shared-bw-id", nil, "Required. Resource ID of shared bandwidth instances to delete") + req.EIPBandwidth = flags.Int("eip-bandwidth-mb", 1, "Optional. Bandwidth of the joined EIPs,after deleting the shared bandwidth instance") + req.PayMode = flags.String("traffic-mode", "", "Optional. The charge mode of joined EIPs after deleting the shared bandwidth. Accept values:Bandwidth,Traffic") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + command.SetCompletion(cmd, "shared-bw-id", func() []string { + list, _ := getAllSharedBW(ctx, *req.ProjectId, *req.Region) + return list + }) + command.SetFlagValues(cmd, "traffic-mode", "Bandwidth", "Traffic") + + cmd.MarkFlagRequired("shared-bw-id") + + return cmd +} diff --git a/products/sharedbw/internal/bw/shared_list.go b/products/sharedbw/internal/bw/shared_list.go new file mode 100644 index 0000000000..1aa7c51510 --- /dev/null +++ b/products/sharedbw/internal/bw/shared_list.go @@ -0,0 +1,61 @@ +package bw + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSharedList returns ucloud bw shared list. +func newSharedList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeShareBandwidthRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List shared bandwidth instances", + Long: "List shared bandwidth instances", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeShareBandwidth(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []SharedBWRow{} + for _, sb := range resp.DataSet { + row := SharedBWRow{} + row.Name = sb.Name + row.ResourceID = sb.ShareBandwidthId + row.ChargeType = sb.ChargeType + row.Bandwidth = strconv.Itoa(sb.ShareBandwidth) + "Mb" + row.ExpirationTime = common.FormatDate(sb.ExpireTime) + eipList := []string{} + for _, eip := range sb.EIPSet { + eipText := "" + eipText += eip.EIPId + for _, ip := range eip.EIPAddr { + eipText += fmt.Sprintf("/%s/%s", ip.IP, ip.OperatorName) + } + eipList = append(eipList, eipText) + } + row.EIP = strings.Join(eipList, "\n") + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + flags.StringSliceVar(&req.ShareBandwidthIds, "shared-bw-id", nil, "Resource ID of shared bandwidth instances to list") + + return cmd +} diff --git a/products/sharedbw/internal/bw/shared_resize.go b/products/sharedbw/internal/bw/shared_resize.go new file mode 100644 index 0000000000..a744fa8d9e --- /dev/null +++ b/products/sharedbw/internal/bw/shared_resize.go @@ -0,0 +1,56 @@ +package bw + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newSharedResize returns ucloud bw shared resize. +func newSharedResize(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewResizeShareBandwidthRequest() + cmd := &cobra.Command{ + Use: "resize", + Short: "Resize shared bandwidth instance's bandwidth", + Long: "Resize shared bandwidth instance's bandwidth", + Run: func(c *cobra.Command, args []string) { + if *req.ShareBandwidth < 20 || *req.ShareBandwidth > 5000 { + fmt.Fprintf(ctx.ProgressWriter(), "bandwidth should be between 20 and 5000. received %d\n", *req.ShareBandwidth) + return + } + req.ShareBandwidthId = sdk.String(ctx.PickResourceID(*req.ShareBandwidthId)) + _, err := client.ResizeShareBandwidth(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "shared bandwidth[%s] resized to %dMb\n", *req.ShareBandwidthId, *req.ShareBandwidth) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.ShareBandwidthId, Action: "resize", Status: "Resized"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ShareBandwidthId = flags.String("shared-bw-id", "", "Required. Resource ID of shared bandwidth instance to resize") + req.ShareBandwidth = flags.Int("bandwidth-mb", 0, "Required. Unit:Mb. resize to bandwidth value") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetCompletion(cmd, "shared-bw-id", func() []string { + list, _ := getAllSharedBW(ctx, *req.ProjectId, *req.Region) + return list + }) + + cmd.MarkFlagRequired("shared-bw-id") + cmd.MarkFlagRequired("bandwidth-mb") + + return cmd +} diff --git a/products/sharedbw/internal/bw/status.go b/products/sharedbw/internal/bw/status.go new file mode 100644 index 0000000000..f9c852f0d0 --- /dev/null +++ b/products/sharedbw/internal/bw/status.go @@ -0,0 +1,7 @@ +package bw + +const ( + EIP_USED = "used" + + EIP_CHARGE_BANDWIDTH = "Bandwidth" +) diff --git a/products/sharedbw/product.go b/products/sharedbw/product.go new file mode 100644 index 0000000000..f4a0e24c1f --- /dev/null +++ b/products/sharedbw/product.go @@ -0,0 +1,20 @@ +package sharedbw + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalbw "github.com/ucloud/ucloud-cli/products/sharedbw/internal/bw" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "sharedbw", Commands: []string{"bw"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalbw.NewCommand(ctx)} +} diff --git a/products/sharedbw/product.yaml b/products/sharedbw/product.yaml new file mode 100644 index 0000000000..70369773ce --- /dev/null +++ b/products/sharedbw/product.yaml @@ -0,0 +1,6 @@ +name: sharedbw +owners: + - Episkey-G +commands: + - bw +enabled: true diff --git a/products/sharedbw/testdata/cmdtree.golden b/products/sharedbw/testdata/cmdtree.golden new file mode 100644 index 0000000000..f8b071e5d5 --- /dev/null +++ b/products/sharedbw/testdata/cmdtree.golden @@ -0,0 +1,41 @@ +ucloud bw use=bw short=Manipulate bandwidth package and shared bandwidth +ucloud bw pkg use=pkg short=List, create and delete bandwidth package instances +ucloud bw pkg create use=create short=Create bandwidth package + flag=bandwidth-mb short= default=0 required=true + flag=eip-id short= default=[] required=true + flag=end-time short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=start-time short= default= required=true +ucloud bw pkg delete use=delete short=Delete bandwidth packages + flag=project-id short= default= required= + flag=region short= default= required= + flag=resource-id short= default=[] required= +ucloud bw pkg list use=list short=List bandwidth packages + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= +ucloud bw shared use=shared short=Create and manipulate shared bandwidth instances +ucloud bw shared create use=create short=Create shared bandwidth instance + flag=bandwidth-mb short= default=20 required= + flag=charge-type short= default=Month required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= +ucloud bw shared delete use=delete short=Delete shared bandwidth instance + flag=eip-bandwidth-mb short= default=1 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=shared-bw-id short= default=[] required=true + flag=traffic-mode short= default= required= +ucloud bw shared list use=list short=List shared bandwidth instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=shared-bw-id short= default=[] required= +ucloud bw shared resize use=resize short=Resize shared bandwidth instance's bandwidth + flag=bandwidth-mb short= default=0 required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=shared-bw-id short= default= required=true diff --git a/products/sharedbw/testdata/completion.golden b/products/sharedbw/testdata/completion.golden new file mode 100644 index 0000000000..deb65db956 --- /dev/null +++ b/products/sharedbw/testdata/completion.golden @@ -0,0 +1,19 @@ +ucloud bw pkg create eip-id dynamic +ucloud bw pkg create project-id dynamic +ucloud bw pkg create region dynamic +ucloud bw pkg delete project-id dynamic +ucloud bw pkg delete region dynamic +ucloud bw pkg list project-id dynamic +ucloud bw pkg list region dynamic +ucloud bw shared create charge-type static Dynamic,Month,Year +ucloud bw shared create project-id dynamic +ucloud bw shared create region dynamic +ucloud bw shared delete project-id dynamic +ucloud bw shared delete region dynamic +ucloud bw shared delete shared-bw-id dynamic +ucloud bw shared delete traffic-mode static Bandwidth,Traffic +ucloud bw shared list project-id dynamic +ucloud bw shared list region dynamic +ucloud bw shared resize project-id dynamic +ucloud bw shared resize region dynamic +ucloud bw shared resize shared-bw-id dynamic diff --git a/products/sqlserver/internal/sqlserver/cmd.go b/products/sqlserver/internal/sqlserver/cmd.go new file mode 100644 index 0000000000..1d58f2c0b0 --- /dev/null +++ b/products/sqlserver/internal/sqlserver/cmd.go @@ -0,0 +1,18 @@ +package sqlserver + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `sqlserver` root command and mounts the `db` subtree. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "sqlserver", + Short: "Manipulate SQL Server on UCloud platform", + Long: "Manipulate SQL Server on UCloud platform", + } + cmd.AddCommand(newSQLServerDB(ctx)) + return cmd +} diff --git a/products/sqlserver/internal/sqlserver/completion.go b/products/sqlserver/internal/sqlserver/completion.go new file mode 100644 index 0000000000..294e46b555 --- /dev/null +++ b/products/sqlserver/internal/sqlserver/completion.go @@ -0,0 +1,126 @@ +package sqlserver + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +var dbVersionList = []string{"sqlserver-2017", "sqlserver-2019", "sqlserver-2022"} + +// getAllVPCIns returns VPC list for the given project and region. +func getAllVPCIns(ctx *cli.Context, project, region string) ([]vpc.VPCInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +// getAllVPCIdNames returns VPC ID/Name pairs for shell completion. +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + vpcInsList, err := getAllVPCIns(ctx, project, region) + list := []string{} + if err != nil { + return nil + } + for _, v := range vpcInsList { + list = append(list, fmt.Sprintf("%s/%s", v.VPCId, v.Name)) + } + return list +} + +// getAllSubnets returns subnet list for the given VPC, project and region. +func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpc.SubnetInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + if vpcID != "" { + req.VPCId = sdk.String(cli.PickResourceID(vpcID)) + } + subnets := []vpc.SubnetInfo{} + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.DescribeSubnet(req) + if err != nil { + ctx.HandleError(err) + return nil, err + } + subnets = append(subnets, resp.DataSet...) + if limit+offset >= resp.TotalCount { + break + } + } + return subnets, nil +} + +// getAllSubnetIDNames returns Subnet ID/Name pairs for shell completion. +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + subnets, err := getAllSubnets(ctx, vpcID, project, region) + if err != nil { + return nil + } + list := []string{} + for _, s := range subnets { + list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return list +} + +func getUDBIDList(ctx *cli.Context, states []string, dbType, project, region, zone string) []string { + udbs, err := getUDBList(ctx, states, dbType, project, region, zone) + if err != nil { + return nil + } + list := []string{} + for _, db := range udbs { + list = append(list, fmt.Sprintf("%s/%s", db.DBId, db.Name)) + } + return list +} + +func getUDBList(ctx *cli.Context, states []string, dbType, project, region, zone string) ([]udb.UDBInstanceSet, error) { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBInstanceRequest() + if dbType == "" { + dbType = "sqlserver" + } + req.ClassType = &dbType + req.ProjectId = &project + req.Region = ®ion + req.Zone = &zone + list := []udb.UDBInstanceSet{} + for offset, limit := 0, 50; ; offset += limit { + req.Offset = sdk.Int(offset) + req.Limit = sdk.Int(limit) + resp, err := client.DescribeUDBInstance(req) + if err != nil { + return nil, err + } + for _, ins := range resp.DataSet { + if states != nil { + for _, s := range states { + if s == ins.State { + list = append(list, ins) + } + } + } else { + list = append(list, ins) + } + } + if offset+limit >= resp.TotalCount { + break + } + } + return list, nil +} diff --git a/products/sqlserver/internal/sqlserver/create.go b/products/sqlserver/internal/sqlserver/create.go new file mode 100644 index 0000000000..b94e0809aa --- /dev/null +++ b/products/sqlserver/internal/sqlserver/create.go @@ -0,0 +1,226 @@ +package sqlserver + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +var dbStorageClassList = []string{"CLOUD_RSSD"} +var dbSpecClassList = []string{"O"} +var dbMachineTypeList = []string{ + "o.sqlserver2m.medium", // 2C4G + "o.sqlserver2m.xlarge", // 4C8G + "o.sqlserver2m.2xlarge", // 8C16G + "o.sqlserver2m.4xlarge", // 16C32G + "o.sqlserver2m.8xlarge", // 32C64G + "o.sqlserver4m.medium", // 2C8G + "o.sqlserver4m.xlarge", // 4C16G + "o.sqlserver4m.2xlarge", // 8C32G + "o.sqlserver4m.4xlarge", // 16C64G + "o.sqlserver4m.8xlarge", // 32C128G + "o.sqlserver8m.medium", // 2C16G + "o.sqlserver8m.xlarge", // 4C32G + "o.sqlserver8m.2xlarge", // 8C64G + "o.sqlserver8m.4xlarge", // 16C128G + "o.sqlserver8m.8xlarge", // 32C256G +} + +// newCreate returns the "create" command for SQL Server Normal (single-node) instances. +func newCreate(ctx *cli.Context) *cobra.Command { + var labels []string + var name, password, version, storageClass, specClass string + var cpu, memory, port, diskSpace int + var chargeType string + var quantity int + var vpcID, subnetID string + var backupCount, backupTime, backupDuration int + var tag, alarmTemplateID string + var couponID string + var async bool + var common request.CommonBase + + cmd := &cobra.Command{ + Use: "create", + Short: "Create SQL Server instance (Normal/single-node mode) on UCloud platform", + Long: "Create SQL Server instance (Normal/single-node mode) on UCloud platform", + Run: func(c *cobra.Command, args []string) { + region := common.GetRegion() + zone := common.GetZone() + projectID := common.GetProjectId() + if len(name) < 6 { + ctx.HandleError(fmt.Errorf("name must be at least 6 characters")) + return + } + if diskSpace < 20 || diskSpace > 32000 { + ctx.HandleError(fmt.Errorf("disk-size-gb must be between 20 and 32000")) + return + } + + params := map[string]interface{}{ + "Action": "CreateUDBSQLServerInstance", + "Region": region, + "Zone": zone, + "Name": name, + "AdminPassword": password, + "DBTypeId": version, + "Port": port, + "DiskSpace": diskSpace, + "CPU": cpu, + "MemoryLimit": memory, + "StorageClass": storageClass, + "SpecificationClass": specClass, + "ChargeType": chargeType, + "Quantity": quantity, + "InstanceMode": "Normal", + "BackupCount": backupCount, + "BackupTime": backupTime, + "BackupDuration": backupDuration, + } + if projectID != "" { + params["ProjectId"] = projectID + } + + // Optional params, only send when explicitly set + if c.Flags().Changed("vpc-id") { + params["VPCId"] = vpcID + } + if c.Flags().Changed("subnet-id") { + params["SubnetId"] = subnetID + } + if c.Flags().Changed("tag") { + params["Tag"] = tag + } + if c.Flags().Changed("alarm-template-id") { + params["AlarmTemplateId"] = alarmTemplateID + } + if c.Flags().Changed("coupon-id") { + params["CouponId"] = couponID + } + + idx := 0 + for _, l := range labels { + parts := strings.SplitN(l, "=", 2) + if len(parts) == 2 { + params[fmt.Sprintf("Labels.%d.Key", idx)] = parts[0] + params[fmt.Sprintf("Labels.%d.Value", idx)] = parts[1] + idx++ + } + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(err) + return + } + + dbID, _ := resp.GetPayload()["DBId"].(string) + if dbID == "" { + ctx.HandleError(fmt.Errorf("empty DBId in response")) + return + } + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "udb[%s] is initializing\n", dbID) + } else { + text := fmt.Sprintf("udb[%s] is initializing", dbID) + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(dbID, text, []string{UDB_RUNNING, UDB_FAIL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: dbID, Action: "create", Status: "Initializing"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + // Required flags + flags.StringVar(&name, "name", "", "Required. Instance name, at least 6 characters") + flags.StringVar(&password, "password", "", "Required. Admin password") + flags.StringVar(&version, "db-type", "", "Required. SQL Server version. Options: sqlserver-2017, sqlserver-2019, sqlserver-2022") + + // Optional flags with defaults + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + flags.IntVar(&cpu, "cpu", 2, "Optional. CPU cores. Options: 2/4/8/16/32/64, default 2") + flags.IntVar(&memory, "memory", 4000, "Optional. Memory limit (MB). Options: 2000/4000/6000/8000/12000/16000/24000/32000/48000/64000/96000/128000/192000/256000/320000, default 4000") + flags.IntVar(&port, "port", 1433, "Optional. Port, default 1433") + flags.IntVar(&diskSpace, "disk-size-gb", 50, "Optional. Disk size (GiB), 20-32000, default 50") + flags.StringVar(&storageClass, "storage-class", "CLOUD_RSSD", "Optional. Storage class: CLOUD_RSSD") + flags.StringVar(&specClass, "spec-class", "O", "Optional. Spec class: O(NVMe)") + + flags.StringVar(&chargeType, "charge-type", "Month", "Optional. Year / Month / Dynamic") + flags.IntVar(&quantity, "quantity", 1, "Optional. Purchase duration") + flags.StringVar(&vpcID, "vpc-id", "", "Optional. VPC ID. See 'ucloud vpc list'") + flags.StringVar(&subnetID, "subnet-id", "", "Optional. Subnet ID. See 'ucloud subnet list'") + flags.IntVar(&backupCount, "backup-count", 7, "Optional. Weekly backup count, default 7") + flags.IntVar(&backupTime, "backup-time", 1, "Optional. Backup start hour (0-23), default 1") + flags.IntVar(&backupDuration, "backup-duration", 24, "Optional. Backup interval hours, default 24") + flags.StringVar(&tag, "tag", "", "Optional. Business group name") + flags.StringVar(&alarmTemplateID, "alarm-template-id", "", "Optional. Alarm template ID") + flags.StringSliceVar(&labels, "label", nil, "Optional. Resource label, format: key=value, repeatable") + flags.StringVar(&couponID, "coupon-id", "", "Optional. Coupon ID") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for creation to finish") + + command.SetFlagValues(cmd, "db-type", dbVersionList...) + command.SetFlagValues(cmd, "storage-class", dbStorageClassList...) + command.SetFlagValues(cmd, "spec-class", dbSpecClassList...) + command.SetFlagValues(cmd, "charge-type", "Month", "Dynamic", "Year") + command.SetFlagValues(cmd, "cpu", "2", "4", "8", "16", "32", "64") + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, common.GetProjectId(), common.GetRegion()) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, vpcID, common.GetProjectId(), common.GetRegion()) + }) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("db-type") + + // Custom usage, highlight required flags + requiredFlags := []string{"name", "password", "db-type"} + cmd.SetUsageFunc(func(c *cobra.Command) error { + w := c.OutOrStderr() + fmt.Fprintln(w, "Usage:") + fmt.Fprintf(w, " %s [flags]\n\n", c.CommandPath()) + fmt.Fprintln(w, "★ Required flags (must be provided):") + for _, name := range requiredFlags { + f := c.Flags().Lookup(name) + if f != nil { + fmt.Fprintf(w, " --%-20s %s\n", f.Name, f.Usage) + } + } + fmt.Fprintln(w, "\nOptional flags:") + c.Flags().VisitAll(func(f *pflag.Flag) { + for _, req := range requiredFlags { + if f.Name == req { + return + } + } + defVal := "" + if f.DefValue != "" && f.DefValue != "[]" { + defVal = fmt.Sprintf(" (default %s)", f.DefValue) + } + fmt.Fprintf(w, " --%-20s %s%s\n", f.Name, f.Usage, defVal) + }) + return nil + }) + + return cmd +} diff --git a/products/sqlserver/internal/sqlserver/create_alwayson.go b/products/sqlserver/internal/sqlserver/create_alwayson.go new file mode 100644 index 0000000000..f7ab29e161 --- /dev/null +++ b/products/sqlserver/internal/sqlserver/create_alwayson.go @@ -0,0 +1,202 @@ +package sqlserver + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreateAlwaysOn returns the "create-alwayson" command for SQL Server HA (AlwaysOn cluster) instances. +func newCreateAlwaysOn(ctx *cli.Context) *cobra.Command { + var labels []string + var name, password, version, machineType, storageClass, specClass string + var port, diskSpace int + var chargeType string + var quantity int + var vpcID, subnetID string + var backupCount, backupTime, backupDuration int + var tag, alarmTemplateID string + var couponID string + var async bool + var common request.CommonBase + + cmd := &cobra.Command{ + Use: "create-alwayson", + Short: "Create SQL Server instance (HA/AlwaysOn cluster mode) on UCloud platform", + Long: "Create SQL Server instance (HA/AlwaysOn cluster mode) on UCloud platform.", + Run: func(c *cobra.Command, args []string) { + region := common.GetRegion() + zone := common.GetZone() + projectID := common.GetProjectId() + if len(name) < 6 { + ctx.HandleError(fmt.Errorf("name must be at least 6 characters")) + return + } + if diskSpace < 20 || diskSpace > 32000 { + ctx.HandleError(fmt.Errorf("disk-size-gb must be between 20 and 32000")) + return + } + + params := map[string]interface{}{ + "Action": "CreateUDBSQLServerInstance", + "Region": region, + "Zone": zone, + "Name": name, + "AdminPassword": password, + "DBTypeId": version, + "Port": port, + "DiskSpace": diskSpace, + "MachineType": machineType, + "VPCId": vpcID, + "SubnetId": subnetID, + "StorageClass": storageClass, + "SpecificationClass": specClass, + "ChargeType": chargeType, + "Quantity": quantity, + "InstanceMode": "AlwaysOn", + "BackupCount": backupCount, + "BackupTime": backupTime, + "BackupDuration": backupDuration, + } + if projectID != "" { + params["ProjectId"] = projectID + } + + // Optional params, only send when explicitly set + if c.Flags().Changed("tag") { + params["Tag"] = tag + } + if c.Flags().Changed("alarm-template-id") { + params["AlarmTemplateId"] = alarmTemplateID + } + if c.Flags().Changed("coupon-id") { + params["CouponId"] = couponID + } + + idx := 0 + for _, l := range labels { + parts := strings.SplitN(l, "=", 2) + if len(parts) == 2 { + params[fmt.Sprintf("Labels.%d.Key", idx)] = parts[0] + params[fmt.Sprintf("Labels.%d.Value", idx)] = parts[1] + idx++ + } + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(err) + return + } + + dbID, _ := resp.GetPayload()["DBId"].(string) + if dbID == "" { + ctx.HandleError(fmt.Errorf("empty DBId in response")) + return + } + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "udb[%s] is initializing\n", dbID) + } else { + text := fmt.Sprintf("udb[%s] is initializing", dbID) + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(dbID, text, []string{UDB_RUNNING, UDB_FAIL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: dbID, Action: "create-alwayson", Status: "Initializing"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + // Required flags + flags.StringVar(&name, "name", "", "Required. Instance name, at least 6 characters") + flags.StringVar(&password, "password", "", "Required. Admin password") + flags.StringVar(&version, "db-type", "", "Required. SQL Server version. Options: sqlserver-2017, sqlserver-2019, sqlserver-2022") + flags.StringVar(&vpcID, "vpc-id", "", "Required. VPC ID. See 'ucloud vpc list'") + flags.StringVar(&subnetID, "subnet-id", "", "Required. Subnet ID. See 'ucloud subnet list'") + + // Optional flags with defaults + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + flags.StringVar(&machineType, "machine-type", "o.sqlserver2m.medium", "Optional. Machine type ID, e.g. o.sqlserver2m.medium for 2C4G. Use ListUDBMachineType API with InstanceMode=AlwaysOn to get available types") + flags.IntVar(&port, "port", 1433, "Optional. Port, default 1433") + flags.IntVar(&diskSpace, "disk-size-gb", 50, "Optional. Disk size (GiB), 20-32000, default 50") + flags.StringVar(&storageClass, "storage-class", "CLOUD_RSSD", "Optional. Storage class: CLOUD_RSSD") + flags.StringVar(&specClass, "spec-class", "O", "Optional. Spec class: O(NVMe)") + + flags.StringVar(&chargeType, "charge-type", "Month", "Optional. Year / Month / Dynamic") + flags.IntVar(&quantity, "quantity", 1, "Optional. Purchase duration") + flags.IntVar(&backupCount, "backup-count", 7, "Optional. Weekly backup count, default 7") + flags.IntVar(&backupTime, "backup-time", 1, "Optional. Backup start hour (0-23), default 1") + flags.IntVar(&backupDuration, "backup-duration", 24, "Optional. Backup interval hours, default 24") + flags.StringVar(&tag, "tag", "", "Optional. Business group name") + flags.StringVar(&alarmTemplateID, "alarm-template-id", "", "Optional. Alarm template ID") + flags.StringSliceVar(&labels, "label", nil, "Optional. Resource label, format: key=value, repeatable") + flags.StringVar(&couponID, "coupon-id", "", "Optional. Coupon ID") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for creation to finish") + + command.SetFlagValues(cmd, "db-type", dbVersionList...) + command.SetFlagValues(cmd, "storage-class", dbStorageClassList...) + command.SetFlagValues(cmd, "spec-class", dbSpecClassList...) + command.SetFlagValues(cmd, "charge-type", "Month", "Dynamic", "Year") + command.SetFlagValues(cmd, "machine-type", dbMachineTypeList...) + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, common.GetProjectId(), common.GetRegion()) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, vpcID, common.GetProjectId(), common.GetRegion()) + }) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("db-type") + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("subnet-id") + + // Custom usage, highlight required flags + requiredFlags := []string{"name", "password", "db-type", "vpc-id", "subnet-id"} + cmd.SetUsageFunc(func(c *cobra.Command) error { + w := c.OutOrStderr() + fmt.Fprintln(w, "Usage:") + fmt.Fprintf(w, " %s [flags]\n\n", c.CommandPath()) + fmt.Fprintln(w, "★ Required flags (must be provided):") + for _, name := range requiredFlags { + f := c.Flags().Lookup(name) + if f != nil { + fmt.Fprintf(w, " --%-20s %s\n", f.Name, f.Usage) + } + } + fmt.Fprintln(w, "\nOptional flags:") + c.Flags().VisitAll(func(f *pflag.Flag) { + for _, req := range requiredFlags { + if f.Name == req { + return + } + } + defVal := "" + if f.DefValue != "" && f.DefValue != "[]" { + defVal = fmt.Sprintf(" (default %s)", f.DefValue) + } + fmt.Fprintf(w, " --%-20s %s%s\n", f.Name, f.Usage, defVal) + }) + return nil + }) + + return cmd +} diff --git a/products/sqlserver/internal/sqlserver/db.go b/products/sqlserver/internal/sqlserver/db.go new file mode 100644 index 0000000000..f2cf1d2e8a --- /dev/null +++ b/products/sqlserver/internal/sqlserver/db.go @@ -0,0 +1,26 @@ +package sqlserver + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSQLServerDB builds the "sqlserver db" subcommand group for instance lifecycle operations. +func newSQLServerDB(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "db", + Short: "Manage SQL Server instances", + Long: "Manage SQL Server instances", + } + + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + // cmd.AddCommand(newCreateAlwaysOn(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newStart(ctx)) + cmd.AddCommand(newStop(ctx)) + cmd.AddCommand(newRestart(ctx)) + + return cmd +} diff --git a/products/sqlserver/internal/sqlserver/delete.go b/products/sqlserver/internal/sqlserver/delete.go new file mode 100644 index 0000000000..0612c46f94 --- /dev/null +++ b/products/sqlserver/internal/sqlserver/delete.go @@ -0,0 +1,77 @@ +package sqlserver + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete returns the "delete" command for SQL Server instances. +func newDelete(ctx *cli.Context) *cobra.Command { + var idNames []string + var yes bool + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDeleteUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete SQL Server instances by udb-id", + Long: "Delete SQL Server instances by udb-id", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the udb(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + any, err := describeUdbByID(ctx)(id, nil) + if err != nil { + ctx.HandleError(err) + continue + } + req.DBId = &id + ins, ok := any.(*udb.UDBInstanceSet) + if ok && ins.State == UDB_RUNNING { + stopReq := client.NewStopUDBInstanceRequest() + stopReq.ProjectId = req.ProjectId + stopReq.Region = req.Region + stopReq.Zone = req.Zone + stopReq.DBId = req.DBId + stopUdbIns(ctx, stopReq, false, w) + } + _, err = client.DeleteUDBInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "udb[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to delete") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.") + + cmd.MarkFlagRequired("udb-id") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "", *req.ProjectId, *req.Region, *req.Zone) + }) + return cmd +} diff --git a/products/sqlserver/internal/sqlserver/list.go b/products/sqlserver/internal/sqlserver/list.go new file mode 100644 index 0000000000..07d7f579d3 --- /dev/null +++ b/products/sqlserver/internal/sqlserver/list.go @@ -0,0 +1,86 @@ +package sqlserver + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList returns the "list" command for SQL Server instances. +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List SQL Server instances", + Long: "List SQL Server instances", + Run: func(c *cobra.Command, args []string) { + if *req.DBId != "" { + *req.DBId = ctx.PickResourceID(*req.DBId) + } + resp, err := client.DescribeUDBInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []UDBSQLServerRow{} + for _, ins := range resp.DataSet { + row := UDBSQLServerRow{} + row.Name = ins.Name + row.Zone = ins.Zone + row.Role = ins.Role + row.ResourceID = ins.DBId + row.Group = ins.Tag + row.VPC = ins.VPCId + row.Subnet = ins.SubnetId + row.IP = ins.VirtualIP + row.Mode = ins.InstanceMode + row.DiskType = ins.InstanceType + row.Status = ins.State + row.Config = fmt.Sprintf("%s|%dG|%dG", ins.DBTypeId, ins.MemoryLimit/1000, ins.DiskSpace) + list = append(list, row) + for _, slave := range ins.DataSet { + row := UDBSQLServerRow{} + row.Name = slave.Name + row.Zone = slave.Zone + row.Role = fmt.Sprintf("⮑ %s", slave.Role) + row.ResourceID = slave.DBId + row.Group = slave.Tag + row.VPC = slave.VPCId + row.Subnet = slave.SubnetId + row.IP = slave.VirtualIP + row.Mode = slave.InstanceMode + row.DiskType = slave.InstanceType + row.Config = fmt.Sprintf("%s|%dG|%dG", slave.DBTypeId, slave.MemoryLimit/1000, slave.DiskSpace) + row.Status = slave.State + list = append(list, row) + } + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.DBId = flags.String("udb-id", "", "Optional. List the specified SQL Server instance") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindLimit(cmd, req) + ctx.BindOffset(cmd, req) + req.IncludeSlaves = flags.Bool("include-slaves", false, "Optional. When specifying the udb-id, whether to display its slaves together. Accept values:true, false") + req.ClassType = sdk.String("sqlserver") + + command.SetFlagValues(cmd, "include-slaves", "true", "false") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "sqlserver", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/sqlserver/internal/sqlserver/poll.go b/products/sqlserver/internal/sqlserver/poll.go new file mode 100644 index 0000000000..0866f52bb2 --- /dev/null +++ b/products/sqlserver/internal/sqlserver/poll.go @@ -0,0 +1,52 @@ +package sqlserver + +import ( + "fmt" + "io" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// stopUdbIns stops the instance and narrates progress to out (the caller passes +// ctx.ProgressWriter(): stdout in table mode, stderr in json/yaml). Returns the +// stop error so callers can decide whether to record a structured result. +func stopUdbIns(ctx *cli.Context, req *udb.StopUDBInstanceRequest, async bool, out io.Writer) error { + client := cli.NewServiceClient(ctx, udb.NewClient) + _, err := client.StopUDBInstance(req) + if err != nil { + ctx.HandleError(err) + return err + } + text := fmt.Sprintf("udb[%s] is stopping", *req.DBId) + if async { + fmt.Fprintln(out, text) + } else { + ctx.PollerTo(out, describeUdbByID(ctx)).Spoll(*req.DBId, text, []string{UDB_SHUTOFF, UDB_FAIL}) + } + return nil +} + +// describeUdbByID returns the poller's describe func, closing over ctx so it +// can build an authed udb client. +func describeUdbByID(ctx *cli.Context) func(udbID string, commonBase *request.CommonBase) (interface{}, error) { + return func(udbID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewDescribeUDBInstanceRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.DBId = sdk.String(udbID) + resp, err := client.DescribeUDBInstance(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) < 1 { + return nil, fmt.Errorf("udb[%s] may not exist", udbID) + } + return &resp.DataSet[0], nil + } +} diff --git a/products/sqlserver/internal/sqlserver/restart.go b/products/sqlserver/internal/sqlserver/restart.go new file mode 100644 index 0000000000..a36923c932 --- /dev/null +++ b/products/sqlserver/internal/sqlserver/restart.go @@ -0,0 +1,61 @@ +package sqlserver + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestart returns the "restart" command for SQL Server instances. +func newRestart(ctx *cli.Context) *cobra.Command { + var async bool + var idNames []string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewRestartUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart SQL Server instances by udb-id", + Long: "Restart SQL Server instances by udb-id", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.DBId = &id + _, err := client.RestartUDBInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + if async { + fmt.Fprintf(w, "udb[%s] is restarting\n", idname) + } else { + text := fmt.Sprintf("udb[%s] is restarting", idname) + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(*req.DBId, text, []string{UDB_RUNNING, UDB_FAIL}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "restart", Status: "Restarting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to restart") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + + cmd.MarkFlagRequired("udb-id") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, nil, "", *req.ProjectId, *req.Region, *req.Zone) + }) + return cmd +} diff --git a/products/sqlserver/internal/sqlserver/rows.go b/products/sqlserver/internal/sqlserver/rows.go new file mode 100644 index 0000000000..8c0010d7a8 --- /dev/null +++ b/products/sqlserver/internal/sqlserver/rows.go @@ -0,0 +1,26 @@ +package sqlserver + +// MachineTypeRow 计算规格表格行 +type MachineTypeRow struct { + ID string + Description string + Cpu int + Memory int + Group string +} + +// UDBSQLServerRow 表格行 +type UDBSQLServerRow struct { + Name string + ResourceID string + Role string + Status string + Config string + Mode string + DiskType string + IP string + Group string + Zone string + VPC string + Subnet string +} diff --git a/products/sqlserver/internal/sqlserver/start.go b/products/sqlserver/internal/sqlserver/start.go new file mode 100644 index 0000000000..47118abe4d --- /dev/null +++ b/products/sqlserver/internal/sqlserver/start.go @@ -0,0 +1,62 @@ +package sqlserver + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStart returns the "start" command for SQL Server instances. +func newStart(ctx *cli.Context) *cobra.Command { + var async bool + var idNames []string + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewStartUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "start", + Short: "Start SQL Server instances by udb-id", + Long: "Start SQL Server instances by udb-id", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.DBId = &id + _, err := client.StartUDBInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + if async { + fmt.Fprintf(w, "udb[%s] is starting\n", idname) + } else { + text := fmt.Sprintf("udb[%s] is starting", idname) + ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(*req.DBId, text, []string{UDB_RUNNING, UDB_FAIL}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "start", Status: "Starting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to start") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + + cmd.MarkFlagRequired("udb-id") + + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, []string{UDB_SHUTOFF}, "", *req.ProjectId, *req.Region, *req.Zone) + }) + return cmd +} diff --git a/products/sqlserver/internal/sqlserver/status.go b/products/sqlserver/internal/sqlserver/status.go new file mode 100644 index 0000000000..d29a669dbb --- /dev/null +++ b/products/sqlserver/internal/sqlserver/status.go @@ -0,0 +1,11 @@ +package sqlserver + +// UDB-domain state constants, product-owned copies. +const ( + UDB_FAIL = "Fail" + UDB_RUNNING = "Running" + UDB_SHUTOFF = "Shutoff" + UDB_RECOVER_FAIL = "Recover fail" + UDB_UPGRADE_FAIL = "UpgradeFail" + UDB_TOBE_SWITCH = "WaitForSwitch" +) diff --git a/products/sqlserver/internal/sqlserver/stop.go b/products/sqlserver/internal/sqlserver/stop.go new file mode 100644 index 0000000000..bd7071c3bd --- /dev/null +++ b/products/sqlserver/internal/sqlserver/stop.go @@ -0,0 +1,56 @@ +package sqlserver + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/udb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStop returns the "stop" command for SQL Server instances. +func newStop(ctx *cli.Context) *cobra.Command { + var idNames []string + var async bool + client := cli.NewServiceClient(ctx, udb.NewClient) + req := client.NewStopUDBInstanceRequest() + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop SQL Server instances by udb-id", + Long: "Stop SQL Server instances by udb-id", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.DBId = sdk.String(id) + if err := stopUdbIns(ctx, req, async, w); err != nil { + continue + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "stop", Status: "Stopping"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to stop") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + req.ForceToKill = flags.Bool("force", false, "Optional. Stop UDB instances by force or not") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + + cmd.MarkFlagRequired("udb-id") + + command.SetFlagValues(cmd, "force", "true", "false") + command.SetCompletion(cmd, "udb-id", func() []string { + return getUDBIDList(ctx, []string{UDB_RUNNING}, "", *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} diff --git a/products/sqlserver/product.go b/products/sqlserver/product.go new file mode 100644 index 0000000000..c782dbafa8 --- /dev/null +++ b/products/sqlserver/product.go @@ -0,0 +1,20 @@ +package sqlserver + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalsqlserver "github.com/ucloud/ucloud-cli/products/sqlserver/internal/sqlserver" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "sqlserver", Commands: []string{"sqlserver"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalsqlserver.NewCommand(ctx)} +} diff --git a/products/sqlserver/product.yaml b/products/sqlserver/product.yaml new file mode 100644 index 0000000000..1e313e67f1 --- /dev/null +++ b/products/sqlserver/product.yaml @@ -0,0 +1,6 @@ +name: sqlserver +owners: + - xiaolaohu-hi +commands: + - sqlserver +enabled: true diff --git a/products/sqlserver/testdata/cmdtree.golden b/products/sqlserver/testdata/cmdtree.golden new file mode 100644 index 0000000000..37684005de --- /dev/null +++ b/products/sqlserver/testdata/cmdtree.golden @@ -0,0 +1,60 @@ +ucloud sqlserver use=sqlserver short=Manipulate SQL Server on UCloud platform +ucloud sqlserver db use=db short=Manage SQL Server instances +ucloud sqlserver db create use=create short=Create SQL Server instance (Normal/single-node mode) on UCloud platform + flag=alarm-template-id short= default= required= + flag=async short= default=false required= + flag=backup-count short= default=7 required= + flag=backup-duration short= default=24 required= + flag=backup-time short= default=1 required= + flag=charge-type short= default=Month required= + flag=coupon-id short= default= required= + flag=cpu short= default=2 required= + flag=db-type short= default= required=true + flag=disk-size-gb short= default=50 required= + flag=label short= default=[] required= + flag=memory short= default=4000 required= + flag=name short= default= required=true + flag=password short= default= required=true + flag=port short= default=1433 required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=spec-class short= default=O required= + flag=storage-class short= default=CLOUD_RSSD required= + flag=subnet-id short= default= required= + flag=tag short= default= required= + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud sqlserver db delete use=delete short=Delete SQL Server instances by udb-id + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud sqlserver db list use=list short=List SQL Server instances + flag=include-slaves short= default=false required= + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default= required= + flag=zone short= default= required= +ucloud sqlserver db restart use=restart short=Restart SQL Server instances by udb-id + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default=[] required=true + flag=zone short= default= required= +ucloud sqlserver db start use=start short=Start SQL Server instances by udb-id + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default=[] required=true + flag=zone short= default= required= +ucloud sqlserver db stop use=stop short=Stop SQL Server instances by udb-id + flag=async short=a default=false required= + flag=force short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udb-id short= default=[] required=true + flag=zone short= default= required= diff --git a/products/sqlserver/testdata/completion.golden b/products/sqlserver/testdata/completion.golden new file mode 100644 index 0000000000..3d1ea4d8dc --- /dev/null +++ b/products/sqlserver/testdata/completion.golden @@ -0,0 +1,32 @@ +ucloud sqlserver db create charge-type static Dynamic,Month,Year +ucloud sqlserver db create cpu static 16,2,32,4,64,8 +ucloud sqlserver db create db-type static sqlserver-2017,sqlserver-2019,sqlserver-2022 +ucloud sqlserver db create project-id dynamic +ucloud sqlserver db create region dynamic +ucloud sqlserver db create spec-class static O +ucloud sqlserver db create storage-class static CLOUD_RSSD +ucloud sqlserver db create subnet-id dynamic +ucloud sqlserver db create vpc-id dynamic +ucloud sqlserver db create zone dynamic +ucloud sqlserver db delete project-id dynamic +ucloud sqlserver db delete region dynamic +ucloud sqlserver db delete udb-id dynamic +ucloud sqlserver db delete zone dynamic +ucloud sqlserver db list include-slaves static false,true +ucloud sqlserver db list project-id dynamic +ucloud sqlserver db list region dynamic +ucloud sqlserver db list udb-id dynamic +ucloud sqlserver db list zone dynamic +ucloud sqlserver db restart project-id dynamic +ucloud sqlserver db restart region dynamic +ucloud sqlserver db restart udb-id dynamic +ucloud sqlserver db restart zone dynamic +ucloud sqlserver db start project-id dynamic +ucloud sqlserver db start region dynamic +ucloud sqlserver db start udb-id dynamic +ucloud sqlserver db start zone dynamic +ucloud sqlserver db stop force static false,true +ucloud sqlserver db stop project-id dynamic +ucloud sqlserver db stop region dynamic +ucloud sqlserver db stop udb-id dynamic +ucloud sqlserver db stop zone dynamic diff --git a/products/subnet/internal/subnet/cmd.go b/products/subnet/internal/subnet/cmd.go new file mode 100644 index 0000000000..b143c66aeb --- /dev/null +++ b/products/subnet/internal/subnet/cmd.go @@ -0,0 +1,22 @@ +package subnet + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand returns the ucloud subnet command tree. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "subnet", + Short: "List, create and delete subnet", + Long: "List, create and delete subnet", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newListResource(ctx)) + return cmd +} diff --git a/products/subnet/internal/subnet/completion.go b/products/subnet/internal/subnet/completion.go new file mode 100644 index 0000000000..5b255c1e91 --- /dev/null +++ b/products/subnet/internal/subnet/completion.go @@ -0,0 +1,71 @@ +package subnet + +import ( + "fmt" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func getAllVPCIns(ctx *cli.Context, project, region string) ([]vpcsdk.VPCInfo, error) { + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + vpcInsList, err := getAllVPCIns(ctx, project, region) + list := []string{} + if err != nil { + return nil + } + for _, vpc := range vpcInsList { + list = append(list, fmt.Sprintf("%s/%s", vpc.VPCId, vpc.Name)) + } + return list +} + +func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpcsdk.SubnetInfo, error) { + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + if vpcID != "" { + req.VPCId = sdk.String(cli.PickResourceID(vpcID)) + } + subnets := []vpcsdk.SubnetInfo{} + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.DescribeSubnet(req) + if err != nil { + ctx.HandleError(err) + return nil, err + } + subnets = append(subnets, resp.DataSet...) + if limit+offset >= resp.TotalCount { + break + } + } + return subnets, nil +} + +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + subnets, err := getAllSubnets(ctx, vpcID, project, region) + if err != nil { + return nil + } + list := []string{} + for _, s := range subnets { + list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return list +} diff --git a/products/subnet/internal/subnet/create.go b/products/subnet/internal/subnet/create.go new file mode 100644 index 0000000000..c20e23d83a --- /dev/null +++ b/products/subnet/internal/subnet/create.go @@ -0,0 +1,67 @@ +package subnet + +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/spf13/cobra" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate returns ucloud subnet create. +func newCreate(ctx *cli.Context) *cobra.Command { + var segment *net.IPNet + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewCreateSubnetRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create subnet of vpc network", + Long: "Create subnet of vpc network", + Example: "ucloud subnet create --vpc-id uvnet-vpcxid --name testName --segment 192.168.2.0/24", + Run: func(cmd *cobra.Command, args []string) { + ipMaskStrs := strings.SplitN(segment.String(), "/", 2) + req.Subnet = sdk.String(ipMaskStrs[0]) + mask, err := strconv.Atoi(ipMaskStrs[1]) + if err != nil { + ctx.HandleError(err) + return + } + req.Netmask = sdk.Int(mask) + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + resp, err := client.CreateSubnet(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "subnet[%s] created\n", resp.SubnetId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.SubnetId, Action: "create", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.VPCId = flags.String("vpc-id", "", "Required. Assign the VPC network of the subnet") + segment = flags.IPNet("segment", net.IPNet{}, "Required. Segment of subnet. For example '192.168.0.0/24'") + req.SubnetName = flags.String("name", "Subnet", "Optional. Name of subnet to create") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.Tag = flags.String("group", "", "Optional. Business group") + req.Remark = flags.String("remark", "", "Optional. Remark of subnet to create") + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("segment") + + return cmd +} diff --git a/products/subnet/internal/subnet/delete.go b/products/subnet/internal/subnet/delete.go new file mode 100644 index 0000000000..bb904d61f1 --- /dev/null +++ b/products/subnet/internal/subnet/delete.go @@ -0,0 +1,54 @@ +package subnet + +import ( + "fmt" + + "github.com/spf13/cobra" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete returns ucloud subnet delete. +func newDelete(ctx *cli.Context) *cobra.Command { + idNames := []string{} + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDeleteSubnetRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete subnet", + Long: "Delete subnet", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, id := range idNames { + resourceID := ctx.PickResourceID(id) + req.SubnetId = sdk.String(resourceID) + _, err := client.DeleteSubnet(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "subnet[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: resourceID, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "subnet-id", nil, "Required. Resource ID of subent") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("subnet-id") + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, "", *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/subnet/internal/subnet/list.go b/products/subnet/internal/subnet/list.go new file mode 100644 index 0000000000..8710e24cd5 --- /dev/null +++ b/products/subnet/internal/subnet/list.go @@ -0,0 +1,55 @@ +package subnet + +import ( + "fmt" + + "github.com/spf13/cobra" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList returns ucloud subnet list. +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDescribeSubnetRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List subnet", + Long: `List subnet`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.DescribeSubnet(req) + if err != nil { + ctx.HandleError(err) + return + } + list := make([]Row, 0) + for _, sn := range resp.DataSet { + row := Row{} + row.SubnetName = sn.SubnetName + row.ResourceID = sn.SubnetId + row.Group = sn.Tag + row.AffiliatedVPC = fmt.Sprintf("%s/%s", sn.VPCId, sn.VPCName) + row.NetworkSegment = fmt.Sprintf("%s/%s", sn.Subnet, sn.Netmask) + row.CreationTime = common.FormatDate(sn.CreateTime) + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + flags.StringSliceVar(&req.SubnetIds, "subnet-id", []string{}, "Optional. Multiple values separated by commas") + req.VPCId = flags.String("vpc-id", "", "Optional. Resource ID of VPC") + req.Tag = flags.String("group", "", "Optional. Group") + req.Offset = flags.Int("offset", 0, "Optional. Offset") + req.Limit = flags.Int("limit", 50, "Optional. Limit") + + return cmd +} diff --git a/products/subnet/internal/subnet/list_resource.go b/products/subnet/internal/subnet/list_resource.go new file mode 100644 index 0000000000..9392a0b100 --- /dev/null +++ b/products/subnet/internal/subnet/list_resource.go @@ -0,0 +1,56 @@ +package subnet + +import ( + "github.com/spf13/cobra" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListResource returns ucloud subnet list-resource. +func newListResource(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDescribeSubnetResourceRequest() + cmd := &cobra.Command{ + Use: "list-resource", + Short: "List resources belong to subnet", + Long: "List resources belong to subnet", + Run: func(c *cobra.Command, args []string) { + req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId)) + resp, err := client.DescribeSubnetResource(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []ResourceRow{} + for _, r := range resp.DataSet { + row := ResourceRow{ + ResourceName: r.Name, + ResourceID: r.ResourceId, + ResourceType: r.ResourceType, + PrivateIP: r.IP, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.SubnetId = flags.String("subnet-id", "", "Required. Resource ID of subnet which resources to list belong to") + req.ResourceType = flags.String("resource-type", "", "Optional. Resource type of resources to list. Accept values:'uhost','phost','ulb','uhadoophost','ufortresshost','unatgw','ukafka','umem','docker','udb','udw' and 'vip'") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + ctx.BindLimit(cmd, req) + ctx.BindOffset(cmd, req) + cmd.MarkFlagRequired("subnet-id") + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, "", *req.ProjectId, *req.Region) + }) + command.SetFlagValues(cmd, "resource-type", "uhost", "phost", "ulb", "uhadoophost", "ufortresshost", "unatgw", "ukafka", "umem", "docker", "udb", "udw", "vip") + + return cmd +} diff --git a/products/subnet/internal/subnet/rows.go b/products/subnet/internal/subnet/rows.go new file mode 100644 index 0000000000..e4bf309bc2 --- /dev/null +++ b/products/subnet/internal/subnet/rows.go @@ -0,0 +1,17 @@ +package subnet + +type Row struct { + SubnetName string + ResourceID string + Group string + AffiliatedVPC string + NetworkSegment string + CreationTime string +} + +type ResourceRow struct { + ResourceName string + ResourceID string + ResourceType string + PrivateIP string +} diff --git a/products/subnet/product.go b/products/subnet/product.go new file mode 100644 index 0000000000..44606e99ee --- /dev/null +++ b/products/subnet/product.go @@ -0,0 +1,20 @@ +package subnet + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalsubnet "github.com/ucloud/ucloud-cli/products/subnet/internal/subnet" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "subnet", Commands: []string{"subnet"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalsubnet.NewCommand(ctx)} +} diff --git a/products/subnet/product.yaml b/products/subnet/product.yaml new file mode 100644 index 0000000000..fc1da014cc --- /dev/null +++ b/products/subnet/product.yaml @@ -0,0 +1,6 @@ +name: subnet +owners: + - Episkey-G +commands: + - subnet +enabled: true diff --git a/products/subnet/testdata/cmdtree.golden b/products/subnet/testdata/cmdtree.golden new file mode 100644 index 0000000000..c16587d310 --- /dev/null +++ b/products/subnet/testdata/cmdtree.golden @@ -0,0 +1,28 @@ +ucloud subnet use=subnet short=List, create and delete subnet +ucloud subnet create use=create short=Create subnet of vpc network + flag=group short= default= required= + flag=name short= default=Subnet required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=segment short= default= required=true + flag=vpc-id short= default= required=true +ucloud subnet delete use=delete short=Delete subnet + flag=project-id short= default= required= + flag=region short= default= required= + flag=subnet-id short= default=[] required=true +ucloud subnet list use=list short=List subnet + flag=group short= default= required= + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=subnet-id short= default=[] required= + flag=vpc-id short= default= required= +ucloud subnet list-resource use=list-resource short=List resources belong to subnet + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=resource-type short= default= required= + flag=subnet-id short= default= required=true diff --git a/products/subnet/testdata/completion.golden b/products/subnet/testdata/completion.golden new file mode 100644 index 0000000000..7da48b41b2 --- /dev/null +++ b/products/subnet/testdata/completion.golden @@ -0,0 +1,12 @@ +ucloud subnet create project-id dynamic +ucloud subnet create region dynamic +ucloud subnet create vpc-id dynamic +ucloud subnet delete project-id dynamic +ucloud subnet delete region dynamic +ucloud subnet delete subnet-id dynamic +ucloud subnet list project-id dynamic +ucloud subnet list region dynamic +ucloud subnet list-resource project-id dynamic +ucloud subnet list-resource region dynamic +ucloud subnet list-resource resource-type static docker,phost,udb,udw,ufortresshost,uhadoophost,uhost,ukafka,ulb,umem,unatgw,vip +ucloud subnet list-resource subnet-id dynamic diff --git a/products/uclickhouse/internal/clickhouse/api.go b/products/uclickhouse/internal/clickhouse/api.go new file mode 100644 index 0000000000..1fff730428 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/api.go @@ -0,0 +1,47 @@ +package clickhouse + +import ( + "fmt" + "strings" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + "github.com/ucloud/ucloud-sdk-go/ucloud/response" +) + +type opResponse struct { + response.CommonBase +} + +type createUClickhouseClusterResponse struct { + response.CommonBase + Data createUClickhouseClusterResponseData +} + +type createUClickhouseClusterResponseData struct { + ClusterId string +} + +func invokeUClickhouseAction(client *uclickhousesdk.UClickhouseClient, action string, req request.Common, resp response.Common) error { + return enrichUClickhouseError(action, client.Client.InvokeAction(action, req, resp)) +} + +func enrichUClickhouseError(action string, err error) error { + if err == nil { + return nil + } + uErr, ok := err.(uerr.Error) + if !ok || uErr.Code() == 0 { + return err + } + message := strings.TrimSpace(uErr.Message()) + if message == "" { + message = "" + } + detail := fmt.Sprintf("UClickhouse API %s failed. RetCode:%d. Message:%s", action, uErr.Code(), message) + if strings.TrimSpace(uErr.Message()) == "" { + detail += "\nThe service did not return an error message. Check region/project and create-option compatibility, for example: ucloud uclickhouse create-option --region . Rerun with --debug if request details are needed." + } + return fmt.Errorf("%s", detail) +} diff --git a/products/uclickhouse/internal/clickhouse/args.go b/products/uclickhouse/internal/clickhouse/args.go new file mode 100644 index 0000000000..3bc568e7c3 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/args.go @@ -0,0 +1,69 @@ +package clickhouse + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +func noArgs(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + if len(args) == 1 && (args[0] == "true" || args[0] == "false") { + return fmt.Errorf("unexpected argument %q for %s; boolean flags must use --flag=%s", args[0], cmd.CommandPath(), args[0]) + } + return fmt.Errorf("unexpected argument(s) for %s: %s", cmd.CommandPath(), strings.Join(args, " ")) +} + +func noFlagLikeValues(cmd *cobra.Command, names ...string) error { + for _, name := range names { + flag := cmd.Flags().Lookup(name) + if flag == nil || !flag.Changed { + continue + } + value := flag.Value.String() + if strings.HasPrefix(value, "-") { + return fmt.Errorf("flag --%s requires a value; got %q, which looks like another flag", name, value) + } + } + return nil +} + +func requireFlagsWhenBool(cmd *cobra.Command, conditionName string, conditionValue bool, requiredNames ...string) error { + value, err := cmd.Flags().GetBool(conditionName) + if err != nil || value != conditionValue { + return err + } + return requireNonEmptyFlags(cmd, fmt.Sprintf("--%s=%t", conditionName, conditionValue), requiredNames...) +} + +func requireFlagsWhenString(cmd *cobra.Command, conditionName, conditionValue string, requiredNames ...string) error { + value, err := cmd.Flags().GetString(conditionName) + if err != nil || !strings.EqualFold(value, conditionValue) { + return err + } + return requireNonEmptyFlags(cmd, fmt.Sprintf("--%s=%s", conditionName, conditionValue), requiredNames...) +} + +func requireNonEmptyFlags(cmd *cobra.Command, condition string, names ...string) error { + missing := []string{} + for _, name := range names { + flag := cmd.Flags().Lookup(name) + if flag == nil { + continue + } + if isEmptyFlagValue(flag.Value.String()) { + missing = append(missing, "--"+name) + } + } + if len(missing) == 0 { + return nil + } + return fmt.Errorf("missing required flag(s) when %s: %s", condition, strings.Join(missing, ", ")) +} + +func isEmptyFlagValue(value string) bool { + return value == "" || value == "[]" || value == "" +} diff --git a/products/uclickhouse/internal/clickhouse/cmd.go b/products/uclickhouse/internal/clickhouse/cmd.go new file mode 100644 index 0000000000..feb583e014 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/cmd.go @@ -0,0 +1,26 @@ +package clickhouse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `uclickhouse` root command and mounts the subcommands. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "uclickhouse", + Short: "Manage UClickhouse clusters", + Long: "Manage UClickhouse clusters", + Args: noArgs, + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newExpand(ctx)) + cmd.AddCommand(newResize(ctx)) + cmd.AddCommand(newRestart(ctx)) + cmd.AddCommand(newCreateOption(ctx)) + return cmd +} diff --git a/products/uclickhouse/internal/clickhouse/completion.go b/products/uclickhouse/internal/clickhouse/completion.go new file mode 100644 index 0000000000..34b5d8accb --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/completion.go @@ -0,0 +1,42 @@ +package clickhouse + +import ( + "strings" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// getClusterList returns "ClusterId/Name" completion candidates for clickhouse-id flags. +func getClusterList(ctx *cli.Context, statuses []string, project, region string) []string { + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewListUClickhouseClusterRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + resp, err := listUClickhouseCluster(client, req) + if err != nil { + return nil + } + list := []string{} + for _, cluster := range resp.Data.Clusters { + if cluster.ClusterId == "" { + continue + } + if statuses != nil { + matched := false + for _, status := range statuses { + if cluster.Status == status { + matched = true + break + } + } + if !matched { + continue + } + } + list = append(list, cluster.ClusterId+"/"+strings.Replace(cluster.ClusterName, " ", "-", -1)) + } + return list +} diff --git a/products/uclickhouse/internal/clickhouse/create.go b/products/uclickhouse/internal/clickhouse/create.go new file mode 100644 index 0000000000..f95d353bc5 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/create.go @@ -0,0 +1,146 @@ +package clickhouse + +import ( + "encoding/base64" + "fmt" + "strings" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud clickhouse create +func newCreate(ctx *cli.Context) *cobra.Command { + var adminPassword *string + var async *bool + var labels []string + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewCreateUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create UClickhouse cluster", + Long: "Create UClickhouse cluster", + Args: validateCreateArgs, + Run: func(cmd *cobra.Command, args []string) { + parsedLabels, err := parseLabels(labels) + if err != nil { + ctx.HandleError(err) + return + } + req.Labels = parsedLabels + req.AdminPassword = sdk.String(base64.StdEncoding.EncodeToString([]byte(*adminPassword))) + + w := ctx.ProgressWriter() + resp, err := createUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + return + } + id := resp.Data.ClusterId + text := fmt.Sprintf("clickhouse[%s] is creating", id) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeClusterByID(ctx)).Spoll(id, text, []string{STATUS_RUNNING, STATUS_CREATE_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "create", Status: "Creating"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterName = flags.String("name", "clickhouse", "Optional. Cluster name, default clickhouse") + req.ClickhouseMachineTypeId = flags.String("clickhouse-machine-type-id", "", "Required. ClickHouse machine type ID") + req.DataDiskType = flags.String("data-disk-type", "", "Required. Data disk type") + req.ClickhouseVersion = flags.String("clickhouse-version", "", "Required. ClickHouse version") + adminPassword = flags.String("admin-password", "", "Required. Admin password; CLI base64-encodes it before sending") + req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID") + req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID") + req.ShardCount = flags.Int("shard-count", 1, "Optional. Shard count, default 1") + req.ReplicateCount = flags.Int("replicate-count", 2, "Optional. Replicate count, 1 or 2, default 2") + req.DataDiskSize = flags.Int("data-disk-size-gb", 100, "Optional. Data disk size in GB, default 100") + req.ChargeType = flags.String("charge-type", "Month", "Optional. 'Year', 'Month', or 'Dynamic', default Month") + req.Quantity = flags.Int("quantity", 1, "Optional. Purchase duration, default 1") + req.BackupId = flags.String("backup-id", "", "Optional. Backup task ID to restore from") + req.IsZookeeperHA = flags.Bool("zookeeper-ha", true, "Optional. Enable Zookeeper HA, default true") + req.ZookeeperMachineTypeId = flags.String("zookeeper-machine-type-id", "", "Required when --zookeeper-ha=true. Zookeeper machine type ID") + req.ZookeeperDataDiskType = flags.String("zookeeper-data-disk-type", "", "Required when --zookeeper-ha=true. Zookeeper data disk type") + req.ZookeeperDataDiskSize = flags.String("zookeeper-data-disk-size-gb", "", "Required when --zookeeper-ha=true. Zookeeper data disk size in GB") + req.IsSecGroup = flags.String("sec-group", "false", "Optional. Enable security group, true or false") + req.SecGroupIds = flags.String("sec-group-ids", "", "Optional. Security group IDs") + req.IsMultiZone = flags.String("multi-zone", "false", "Optional. Enable multi-zone, true or false") + flags.StringSliceVar(&req.MultiZones, "multi-zone-name", nil, "Optional. Availability zone name for multi-zone clusters") + flags.StringSliceVar(&labels, "label", nil, "Optional. Resource label, format: key=value, repeatable") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + async = flags.Bool("async", false, "Optional. Do not wait for creation to finish") + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic") + command.SetFlagValues(cmd, "sec-group", "false", "true") + command.SetFlagValues(cmd, "multi-zone", "false", "true") + + cmd.MarkFlagRequired("clickhouse-machine-type-id") + cmd.MarkFlagRequired("data-disk-type") + cmd.MarkFlagRequired("clickhouse-version") + cmd.MarkFlagRequired("admin-password") + + return cmd +} + +func createUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.CreateUClickhouseClusterRequest) (*createUClickhouseClusterResponse, error) { + var resp createUClickhouseClusterResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "CreateUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} + +func parseLabels(labels []string) ([]uclickhousesdk.CreateUClickhouseClusterParamLabels, error) { + parsed := []uclickhousesdk.CreateUClickhouseClusterParamLabels{} + for _, label := range labels { + key, value, ok := strings.Cut(label, "=") + if !ok || key == "" { + return nil, fmt.Errorf("invalid label %q, want key=value", label) + } + parsed = append(parsed, uclickhousesdk.CreateUClickhouseClusterParamLabels{ + Key: sdk.String(key), + Value: sdk.String(value), + }) + } + return parsed, nil +} + +func validateCreateArgs(cmd *cobra.Command, args []string) error { + if err := noFlagLikeValues( + cmd, + "clickhouse-machine-type-id", + "data-disk-type", + "clickhouse-version", + "zookeeper-machine-type-id", + "zookeeper-data-disk-type", + "zookeeper-data-disk-size-gb", + "sec-group-ids", + ); err != nil { + return err + } + if err := noArgs(cmd, args); err != nil { + return err + } + if err := requireFlagsWhenBool(cmd, "zookeeper-ha", true, + "zookeeper-machine-type-id", + "zookeeper-data-disk-type", + "zookeeper-data-disk-size-gb", + ); err != nil { + return err + } + if err := requireFlagsWhenString(cmd, "sec-group", "true", "sec-group-ids"); err != nil { + return err + } + if err := requireFlagsWhenString(cmd, "multi-zone", "true", "multi-zone-name"); err != nil { + return err + } + return nil +} diff --git a/products/uclickhouse/internal/clickhouse/create_option.go b/products/uclickhouse/internal/clickhouse/create_option.go new file mode 100644 index 0000000000..50d195752f --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/create_option.go @@ -0,0 +1,99 @@ +package clickhouse + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newCreateOption ucloud clickhouse create-option +func newCreateOption(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewGetUClickhouseClusterCreateOptionRequest() + cmd := &cobra.Command{ + Use: "create-option", + Short: "List available UClickhouse creation options", + Long: "List available UClickhouse creation options", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + resp, err := getUClickhouseClusterCreateOption(client, req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.PrintList(createOptionRows(resp.Data)) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + return cmd +} + +func getUClickhouseClusterCreateOption(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.GetUClickhouseClusterCreateOptionRequest) (*uclickhousesdk.GetUClickhouseClusterCreateOptionResponse, error) { + var resp uclickhousesdk.GetUClickhouseClusterCreateOptionResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "GetUClickhouseClusterCreateOption", &reqCopier, &resp) + return &resp, err +} + +func createOptionRows(data uclickhousesdk.GetCreateUClickhouseClusterOptionResponseData) []CreateOptionRow { + rows := []CreateOptionRow{} + for _, version := range data.ClickhouseVersions { + rows = append(rows, CreateOptionRow{ + OptionType: "version", + Version: version.Version, + VersionName: version.VersionName, + MaxNodeCount: fmt.Sprintf("%d", data.MaxNodeCount), + }) + } + rows = append(rows, machineTypeRows("clickhouse", data.MaxNodeCount, data.ClickhouseMachineTypes)...) + rows = append(rows, machineTypeRows("zookeeper", data.MaxNodeCount, data.ZookeeperMachineTypes)...) + return rows +} + +func machineTypeRows(nodeType string, maxNodeCount int, machineTypes []uclickhousesdk.ClickhouseMachineType) []CreateOptionRow { + rows := []CreateOptionRow{} + for _, machineType := range machineTypes { + for _, option := range machineType.ClickhouseMachineTypeOptions { + base := CreateOptionRow{ + OptionType: "machine-type", + NodeType: nodeType, + MachineTypeID: option.ClickhouseMachineTypeId, + MachineTypeName: machineType.ClickhouseMachineTypeName, + MachineType: option.MachineType, + CPU: fmt.Sprintf("%d", option.CPU), + MemoryGB: fmt.Sprintf("%d", option.Memory), + NodeCounts: joinInts(option.NodeCounts), + IsSecGroup: machineType.IsSecgroupMachineType, + MaxNodeCount: fmt.Sprintf("%d", maxNodeCount), + } + rows = append(rows, base) + for _, disk := range option.DataDisks { + diskRow := base + diskRow.OptionType = "data-disk" + diskRow.DiskType = disk.DiskType + diskRow.MinSizeGB = fmt.Sprintf("%d", disk.MinDiskSize) + diskRow.MaxSizeGB = fmt.Sprintf("%d", disk.MaxDiskSize) + diskRow.DefaultSizeGB = fmt.Sprintf("%d", disk.DefaultDataDiskSize) + diskRow.StepGB = fmt.Sprintf("%d", disk.Step) + rows = append(rows, diskRow) + } + } + } + return rows +} + +func joinInts(values []int) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, fmt.Sprintf("%d", value)) + } + return strings.Join(parts, ",") +} diff --git a/products/uclickhouse/internal/clickhouse/create_test.go b/products/uclickhouse/internal/clickhouse/create_test.go new file mode 100644 index 0000000000..159cdd0474 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/create_test.go @@ -0,0 +1,183 @@ +package clickhouse + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func TestCreateRejectsMistypedFlagValueBeforeAPI(t *testing.T) { + cmd, apiCalled, cleanup := newCreateTestCommand(t) + defer cleanup() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{ + "create", + "--clickhouse-machine-type-id", "s1-x1", + "--data-disk-type", "--data-disk-type", "CLOUD_RSSD", + "--clickhouse-version", "24.8.14.39", + "--admin-password", "4277813Aa", + "--async", + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected create to reject mistyped flag value") + } + if *apiCalled { + t.Fatal("create should reject malformed arguments before calling API") + } + if !strings.Contains(err.Error(), `flag --data-disk-type requires a value`) || !strings.Contains(err.Error(), `got "--data-disk-type"`) { + t.Fatalf("error = %q, want data-disk-type value error", err.Error()) + } +} + +func TestCreateRejectsMissingZookeeperOptionsBeforeAPI(t *testing.T) { + cmd, apiCalled, cleanup := newCreateTestCommand(t) + defer cleanup() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{ + "create", + "--clickhouse-machine-type-id", "s1-x1", + "--name", "cli-jjk", + "--data-disk-type", "CLOUD_RSSD", + "--clickhouse-version", "24.8.14.39", + "--admin-password", "4277813Aa", + "--async", + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected create to reject missing zookeeper options") + } + if *apiCalled { + t.Fatal("create should reject missing zookeeper options before calling API") + } + for _, want := range []string{"--zookeeper-machine-type-id", "--zookeeper-data-disk-type", "--zookeeper-data-disk-size-gb"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q, want mention %s", err.Error(), want) + } + } +} + +func TestCreateRejectsOtherMissingConditionalOptionsBeforeAPI(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "sec group IDs", + args: []string{"--zookeeper-ha=false", "--sec-group", "true"}, + want: "--sec-group-ids", + }, + { + name: "multi zone names", + args: []string{"--zookeeper-ha=false", "--multi-zone", "true"}, + want: "--multi-zone-name", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, apiCalled, cleanup := newCreateTestCommand(t) + defer cleanup() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + baseArgs := []string{ + "create", + "--clickhouse-machine-type-id", "s1-x1", + "--data-disk-type", "CLOUD_RSSD", + "--clickhouse-version", "24.8.14.39", + "--admin-password", "4277813Aa", + "--async", + } + cmd.SetArgs(append(baseArgs, tt.args...)) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected create to reject missing conditional options") + } + if *apiCalled { + t.Fatal("create should reject missing conditional options before calling API") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %q, want mention %s", err.Error(), tt.want) + } + }) + } +} + +func TestCreateRejectsBareBooleanArgument(t *testing.T) { + cmd, apiCalled, cleanup := newCreateTestCommand(t) + defer cleanup() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{ + "create", + "--clickhouse-machine-type-id", "s1-x1", + "--data-disk-type", "CLOUD_RSSD", + "--clickhouse-version", "24.8.14.39", + "--admin-password", "4277813Aa", + "--zookeeper-ha", "false", + "--async", + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected create to reject bare boolean argument") + } + if *apiCalled { + t.Fatal("create should reject bare boolean argument before calling API") + } + if !strings.Contains(err.Error(), "boolean flags must use --flag=false") { + t.Fatalf("error = %q, want boolean flag hint", err.Error()) + } +} + +func newCreateTestCommand(t *testing.T) (*cobra.Command, *bool, func()) { + t.Helper() + apiCalled := false + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalled = true + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"RetCode":0,"Message":"success","Data":{"ClusterId":"uck-test"}}`) + })) + + cfg := sdk.NewConfig() + cfg.BaseUrl = api.URL + cfg.Region = "cn-bj2" + cfg.ProjectId = "org-test" + cred := auth.NewCredential() + cred.PublicKey = "public" + cred.PrivateKey = "private" + + var out, errOut bytes.Buffer + ctx := cli.NewContext(cli.Deps{ + Out: &out, + Err: &errOut, + Format: cli.OutputJSON, + DefaultsProvider: func() command.Defaults { + return command.Defaults{ProjectID: "org-test", Region: "cn-bj2"} + }, + ClientConfig: func() *sdk.Config { + return &cfg + }, + BuildCredential: func() *auth.Credential { + return &cred + }, + AttachHandlers: func(sdk.ServiceClient) {}, + }) + return NewCommand(ctx), &apiCalled, api.Close +} diff --git a/products/uclickhouse/internal/clickhouse/delete.go b/products/uclickhouse/internal/clickhouse/delete.go new file mode 100644 index 0000000000..eed4b22e9a --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/delete.go @@ -0,0 +1,70 @@ +package clickhouse + +import ( + "fmt" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud clickhouse delete +func newDelete(ctx *cli.Context) *cobra.Command { + var clusterIDs *[]string + var yes *bool + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewDestroyUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete UClickhouse clusters", + Long: "Delete UClickhouse clusters", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(*yes, "Are you sure to delete UClickhouse cluster(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range *clusterIDs { + id := ctx.PickResourceID(idName) + req.ClusterId = &id + _, err := destroyUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "clickhouse[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + clusterIDs = flags.StringSlice("clickhouse-id", nil, "Required. UClickhouse cluster ID(s) to delete") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + yes = flags.BoolP("yes", "y", false, "Optional. Skip confirmation prompt") + + command.SetCompletion(cmd, "clickhouse-id", func() []string { + return getClusterList(ctx, []string{STATUS_RUNNING, STATUS_CREATE_FAILED, STATUS_RESTART_FAILED, STATUS_RESIZE_FAILED, STATUS_EXPAND_FAILED, STATUS_BACKUP_RESTORE_FAILED}, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("clickhouse-id") + return cmd +} + +func destroyUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.DestroyUClickhouseClusterRequest) (*opResponse, error) { + var resp opResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "DestroyUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} diff --git a/products/uclickhouse/internal/clickhouse/describe.go b/products/uclickhouse/internal/clickhouse/describe.go new file mode 100644 index 0000000000..fbeca1d796 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/describe.go @@ -0,0 +1,197 @@ +package clickhouse + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + "github.com/ucloud/ucloud-sdk-go/ucloud/response" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type describeUClickhouseClusterResponse struct { + response.CommonBase + Data describeUClickhouseClusterResponseData + Message string +} + +type describeUClickhouseClusterResponseData struct { + ClickhouseNodes []uclickhousesdk.ClickhouseNode + Cluster uclickhousesdk.ClickhouseCluster + Payment uclickhousePayment + ZookeeperNodes []uclickhousesdk.ZookeeperNode +} + +type uclickhousePayment struct { + ChargeType string + CreateTimestamp int + ExpireTimestamp int + OriginalPrice flexibleString + Price flexibleString + ResourceId string +} + +type flexibleString string + +func (v *flexibleString) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *v = "" + return nil + } + var s string + if err := json.Unmarshal(data, &s); err == nil { + *v = flexibleString(s) + return nil + } + var number json.Number + if err := json.Unmarshal(data, &number); err != nil { + return err + } + *v = flexibleString(number.String()) + return nil +} + +func (v flexibleString) String() string { + return string(v) +} + +// newDescribe ucloud clickhouse describe +func newDescribe(ctx *cli.Context) *cobra.Command { + var clusterID *string + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewDescribeUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe UClickhouse cluster details", + Long: "Describe UClickhouse cluster details", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*clusterID) + req.ClusterId = sdk.String(id) + resp, err := describeUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.PrintList(describeRows(resp.Data)) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + clusterID = flags.String("clickhouse-id", "", "Required. UClickhouse cluster ID to describe") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + + command.SetCompletion(cmd, "clickhouse-id", func() []string { + return getClusterList(ctx, nil, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("clickhouse-id") + return cmd +} + +func describeUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.DescribeUClickhouseClusterRequest) (*describeUClickhouseClusterResponse, error) { + var resp describeUClickhouseClusterResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "DescribeUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} + +func describeRows(data describeUClickhouseClusterResponseData) []cli.DescribeRow { + cluster := data.Cluster + rows := []cli.DescribeRow{ + {Attribute: "ClusterID", Content: cluster.ClusterId}, + {Attribute: "ClusterName", Content: cluster.ClusterName}, + {Attribute: "Status", Content: cluster.Status}, + {Attribute: "VPCId", Content: cluster.VPCId}, + {Attribute: "SubnetId", Content: cluster.SubnetId}, + {Attribute: "ClickhouseVersion", Content: cluster.ClickhouseVersion}, + {Attribute: "ZookeeperVersion", Content: cluster.ZookeeperVersion}, + {Attribute: "MachineType", Content: cluster.MachineType}, + {Attribute: "ShardCount", Content: fmt.Sprintf("%d", cluster.ShardCount)}, + {Attribute: "ReplicateCount", Content: fmt.Sprintf("%d", cluster.ReplicateCount)}, + {Attribute: "ClickhouseMachineTypeID", Content: cluster.ClickhouseMachineTypeId}, + {Attribute: "ClickhouseMachineTypeName", Content: cluster.ClickhouseMachineTypeName}, + {Attribute: "ClickhouseDataDiskType", Content: cluster.ClickhouseDataDiskType}, + {Attribute: "ClickhouseDataDiskSize", Content: fmt.Sprintf("%dGB", cluster.ClickhouseDataDiskSize)}, + {Attribute: "ClickhouseNodeCPU", Content: fmt.Sprintf("%d", cluster.ClickhouseNodeCPU)}, + {Attribute: "ClickhouseNodeMemory", Content: fmt.Sprintf("%dGB", cluster.ClickhouseNodeMemory)}, + {Attribute: "ZookeeperMachineTypeID", Content: cluster.ZookeeperMachineTypeId}, + {Attribute: "ZookeeperMachineTypeName", Content: cluster.ZookeeperMachineTypeName}, + {Attribute: "ZookeeperDataDiskType", Content: cluster.ZookeeperDataDiskType}, + {Attribute: "ZookeeperDataDiskSize", Content: fmt.Sprintf("%dGB", cluster.ZookeeperDataDiskSize)}, + {Attribute: "ZookeeperNodeCPU", Content: fmt.Sprintf("%d", cluster.ZookeeperNodeCPU)}, + {Attribute: "ZookeeperNodeMemory", Content: fmt.Sprintf("%dGB", cluster.ZookeeperNodeMemory)}, + {Attribute: "IsZookeeperHA", Content: cluster.IsZookeeperHA}, + {Attribute: "IsSecgroup", Content: cluster.IsSecgroup}, + {Attribute: "IsBackup", Content: cluster.IsBackup}, + {Attribute: "IsTieredStorage", Content: cluster.IsTieredStorage}, + {Attribute: "MultiZones", Content: strings.Join(cluster.MultiZones, ",")}, + {Attribute: "CreateTime", Content: formatUnixDate(cluster.CreateTimestamp)}, + {Attribute: "ExpireTime", Content: formatUnixDate(int(cluster.ExpireTimestamp))}, + {Attribute: "Payment.ChargeType", Content: data.Payment.ChargeType}, + {Attribute: "Payment.Price", Content: data.Payment.Price.String()}, + {Attribute: "Payment.OriginalPrice", Content: data.Payment.OriginalPrice.String()}, + } + if len(data.ClickhouseNodes) > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "--- ClickhouseNodes ---", Content: fmt.Sprintf("%d nodes", len(data.ClickhouseNodes))}) + for i, node := range data.ClickhouseNodes { + prefix := fmt.Sprintf("ClickhouseNode[%d]", i) + rows = append(rows, + cli.DescribeRow{Attribute: prefix + ".NodeID", Content: node.NodeId}, + cli.DescribeRow{Attribute: prefix + ".NodeName", Content: node.NodeName}, + cli.DescribeRow{Attribute: prefix + ".Zone", Content: node.Zone}, + cli.DescribeRow{Attribute: prefix + ".IPv4", Content: node.IPv4}, + cli.DescribeRow{Attribute: prefix + ".ServiceStatus", Content: node.ServiceStatus}, + cli.DescribeRow{Attribute: prefix + ".ShardGroup", Content: node.ShardGroup}, + cli.DescribeRow{Attribute: prefix + ".MachineType", Content: node.MachineType}, + cli.DescribeRow{Attribute: prefix + ".CPU", Content: fmt.Sprintf("%d", node.CPU)}, + cli.DescribeRow{Attribute: prefix + ".Memory", Content: fmt.Sprintf("%dGB", node.Memory)}, + cli.DescribeRow{Attribute: prefix + ".DataDiskSize", Content: fmt.Sprintf("%dGB", node.DataDiskSize)}, + cli.DescribeRow{Attribute: prefix + ".DataDiskType", Content: node.DataDiskType}, + ) + } + } + if len(data.ZookeeperNodes) > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "--- ZookeeperNodes ---", Content: fmt.Sprintf("%d nodes", len(data.ZookeeperNodes))}) + for i, node := range data.ZookeeperNodes { + prefix := fmt.Sprintf("ZookeeperNode[%d]", i) + rows = append(rows, + cli.DescribeRow{Attribute: prefix + ".NodeID", Content: node.NodeId}, + cli.DescribeRow{Attribute: prefix + ".NodeName", Content: node.NodeName}, + cli.DescribeRow{Attribute: prefix + ".Zone", Content: node.Zone}, + cli.DescribeRow{Attribute: prefix + ".ServiceStatus", Content: node.ServiceStatus}, + cli.DescribeRow{Attribute: prefix + ".MachineType", Content: node.MachineType}, + cli.DescribeRow{Attribute: prefix + ".CPU", Content: fmt.Sprintf("%d", node.CPU)}, + cli.DescribeRow{Attribute: prefix + ".Memory", Content: fmt.Sprintf("%dGB", node.Memory)}, + cli.DescribeRow{Attribute: prefix + ".DataDiskSize", Content: fmt.Sprintf("%dGB", node.DataDiskSize)}, + cli.DescribeRow{Attribute: prefix + ".DataDiskType", Content: node.DataDiskType}, + ) + } + } + return rows +} + +// describeClusterByID returns the poller's describe func. +func describeClusterByID(ctx *cli.Context) func(clusterID string, commonBase *request.CommonBase) (interface{}, error) { + return func(clusterID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewDescribeUClickhouseClusterRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.ClusterId = sdk.String(clusterID) + resp, err := describeUClickhouseCluster(client, req) + if err != nil { + return nil, err + } + return &resp.Data.Cluster, nil + } +} diff --git a/products/uclickhouse/internal/clickhouse/expand.go b/products/uclickhouse/internal/clickhouse/expand.go new file mode 100644 index 0000000000..8cdab59910 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/expand.go @@ -0,0 +1,66 @@ +package clickhouse + +import ( + "fmt" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newExpand ucloud clickhouse expand +func newExpand(ctx *cli.Context) *cobra.Command { + var async *bool + var clusterID *string + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewExpandUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "expand", + Short: "Expand UClickhouse cluster node count", + Long: "Expand UClickhouse cluster node count", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*clusterID) + req.ClusterId = &id + w := ctx.ProgressWriter() + _, err := expandUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("clickhouse[%s] is expanding", id) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeClusterByID(ctx)).Spoll(id, text, []string{STATUS_RUNNING, STATUS_EXPAND_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "expand", Status: "Expanding"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + clusterID = flags.String("clickhouse-id", "", "Required. UClickhouse cluster ID to expand") + req.TotalNodeCount = flags.Int("total-node-count", 0, "Required. Total node count after expansion") + req.SyncNodeId = flags.String("sync-node-id", "", "Optional. Existing node ID used to sync schema/user information") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + async = flags.Bool("async", false, "Optional. Do not wait for expansion to finish") + + command.SetCompletion(cmd, "clickhouse-id", func() []string { + return getClusterList(ctx, []string{STATUS_RUNNING}, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("clickhouse-id") + cmd.MarkFlagRequired("total-node-count") + return cmd +} + +func expandUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.ExpandUClickhouseClusterRequest) (*opResponse, error) { + var resp opResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "ExpandUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} diff --git a/products/uclickhouse/internal/clickhouse/list.go b/products/uclickhouse/internal/clickhouse/list.go new file mode 100644 index 0000000000..fde369ed0f --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/list.go @@ -0,0 +1,77 @@ +package clickhouse + +import ( + "fmt" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + "github.com/ucloud/ucloud-sdk-go/ucloud/response" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +type listUClickhouseClusterResponse struct { + response.CommonBase + Data listUClickhouseClusterResponseData + Message string +} + +type listUClickhouseClusterResponseData struct { + Clusters []uclickhousesdk.ClickhouseCluster + TotalCount int +} + +// newList ucloud clickhouse list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewListUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List UClickhouse clusters", + Long: "List UClickhouse clusters", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + resp, err := listUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + return + } + list := []ClusterRow{} + for _, cluster := range resp.Data.Clusters { + list = append(list, clusterRow(cluster)) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + return cmd +} + +func listUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.ListUClickhouseClusterRequest) (*listUClickhouseClusterResponse, error) { + var resp listUClickhouseClusterResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "ListUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} + +func clusterRow(cluster uclickhousesdk.ClickhouseCluster) ClusterRow { + return ClusterRow{ + ClusterID: cluster.ClusterId, + ClusterName: cluster.ClusterName, + Status: cluster.Status, + ClickhouseVersion: cluster.ClickhouseVersion, + ShardCount: fmt.Sprintf("%d", cluster.ShardCount), + ReplicateCount: fmt.Sprintf("%d", cluster.ReplicateCount), + VPCId: cluster.VPCId, + SubnetId: cluster.SubnetId, + ClickhouseMachineTypeID: cluster.ClickhouseMachineTypeId, + ClickhouseDataDiskType: cluster.ClickhouseDataDiskType, + ClickhouseDataDiskSize: fmt.Sprintf("%d", cluster.ClickhouseDataDiskSize), + CreateTime: formatUnixDate(cluster.CreateTimestamp), + ExpireTime: formatUnixDate(int(cluster.ExpireTimestamp)), + } +} diff --git a/products/uclickhouse/internal/clickhouse/list_test.go b/products/uclickhouse/internal/clickhouse/list_test.go new file mode 100644 index 0000000000..e28cea1510 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/list_test.go @@ -0,0 +1,150 @@ +package clickhouse + +import ( + "encoding/json" + "strings" + "testing" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" +) + +func TestListUClickhouseClusterResponseDecodesClusterArray(t *testing.T) { + body := []byte(`{ + "RetCode": 0, + "Message": "success", + "Data": { + "Clusters": [ + { + "ClusterId": "uck-1", + "ClusterName": "test", + "Status": "RUNNING", + "CreateTimestamp": 1783700833334 + }, + { + "ClusterId": "uck-2", + "ClusterName": "tp_test", + "Status": "RUNNING", + "CreateTimestamp": 1783504224814 + } + ], + "TotalCount": 2 + } + }`) + + var resp listUClickhouseClusterResponse + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("unmarshal list response: %v", err) + } + if got := len(resp.Data.Clusters); got != 2 { + t.Fatalf("cluster count = %d, want 2", got) + } + if got := resp.Data.Clusters[1].ClusterId; got != "uck-2" { + t.Fatalf("second cluster id = %q, want uck-2", got) + } +} + +func TestDescribeUClickhouseClusterResponseDecodesStringPaymentPrice(t *testing.T) { + body := []byte(`{ + "RetCode": 0, + "Message": "success", + "Data": { + "Cluster": { + "ClusterId": "uck-1", + "ClusterName": "test", + "Status": "RUNNING", + "CreateTimestamp": 1783700833334, + "ExpireTimestamp": null + }, + "ClickhouseNodes": [], + "Payment": { + "ChargeType": "Dynamic", + "CreateTimestamp": 1783700878, + "ExpireTimestamp": 1783926000, + "Price": "1.63", + "OriginalPrice": "1.63", + "ResourceId": "uck-1" + }, + "ZookeeperNodes": [] + } + }`) + + var resp describeUClickhouseClusterResponse + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("unmarshal describe response: %v", err) + } + if got := resp.Data.Payment.Price.String(); got != "1.63" { + t.Fatalf("payment price = %q, want 1.63", got) + } +} + +func TestCreateAndOpResponsesDecodeMinimalPayloads(t *testing.T) { + var createResp createUClickhouseClusterResponse + if err := json.Unmarshal([]byte(`{"RetCode":0,"Message":"success","Data":{"ClusterId":"uck-new"}}`), &createResp); err != nil { + t.Fatalf("unmarshal create response: %v", err) + } + if got := createResp.Data.ClusterId; got != "uck-new" { + t.Fatalf("cluster id = %q, want uck-new", got) + } + + var opResp opResponse + if err := json.Unmarshal([]byte(`{"RetCode":0,"Message":"success"}`), &opResp); err != nil { + t.Fatalf("unmarshal op response: %v", err) + } + if got := opResp.Message; got != "success" { + t.Fatalf("message = %q, want success", got) + } +} + +func TestClusterRowFormatsMillisecondCreateTimestamp(t *testing.T) { + row := clusterRow(uclickhousesdk.ClickhouseCluster{ + ClusterId: "uck-1", + CreateTimestamp: 1783700833334, + ExpireTimestamp: 1783926000, + }) + + // Expected values go through formatUnixDate with second-precision inputs so + // the assertions hold in any local timezone. + if want := formatUnixDate(1783700833); row.CreateTime != want { + t.Fatalf("CreateTime = %q, want %q", row.CreateTime, want) + } + if want := formatUnixDate(1783926000); row.ExpireTime != want { + t.Fatalf("ExpireTime = %q, want %q", row.ExpireTime, want) + } +} + +func TestFormatUnixDateReturnsEmptyForMissingTimestamp(t *testing.T) { + if got := formatUnixDate(0); got != "" { + t.Fatalf("formatUnixDate(0) = %q, want empty", got) + } +} + +func TestEnrichUClickhouseErrorFormatsEmptyServerMessage(t *testing.T) { + err := enrichUClickhouseError("CreateUClickhouseCluster", uerr.NewServerCodeError(207803, "")) + if err == nil { + t.Fatal("expected enriched error") + } + got := err.Error() + for _, want := range []string{ + "UClickhouse API CreateUClickhouseCluster failed", + "RetCode:207803", + "Message:", + "ucloud uclickhouse create-option", + "--debug", + } { + if !strings.Contains(got, want) { + t.Fatalf("error = %q, want contain %q", got, want) + } + } +} + +func TestEnrichUClickhouseErrorFormatsServerMessage(t *testing.T) { + err := enrichUClickhouseError("ListUClickhouseCluster", uerr.NewServerCodeError(123, "boom")) + if err == nil { + t.Fatal("expected enriched error") + } + got := err.Error() + if !strings.Contains(got, "UClickhouse API ListUClickhouseCluster failed") || !strings.Contains(got, "Message:boom") { + t.Fatalf("error = %q, want action and service message", got) + } +} diff --git a/products/uclickhouse/internal/clickhouse/resize.go b/products/uclickhouse/internal/clickhouse/resize.go new file mode 100644 index 0000000000..05eea48d0b --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/resize.go @@ -0,0 +1,66 @@ +package clickhouse + +import ( + "fmt" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResize ucloud clickhouse resize +func newResize(ctx *cli.Context) *cobra.Command { + var async *bool + var clusterID *string + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewResizeUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "resize", + Short: "Resize UClickhouse cluster", + Long: "Resize UClickhouse cluster. Set target-machine-type-id to change spec, or target-data-disk-size-gb to expand disk.", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*clusterID) + req.ClusterId = &id + w := ctx.ProgressWriter() + _, err := resizeUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("clickhouse[%s] is resizing", id) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeClusterByID(ctx)).Spoll(id, text, []string{STATUS_RUNNING, STATUS_RESIZE_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "resize", Status: "Resizing"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + clusterID = flags.String("clickhouse-id", "", "Required. UClickhouse cluster ID to resize") + req.TargetMachineTypeId = flags.String("target-machine-type-id", "", "Optional. Target machine type ID") + req.TargetDataDiskSize = flags.Int("target-data-disk-size-gb", 0, "Optional. Target data disk size in GB") + req.IsZooKeeperNode = flags.Bool("zookeeper-node", false, "Optional. Resize Zookeeper nodes instead of ClickHouse nodes") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + async = flags.Bool("async", false, "Optional. Do not wait for resize to finish") + + command.SetCompletion(cmd, "clickhouse-id", func() []string { + return getClusterList(ctx, []string{STATUS_RUNNING}, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("clickhouse-id") + return cmd +} + +func resizeUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.ResizeUClickhouseClusterRequest) (*opResponse, error) { + var resp opResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "ResizeUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} diff --git a/products/uclickhouse/internal/clickhouse/restart.go b/products/uclickhouse/internal/clickhouse/restart.go new file mode 100644 index 0000000000..25a93cea12 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/restart.go @@ -0,0 +1,73 @@ +package clickhouse + +import ( + "fmt" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestart ucloud clickhouse restart +func newRestart(ctx *cli.Context) *cobra.Command { + var async *bool + var clusterID *string + var yes *bool + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewRestartUClickhouseClusterServiceRequest() + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart UClickhouse cluster service", + Long: "Restart UClickhouse cluster service", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*clusterID) + ok, err := ctx.Confirm(*yes, fmt.Sprintf("Are you sure to restart UClickhouse cluster[%s]?", id)) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.ClusterId = &id + w := ctx.ProgressWriter() + _, err = restartUClickhouseClusterService(client, req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("clickhouse[%s] is restarting", id) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeClusterByID(ctx)).Spoll(id, text, []string{STATUS_RUNNING, STATUS_RESTART_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "restart", Status: "Restarting"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + clusterID = flags.String("clickhouse-id", "", "Required. UClickhouse cluster ID to restart") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + yes = flags.BoolP("yes", "y", false, "Optional. Skip confirmation prompt") + async = flags.Bool("async", false, "Optional. Do not wait for restart to finish") + + command.SetCompletion(cmd, "clickhouse-id", func() []string { + return getClusterList(ctx, []string{STATUS_RUNNING, STATUS_RESTART_FAILED}, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("clickhouse-id") + return cmd +} + +func restartUClickhouseClusterService(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.RestartUClickhouseClusterServiceRequest) (*opResponse, error) { + var resp opResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "RestartUClickhouseClusterService", &reqCopier, &resp) + return &resp, err +} diff --git a/products/uclickhouse/internal/clickhouse/rows.go b/products/uclickhouse/internal/clickhouse/rows.go new file mode 100644 index 0000000000..51a3568b05 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/rows.go @@ -0,0 +1,39 @@ +package clickhouse + +// ClusterRow represents a UClickhouse cluster in list output. +type ClusterRow struct { + ClusterID string + ClusterName string + Status string + ClickhouseVersion string + ShardCount string + ReplicateCount string + VPCId string + SubnetId string + ClickhouseMachineTypeID string + ClickhouseDataDiskType string + ClickhouseDataDiskSize string + CreateTime string + ExpireTime string +} + +// CreateOptionRow represents an available creation option. +type CreateOptionRow struct { + OptionType string + Version string + VersionName string + NodeType string + MachineTypeID string + MachineTypeName string + MachineType string + CPU string + MemoryGB string + NodeCounts string + IsSecGroup string + DiskType string + MinSizeGB string + MaxSizeGB string + DefaultSizeGB string + StepGB string + MaxNodeCount string +} diff --git a/products/uclickhouse/internal/clickhouse/status.go b/products/uclickhouse/internal/clickhouse/status.go new file mode 100644 index 0000000000..bb44599e6e --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/status.go @@ -0,0 +1,16 @@ +package clickhouse + +const ( + STATUS_CREATING = "CREATING" + STATUS_RUNNING = "RUNNING" + STATUS_RESIZING = "RESIZING" + STATUS_RESTARTING = "RESTARTING" + STATUS_DESTROYING = "DESTROYING" + STATUS_CREATE_FAILED = "CREATE_FAILED" + STATUS_RESTART_FAILED = "RESTART_FAILED" + STATUS_DESTROY_FAILED = "DESTROY_FAILED" + STATUS_RESIZE_FAILED = "RESIZE_FAILED" + STATUS_BACKUP_RESTORE_FAILED = "BACKUP_RESTORE_FAILED" + STATUS_EXPANDING = "EXPANDING" + STATUS_EXPAND_FAILED = "EXPAND_FAILED" +) diff --git a/products/uclickhouse/internal/clickhouse/time.go b/products/uclickhouse/internal/clickhouse/time.go new file mode 100644 index 0000000000..6a4119e3e0 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/time.go @@ -0,0 +1,13 @@ +package clickhouse + +import "github.com/ucloud/ucloud-cli/internal/common" + +func formatUnixDate(timestamp int) string { + if timestamp <= 0 { + return "" + } + if timestamp > 1000000000000 { + timestamp = timestamp / 1000 + } + return common.FormatDate(timestamp) +} diff --git a/products/uclickhouse/product.go b/products/uclickhouse/product.go new file mode 100644 index 0000000000..bc592c8a3d --- /dev/null +++ b/products/uclickhouse/product.go @@ -0,0 +1,21 @@ +package uclickhouse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalclickhouse "github.com/ucloud/ucloud-cli/products/uclickhouse/internal/clickhouse" +) + +type product struct{} + +// New returns the uclickhouse product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "uclickhouse", Commands: []string{"uclickhouse"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalclickhouse.NewCommand(ctx)} +} diff --git a/products/uclickhouse/product.yaml b/products/uclickhouse/product.yaml new file mode 100644 index 0000000000..ec85d6f72f --- /dev/null +++ b/products/uclickhouse/product.yaml @@ -0,0 +1,7 @@ +# products/uclickhouse/product.yaml — uclickhouse 产品元数据 +name: uclickhouse +owners: + - jukang-ucloud +commands: + - uclickhouse +enabled: true diff --git a/products/uclickhouse/testdata/cmdtree.golden b/products/uclickhouse/testdata/cmdtree.golden new file mode 100644 index 0000000000..eef0cbf4f9 --- /dev/null +++ b/products/uclickhouse/testdata/cmdtree.golden @@ -0,0 +1,63 @@ +ucloud uclickhouse use=uclickhouse short=Manage UClickhouse clusters +ucloud uclickhouse create use=create short=Create UClickhouse cluster + flag=admin-password short= default= required=true + flag=async short= default=false required= + flag=backup-id short= default= required= + flag=charge-type short= default=Month required= + flag=clickhouse-machine-type-id short= default= required=true + flag=clickhouse-version short= default= required=true + flag=data-disk-size-gb short= default=100 required= + flag=data-disk-type short= default= required=true + flag=label short= default=[] required= + flag=multi-zone short= default=false required= + flag=multi-zone-name short= default=[] required= + flag=name short= default=clickhouse required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=replicate-count short= default=2 required= + flag=sec-group short= default=false required= + flag=sec-group-ids short= default= required= + flag=shard-count short= default=1 required= + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= + flag=zookeeper-data-disk-size-gb short= default= required= + flag=zookeeper-data-disk-type short= default= required= + flag=zookeeper-ha short= default=true required= + flag=zookeeper-machine-type-id short= default= required= +ucloud uclickhouse create-option use=create-option short=List available UClickhouse creation options + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uclickhouse delete use=delete short=Delete UClickhouse clusters + flag=clickhouse-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= +ucloud uclickhouse describe use=describe short=Describe UClickhouse cluster details + flag=clickhouse-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uclickhouse expand use=expand short=Expand UClickhouse cluster node count + flag=async short= default=false required= + flag=clickhouse-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=sync-node-id short= default= required= + flag=total-node-count short= default=0 required=true +ucloud uclickhouse list use=list short=List UClickhouse clusters + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uclickhouse resize use=resize short=Resize UClickhouse cluster + flag=async short= default=false required= + flag=clickhouse-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=target-data-disk-size-gb short= default=0 required= + flag=target-machine-type-id short= default= required= + flag=zookeeper-node short= default=false required= +ucloud uclickhouse restart use=restart short=Restart UClickhouse cluster service + flag=async short= default=false required= + flag=clickhouse-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= diff --git a/products/uclickhouse/testdata/completion.golden b/products/uclickhouse/testdata/completion.golden new file mode 100644 index 0000000000..03e942e32b --- /dev/null +++ b/products/uclickhouse/testdata/completion.golden @@ -0,0 +1,8 @@ +ucloud uclickhouse create charge-type static Dynamic,Month,Year +ucloud uclickhouse create multi-zone static false,true +ucloud uclickhouse create sec-group static false,true +ucloud uclickhouse delete clickhouse-id dynamic +ucloud uclickhouse describe clickhouse-id dynamic +ucloud uclickhouse expand clickhouse-id dynamic +ucloud uclickhouse resize clickhouse-id dynamic +ucloud uclickhouse restart clickhouse-id dynamic diff --git a/products/udac/internal/udac/cmd.go b/products/udac/internal/udac/cmd.go new file mode 100644 index 0000000000..ee148284f5 --- /dev/null +++ b/products/udac/internal/udac/cmd.go @@ -0,0 +1,23 @@ +package udac + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `udac` root command and mounts the subcommands. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "udac", + Short: "Manage Database Autonomous Center (UDAC) instances", + Long: "Import, export, and list database instances in the Database Autonomous Center (UDAC).", + Args: cobra.NoArgs, + } + + cmd.AddCommand(newImport(ctx)) + cmd.AddCommand(newExport(ctx)) + cmd.AddCommand(newList(ctx)) + + return cmd +} diff --git a/products/udac/internal/udac/export.go b/products/udac/internal/udac/export.go new file mode 100644 index 0000000000..6179df0de4 --- /dev/null +++ b/products/udac/internal/udac/export.go @@ -0,0 +1,204 @@ +package udac + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// actionForExportType 根据实例类型返回从 UDAC 移除对应的 Action 名。 +func actionForExportType(instanceType string) (string, error) { + switch instanceType { + case "mysql": + return "RemoveUDACMySQLInstances", nil + case "mongodb": + return "RemoveUDACUMongoDBClusters", nil + default: + return "", fmt.Errorf("unsupported instance type: %s, supported: %v", instanceType, SupportedTypes) + } +} + +// exportInstances 调用 UDAC 移除 MySQL API,所有实例共用同一个 zone。 +// 走 SDK 默认 FormEncoder(扁平化格式),MySQL 后端接受。 +func exportInstances(ctx *cli.Context, action, projectID, zone string, instanceIDs []string) (map[string]interface{}, error) { + instanceInfoSet := make([]interface{}, 0, len(instanceIDs)) + for _, id := range instanceIDs { + instanceInfoSet = append(instanceInfoSet, map[string]interface{}{ + "ID": id, + "Zone": zone, + }) + } + + params := map[string]interface{}{ + "Action": action, + "ProjectId": projectID, + "InstanceInfoSet": instanceInfoSet, + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, err + } + return resp.GetPayload(), nil +} + +func exportMongoDBClusters(ctx *cli.Context, action, projectID, region string, clusterIDs []string) (map[string]interface{}, error) { + mongoDBClusterSet := make([]interface{}, 0, len(clusterIDs)) + for _, id := range clusterIDs { + mongoDBClusterSet = append(mongoDBClusterSet, map[string]interface{}{ + "ClusterId": id, + "Region": region, + }) + } + + params := map[string]interface{}{ + "Action": action, + "ProjectId": projectID, + "Region": region, + "MongoDBClusterSet": mongoDBClusterSet, + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + req.SetEncoder(request.NewJSONEncoder(client.GetConfig(), client.GetCredential())) + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, err + } + return resp.GetPayload(), nil +} + +// newExport implements `ucloud udac export` +// MySQL 必填:--udb-id, --type=mysql, --zone, --project-id +// MongoDB 必填:--udb-id, --type=mongodb, --region, --project-id +// export 是从 UDAC 移除实例(不是导出数据),语义同 import 的反向操作。 +func newExport(ctx *cli.Context) *cobra.Command { + var instanceIDs []string + var instanceType string + var common request.CommonBase + + cmd := &cobra.Command{ + Use: "export", + Short: "Export database instances from UDAC", + Long: `Export existing database instances from the Database Autonomous Center (UDAC). + +You must specify the instance type via --type. Supported types: mysql, mongodb. + +Required flags: + mysql: --udb-id, --type=mysql, --project-id (--zone falls back to config default) + mongodb: --udb-id, --type=mongodb, --project-id (--region falls back to config default) + +--project-id, --region, --zone fall back to config defaults (default-project-id, +default-region, default-zone). + +This is a synchronous operation: the command returns after the export API responds.`, + Run: func(c *cobra.Command, args []string) { + // 1. 开头单独校验 udb-id 和 type(早失败) + if len(instanceIDs) == 0 { + ctx.HandleError(fmt.Errorf("required flag(s) not set: %s", resourceIDFlag)) + return + } + if instanceType == "" { + ctx.HandleError(fmt.Errorf("required flag(s) not set: type")) + return + } + + // 2. 类型校验 + Action 选择 + action, err := actionForExportType(instanceType) + if err != nil { + ctx.HandleError(err) + return + } + + // 3. 从 common 取绑定值 + projectID := common.GetProjectId() + region := common.GetRegion() + zone := common.GetZone() + + // 4. 类型相关必填校验(配置默认值兜底,空时报错) + var missing []string + if projectID == "" { + missing = append(missing, "project-id") + } + if instanceType == "mysql" && zone == "" { + missing = append(missing, "zone") + } + if instanceType == "mongodb" && region == "" { + missing = append(missing, "region") + } + if len(missing) > 0 { + ctx.HandleError(fmt.Errorf("required flag(s) not set: %s", strings.Join(missing, ", "))) + return + } + + // 5. 归一化 instance ID(支持 "udb-xxx/instance-name" 格式) + for i, id := range instanceIDs { + instanceIDs[i] = ctx.PickResourceID(id) + } + + // 6. 按类型分流调用 API + var payload map[string]interface{} + if instanceType == "mongodb" { + payload, err = exportMongoDBClusters(ctx, action, projectID, region, instanceIDs) + } else { + payload, err = exportInstances(ctx, action, projectID, zone, instanceIDs) + } + if err != nil { + ctx.HandleError(err) + return + } + if len(payload) == 0 { + ctx.HandleError(fmt.Errorf("empty response from server")) + return + } + + // 7. 输出 + w := ctx.ProgressWriter() + for _, id := range instanceIDs { + if instanceType == "mongodb" { + fmt.Fprintf(w, "%s[%s] exported successfully (type: %s, region: %s)\n", productName, id, instanceType, region) + } else { + fmt.Fprintf(w, "%s[%s] exported successfully (type: %s, zone: %s)\n", productName, id, instanceType, zone) + } + } + results := make([]cli.OpResultRow, 0, len(instanceIDs)) + for _, id := range instanceIDs { + results = append(results, cli.OpResultRow{ + ResourceID: id, + Action: "export", + Status: "Exported", + }) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&instanceIDs, resourceIDFlag, nil, "Required. Instance ID(s) to export. Repeatable.") + flags.StringVar(&instanceType, typeFlag, "", "Required. Instance type: mysql, mongodb.") + + // 公共参数绑定:project-id/region/zone 都用配置默认值兜底 + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + command.SetFlagValues(cmd, typeFlag, SupportedTypes...) + + return cmd +} diff --git a/products/udac/internal/udac/import.go b/products/udac/internal/udac/import.go new file mode 100644 index 0000000000..4076fa6bef --- /dev/null +++ b/products/udac/internal/udac/import.go @@ -0,0 +1,203 @@ +package udac + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// actionForImportType 根据实例类型返回导入对应的 UDAC Action 名。 +func actionForImportType(instanceType string) (string, error) { + switch instanceType { + case "mysql": + return "AddUDACMySQLInstances", nil + case "mongodb": + return "AddUDACUMongoDBClusters", nil + default: + return "", fmt.Errorf("unsupported instance type: %s, supported: %v", instanceType, SupportedTypes) + } +} + +// importInstances 调用 UDAC 导入 MySQL API,所有实例共用同一个 zone。 +// 走 SDK 默认 FormEncoder(扁平化格式),MySQL 后端接受。 +func importInstances(ctx *cli.Context, action, projectID, zone string, instanceIDs []string) (map[string]interface{}, error) { + instanceInfoSet := make([]interface{}, 0, len(instanceIDs)) + for _, id := range instanceIDs { + instanceInfoSet = append(instanceInfoSet, map[string]interface{}{ + "ID": id, + "Zone": zone, + }) + } + + params := map[string]interface{}{ + "Action": action, + "ProjectId": projectID, + "InstanceInfoSet": instanceInfoSet, + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, err + } + return resp.GetPayload(), nil +} + +func importMongoDBClusters(ctx *cli.Context, action, projectID, region string, clusterIDs []string) (map[string]interface{}, error) { + mongoDBClusterSet := make([]interface{}, 0, len(clusterIDs)) + for _, id := range clusterIDs { + mongoDBClusterSet = append(mongoDBClusterSet, map[string]interface{}{ + "ClusterId": id, + "Region": region, + }) + } + + params := map[string]interface{}{ + "Action": action, + "ProjectId": projectID, + "Region": region, + "MongoDBClusterSet": mongoDBClusterSet, + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + req.SetEncoder(request.NewJSONEncoder(client.GetConfig(), client.GetCredential())) + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, err + } + return resp.GetPayload(), nil +} + +// newImport implements `ucloud udac import` +// MySQL 必填:--udb-id, --type=mysql, --zone, --project-id +// MongoDB 必填:--udb-id, --type=mongodb, --region, --project-id +func newImport(ctx *cli.Context) *cobra.Command { + var instanceIDs []string + var instanceType string + var common request.CommonBase + + cmd := &cobra.Command{ + Use: "import", + Short: "Import database instances into UDAC", + Long: `Import existing database instances into the Database Autonomous Center (UDAC) for autonomous management. + +You must specify the instance type via --type. Supported types: mysql, mongodb. + +Required flags: + mysql: --udb-id, --type=mysql, --project-id (--zone falls back to config default) + mongodb: --udb-id, --type=mongodb, --project-id (--region falls back to config default) + +--project-id, --region, --zone fall back to config defaults (default-project-id, +default-region, default-zone). + +This is a synchronous operation: the command returns after the import API responds.`, + Run: func(c *cobra.Command, args []string) { + // 1. 开头单独校验 udb-id 和 type(早失败) + if len(instanceIDs) == 0 { + ctx.HandleError(fmt.Errorf("required flag(s) not set: %s", resourceIDFlag)) + return + } + if instanceType == "" { + ctx.HandleError(fmt.Errorf("required flag(s) not set: type")) + return + } + + // 2. 类型校验 + Action 选择 + action, err := actionForImportType(instanceType) + if err != nil { + ctx.HandleError(err) + return + } + + // 3. 从 common 取绑定值 + projectID := common.GetProjectId() + region := common.GetRegion() + zone := common.GetZone() + + // 4. 类型相关必填校验(配置默认值兜底,空时报错) + var missing []string + if projectID == "" { + missing = append(missing, "project-id") + } + if instanceType == "mysql" && zone == "" { + missing = append(missing, "zone") + } + if instanceType == "mongodb" && region == "" { + missing = append(missing, "region") + } + if len(missing) > 0 { + ctx.HandleError(fmt.Errorf("required flag(s) not set: %s", strings.Join(missing, ", "))) + return + } + + // 5. 归一化 instance ID(支持 "udb-xxx/instance-name" 格式) + for i, id := range instanceIDs { + instanceIDs[i] = ctx.PickResourceID(id) + } + + // 6. 按类型分流调用 API + var payload map[string]interface{} + if instanceType == "mongodb" { + payload, err = importMongoDBClusters(ctx, action, projectID, region, instanceIDs) + } else { + payload, err = importInstances(ctx, action, projectID, zone, instanceIDs) + } + if err != nil { + ctx.HandleError(err) + return + } + if len(payload) == 0 { + ctx.HandleError(fmt.Errorf("empty response from server")) + return + } + + // 7. 输出 + w := ctx.ProgressWriter() + for _, id := range instanceIDs { + if instanceType == "mongodb" { + fmt.Fprintf(w, "%s[%s] imported successfully (type: %s, region: %s)\n", productName, id, instanceType, region) + } else { + fmt.Fprintf(w, "%s[%s] imported successfully (type: %s, zone: %s)\n", productName, id, instanceType, zone) + } + } + results := make([]cli.OpResultRow, 0, len(instanceIDs)) + for _, id := range instanceIDs { + results = append(results, cli.OpResultRow{ + ResourceID: id, + Action: "import", + Status: "Imported", + }) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&instanceIDs, resourceIDFlag, nil, "Required. Instance ID(s) to import. Repeatable.") + flags.StringVar(&instanceType, typeFlag, "", "Required. Instance type: mysql, mongodb.") + + // 公共参数绑定:project-id/region/zone 都用配置默认值兜底 + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + command.SetFlagValues(cmd, typeFlag, SupportedTypes...) + + return cmd +} diff --git a/products/udac/internal/udac/list.go b/products/udac/internal/udac/list.go new file mode 100644 index 0000000000..1274b8cd49 --- /dev/null +++ b/products/udac/internal/udac/list.go @@ -0,0 +1,299 @@ +package udac + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// actionForType 根据实例类型返回对应的 UDAC Action 名。 +func actionForType(instanceType string) (string, error) { + switch instanceType { + case "mysql": + return "ListUDACMySQLInstance", nil + case "mongodb": + return "ListUDACUMongoDBClusters", nil + default: + return "", fmt.Errorf("unsupported instance type: %s, supported: %v", instanceType, SupportedTypes) + } +} + +// regionFromZone 从 zone 推导 region。 +// UCloud zone 命名规则:{region}-{suffix},例如 cn-bj2-02 → cn-bj2、hk-02 → hk。 +func regionFromZone(zone string) string { + if i := strings.LastIndex(zone, "-"); i > 0 { + return zone[:i] + } + return zone +} + +// newInstanceRow 从 API 返回的 map 构造一行展示数据。 +// overrideRegion 非空时用用户传入值,否则优先读 API 返回的 Region,再兜底从 Zone 推导。 +// MongoDB 响应字段:ClusterId/Name/Region/State/JoinTime(无 Zone)。 +// MySQL 响应字段:ID 或 InstanceId/Name/Zone/State/Status/JoinTime(无 Region)。 +func newInstanceRow(m map[string]interface{}, instanceType, overrideRegion string) (importedInstanceRow, bool) { + id := firstString(m, "ClusterId", "InstanceId", "ID") + if id == "" { + return importedInstanceRow{}, false + } + zoneVal := getString(m, "Zone") + regionVal := getString(m, "Region") + if regionVal == "" { + regionVal = overrideRegion + } + if regionVal == "" { + regionVal = regionFromZone(zoneVal) + } + status := firstString(m, "State", "Status") + joinTime := getInt64(m, "JoinTime") + importTime := "" + if joinTime > 0 { + importTime = time.Unix(joinTime, 0).Format(time.RFC3339) + } + return importedInstanceRow{ + ResourceID: id, + InstanceID: id, + Name: getString(m, "Name"), + Type: instanceType, + Status: status, + ImportTime: importTime, + Region: regionVal, + Zone: zoneVal, + }, true +} + +// firstString 按顺序尝试多个 key,返回第一个非空字符串。 +func firstString(m map[string]interface{}, keys ...string) string { + for _, k := range keys { + if v := getString(m, k); v != "" { + return v + } + } + return "" +} + +// fetchUDACInstances 调用 UDAC list API,返回账号下全部实例。 +func fetchUDACInstances(ctx *cli.Context, action, projectID string) ([]map[string]interface{}, error) { + params := map[string]interface{}{ + "Action": action, + "ProjectId": projectID, + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, err + } + payload := resp.GetPayload() + + var raw []interface{} + for _, key := range []string{"InstanceInfoSet", "Instances", "DataSet"} { + if val, ok := payload[key].([]interface{}); ok { + raw = val + break + } + } + out := make([]map[string]interface{}, 0, len(raw)) + for _, item := range raw { + if m, ok := item.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out, nil +} + +// newList implements `ucloud udac list` +// --project-id 必填(配置默认值兜底);其他可选。 +func newList(ctx *cli.Context) *cobra.Command { + var instanceID, instanceType, statusFilter string + var allRegions bool + var common request.CommonBase + + cmd := &cobra.Command{ + Use: "list", + Short: "List imported database instances in UDAC", + Long: `List database instances that have been imported into the Database Autonomous Center (UDAC). + +Required flag: --project-id (falls back to default-project-id from config if set). + +Optional filters: + --type Instance type: mysql, mongodb + --region Filter by region. Defaults to config's default-region. + --zone Filter by zone. If omitted, list across all zones in the region. + --udb-id List only the specified instance. + --status Filter by status (e.g., Running, Failed). + --all-regions List instances across all regions (ignore --region and config default). + +When both --region and --zone are specified, the zone must belong to the region.`, + Run: func(c *cobra.Command, args []string) { + // 1. 必填校验:project-id(配置默认值兜底) + projectID := common.GetProjectId() + if projectID == "" { + ctx.HandleError(fmt.Errorf("required flag(s) not set: project-id")) + return + } + + // 2. 类型校验 + Action 选择 + action, err := actionForType(instanceType) + if err != nil { + ctx.HandleError(err) + return + } + + // 3. 确定 region 过滤值:--all-regions 优先级最高 + if allRegions && c.Flags().Changed("region") { + ctx.HandleError(fmt.Errorf("--all-regions and --region are mutually exclusive")) + return + } + region := common.GetRegion() + zone := common.GetZone() + if allRegions { + region = "" + } + + // 4. region/zone 一致性校验 + if region != "" && zone != "" && !strings.HasPrefix(zone, region+"-") { + ctx.HandleError(fmt.Errorf("zone %s does not belong to region %s", zone, region)) + return + } + + // mongodb 响应无 Zone 字段,--zone 过滤会静默清空 + if instanceType == "mongodb" && zone != "" { + ctx.HandleError(fmt.Errorf("--zone is not supported for mongodb, use --region instead")) + return + } + + // 5. 拉取全部实例(API 只带 ProjectId,不传 Region/Zone) + instances, err := fetchUDACInstances(ctx, action, projectID) + if err != nil { + ctx.HandleError(err) + return + } + + // 6. 客户端过滤 + instanceID = ctx.PickResourceID(instanceID) + rows := make([]importedInstanceRow, 0, len(instances)) + for _, m := range instances { + if instanceID != "" && firstString(m, "ClusterId", "InstanceId", "ID") != instanceID { + continue + } + zoneVal := getString(m, "Zone") + if zone != "" && zoneVal != zone { + continue + } + if region != "" { + // 优先用 API 返回的 Region,其次从 Zone 推导 + instanceRegion := getString(m, "Region") + if instanceRegion == "" { + instanceRegion = regionFromZone(zoneVal) + } + if instanceRegion != region { + continue + } + } + if statusFilter != "" && firstString(m, "State", "Status") != statusFilter { + continue + } + if row, ok := newInstanceRow(m, instanceType, region); ok { + rows = append(rows, row) + } + } + + // 7. 输出(空列表正常输出空表,不报错) + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&instanceID, resourceIDFlag, "", "Optional. List only the specified instance.") + flags.StringVar(&instanceType, typeFlag, "mysql", "Optional. Instance type: mysql, mongodb.") + flags.StringVar(&statusFilter, "status", "", "Optional. Filter by status (e.g., Running, Failed).") + flags.BoolVar(&allRegions, "all-regions", false, "Optional. List instances across all regions (ignore --region and config default).") + + // 公共参数绑定:region/zone/project-id 都用配置默认值兜底 + ctx.BindRegion(cmd, &common) + ctx.BindZoneEmpty(cmd, &common) + ctx.BindProjectID(cmd, &common) + + command.SetFlagValues(cmd, typeFlag, SupportedTypes...) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return listImportedInstanceIDs(ctx, instanceType, common.GetRegion(), common.GetZone(), common.GetProjectId()) + }) + + return cmd +} + +func listImportedInstanceIDs(ctx *cli.Context, instanceType, region, zone, projectID string) []string { + action, err := actionForType(instanceType) + if err != nil { + return nil + } + if instanceType == "mongodb" && zone != "" { + return nil + } + instances, err := fetchUDACInstances(ctx, action, projectID) + if err != nil { + return nil + } + out := make([]string, 0, len(instances)) + for _, m := range instances { + zoneVal := getString(m, "Zone") + if zone != "" && zoneVal != zone { + continue + } + if region != "" { + instanceRegion := getString(m, "Region") + if instanceRegion == "" { + instanceRegion = regionFromZone(zoneVal) + } + if instanceRegion != region { + continue + } + } + id := firstString(m, "ClusterId", "InstanceId", "ID") + if id == "" { + continue + } + if name := getString(m, "Name"); name != "" { + out = append(out, id+"/"+name) + } else { + out = append(out, id) + } + } + return out +} + +// getString 从 map 中安全获取字符串值 +func getString(m map[string]interface{}, key string) string { + if val, ok := m[key].(string); ok { + return val + } + return "" +} + +// getInt64 从 map 中安全获取 int64 值(兼容 float64/int64/int) +func getInt64(m map[string]interface{}, key string) int64 { + if val, ok := m[key].(float64); ok { + return int64(val) + } + if val, ok := m[key].(int64); ok { + return int64(val) + } + if val, ok := m[key].(int); ok { + return int64(val) + } + return 0 +} diff --git a/products/udac/internal/udac/rows.go b/products/udac/internal/udac/rows.go new file mode 100644 index 0000000000..2c605b76a2 --- /dev/null +++ b/products/udac/internal/udac/rows.go @@ -0,0 +1,13 @@ +package udac + +// importedInstanceRow is the row format for list output +type importedInstanceRow struct { + ResourceID string `json:"resourceId" header:"ResourceID"` + InstanceID string `json:"instanceId" header:"InstanceID"` + Name string `json:"name" header:"Name"` + Type string `json:"type" header:"Type"` + Status string `json:"status" header:"Status"` + ImportTime string `json:"importTime" header:"ImportTime"` + Region string `json:"region" header:"Region"` + Zone string `json:"zone" header:"Zone"` +} diff --git a/products/udac/internal/udac/status.go b/products/udac/internal/udac/status.go new file mode 100644 index 0000000000..e2a54d62aa --- /dev/null +++ b/products/udac/internal/udac/status.go @@ -0,0 +1,10 @@ +package udac + +const ( + productName = "udac" + resourceIDFlag = "udb-id" // list 命令的 --udb-id flag + typeFlag = "type" // 实例类型 flag +) + +// SupportedTypes 支持的实例类型列表 +var SupportedTypes = []string{"mysql", "mongodb"} diff --git a/products/udac/product.go b/products/udac/product.go new file mode 100644 index 0000000000..b051f8e869 --- /dev/null +++ b/products/udac/product.go @@ -0,0 +1,20 @@ +package udac + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internaludac "github.com/ucloud/ucloud-cli/products/udac/internal/udac" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "udac", Commands: []string{"udac"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internaludac.NewCommand(ctx)} +} diff --git a/products/udac/product.yaml b/products/udac/product.yaml new file mode 100644 index 0000000000..874bd471f8 --- /dev/null +++ b/products/udac/product.yaml @@ -0,0 +1,6 @@ +name: udac +owners: + - nzr1122 +commands: + - udac +enabled: true diff --git a/products/udac/testdata/cmdtree.golden b/products/udac/testdata/cmdtree.golden new file mode 100644 index 0000000000..1b03224684 --- /dev/null +++ b/products/udac/testdata/cmdtree.golden @@ -0,0 +1,21 @@ +ucloud udac use=udac short=Manage Database Autonomous Center (UDAC) instances +ucloud udac export use=export short=Export database instances from UDAC + flag=project-id short= default= required= + flag=region short= default= required= + flag=type short= default= required= + flag=udb-id short= default=[] required= + flag=zone short= default= required= +ucloud udac import use=import short=Import database instances into UDAC + flag=project-id short= default= required= + flag=region short= default= required= + flag=type short= default= required= + flag=udb-id short= default=[] required= + flag=zone short= default= required= +ucloud udac list use=list short=List imported database instances in UDAC + flag=all-regions short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=status short= default= required= + flag=type short= default=mysql required= + flag=udb-id short= default= required= + flag=zone short= default= required= diff --git a/products/udac/testdata/completion.golden b/products/udac/testdata/completion.golden new file mode 100644 index 0000000000..26ee94763f --- /dev/null +++ b/products/udac/testdata/completion.golden @@ -0,0 +1,13 @@ +ucloud udac export project-id dynamic +ucloud udac export region dynamic +ucloud udac export type static mongodb,mysql +ucloud udac export zone dynamic +ucloud udac import project-id dynamic +ucloud udac import region dynamic +ucloud udac import type static mongodb,mysql +ucloud udac import zone dynamic +ucloud udac list project-id dynamic +ucloud udac list region dynamic +ucloud udac list type static mongodb,mysql +ucloud udac list udb-id dynamic +ucloud udac list zone dynamic diff --git a/products/uddos/internal/mainland/cmd.go b/products/uddos/internal/mainland/cmd.go new file mode 100644 index 0000000000..56645e8172 --- /dev/null +++ b/products/uddos/internal/mainland/cmd.go @@ -0,0 +1,49 @@ +// Package mainland ... +// +// @Brief 国内高防命令组聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package mainland + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + mainlandip "github.com/ucloud/ucloud-cli/products/uddos/internal/mainland/ip" + mainlandrule "github.com/ucloud/ucloud-cli/products/uddos/internal/mainland/rule" + mainlandsvc "github.com/ucloud/ucloud-cli/products/uddos/internal/mainland/service" +) + +// NewCommand 构建 uddos mainland 命令组 +// +// @Brief 构建国内高防命令组并挂载 service、ip 子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "mainland", + Short: "Manage mainland China DDoS high-protection services", + Long: "Manage UCloud mainland China (国内高防) DDoS protection services and IPs", + Args: cobra.NoArgs, + } + cmd.AddCommand(mainlandip.NewCommand(ctx)) + cmd.AddCommand(mainlandrule.NewCommand(ctx)) + cmd.AddCommand(mainlandsvc.NewCommand(ctx)) + return cmd +} diff --git a/products/uddos/internal/mainland/ip/cmd.go b/products/uddos/internal/mainland/ip/cmd.go new file mode 100644 index 0000000000..f67213f819 --- /dev/null +++ b/products/uddos/internal/mainland/ip/cmd.go @@ -0,0 +1,56 @@ +// Package ip ... +// +// @Brief 国内高防IP管理命令聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand 构建 uddos mainland ip 命令组 +// +// @Brief 构建国内高防 ip 命令组并挂载子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ip", + Short: "Manage mainland DDoS protection IPs", + Long: "List, create and delete mainland BGP DDoS protection IPs", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + return cmd +} + +func strVal(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func intVal(m map[string]interface{}, key string) int { + v, _ := m[key].(float64) + return int(v) +} diff --git a/products/uddos/internal/mainland/ip/create.go b/products/uddos/internal/mainland/ip/create.go new file mode 100644 index 0000000000..60191913b6 --- /dev/null +++ b/products/uddos/internal/mainland/ip/create.go @@ -0,0 +1,83 @@ +// Package ip ... +// +// @Brief 创建国内高防IP命令 +// +// @File create.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newCreate 构建 uddos mainland ip create 命令 +// +// @Brief 构建国内高防 ip create 子命令,调用 CreateBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newCreate(ctx *cli.Context) *cobra.Command { + var resourceID, typeIP, remark, tag string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a mainland BGP high-protection IP", + Long: "Create a new BGP DDoS protection IP for the specified mainland service", + Example: " ucloud uddos mainland ip create --resource-id ghp-xxxxx", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "CreateBGPServiceIP", + "ResourceId": resourceID, + "TypeIP": typeIP, + } + if cmd.Flags().Changed("remark") { + params["Remark"] = remark + } + if cmd.Flags().Changed("tag") { + params["Tag"] = tag + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("CreateBGPServiceIP: %w", err)) + return + } + payload := resp.GetPayload() + defenceIP, _ := payload["DefenceIP"].(string) + fmt.Fprintf(ctx.ProgressWriter(), "BGP IP created: %s\n", defenceIP) + ctx.EmitResult(cli.OpResultRow{ResourceID: defenceIP, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&typeIP, "type-ip", "TypeFree", "Optional. IP type: TypeFree or TypeCharge, default TypeFree") + flags.StringVar(&remark, "remark", "", "Optional. Remark for this IP") + flags.StringVar(&tag, "tag", "", "Optional. Business group tag") + cmd.MarkFlagRequired("resource-id") + return cmd +} diff --git a/products/uddos/internal/mainland/ip/delete.go b/products/uddos/internal/mainland/ip/delete.go new file mode 100644 index 0000000000..47aba00a71 --- /dev/null +++ b/products/uddos/internal/mainland/ip/delete.go @@ -0,0 +1,84 @@ +// Package ip ... +// +// @Brief 删除国内高防IP命令 +// +// @File delete.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete 构建 uddos mainland ip delete 命令 +// +// @Brief 构建国内高防 ip delete 子命令,调用 DeleteBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newDelete(ctx *cli.Context) *cobra.Command { + var resourceID, defenceIP string + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a mainland BGP high-protection IP", + Long: "Delete a BGP DDoS protection IP from the specified mainland service", + Example: " ucloud uddos mainland ip delete --resource-id ghp-xxxxx --defence-ip 1.2.3.4", + Run: func(cmd *cobra.Command, args []string) { + confirmed, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure to delete defence IP %s from service %s?", defenceIP, resourceID)) + if err != nil { + ctx.HandleError(fmt.Errorf("confirm: %w", err)) + return + } + if !confirmed { + return + } + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DeleteBGPServiceIP", + "ResourceId": resourceID, + "DefenceIp": defenceIP, + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + _, invokeErr := client.GenericInvoke(req) + if invokeErr != nil { + ctx.HandleError(fmt.Errorf("DeleteBGPServiceIP: %w", invokeErr)) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "BGP IP deleted: %s\n", defenceIP) + ctx.EmitResult(cli.OpResultRow{ResourceID: defenceIP, Action: "delete", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&defenceIP, "defence-ip", "", "Required. BGP defence IP to delete") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip confirmation prompt") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("defence-ip") + return cmd +} diff --git a/products/uddos/internal/mainland/ip/list.go b/products/uddos/internal/mainland/ip/list.go new file mode 100644 index 0000000000..89932793eb --- /dev/null +++ b/products/uddos/internal/mainland/ip/list.go @@ -0,0 +1,99 @@ +// Package ip ... +// +// @Brief 查询国内高防IP列表命令 +// +// @File list.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList 构建 uddos mainland ip list 命令 +// +// @Brief 构建国内高防 ip list 子命令,调用 GetBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newList(ctx *cli.Context) *cobra.Command { + var resourceID, bgpIP string + var offset, limit int + + cmd := &cobra.Command{ + Use: "list", + Short: "List mainland BGP high-protection IPs", + Long: "List BGP DDoS protection IP addresses for a mainland service instance", + Example: " ucloud uddos mainland ip list --resource-id ghp-xxxxx", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "GetBGPServiceIP", + "ResourceId": resourceID, + "Offset": offset, + "Limit": limit, + } + if bgpIP != "" { + params["BgpIP"] = bgpIP + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("GetBGPServiceIP: %w", err)) + return + } + payload := resp.GetPayload() + gameIPInfo, _ := payload["GameIPInfo"].([]interface{}) + rows := make([]IPRow, 0, len(gameIPInfo)) + for _, item := range gameIPInfo { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, IPRow{ + DefenceIP: strVal(m, "DefenceIP"), + UserIP: strVal(m, "UserIP"), + LineType: strVal(m, "LineType"), + Status: strVal(m, "Status"), + Cname: strVal(m, "Cname"), + RuleCnt: intVal(m, "RuleCnt"), + DefenceDDosBaseFlow: intVal(m, "DefenceDDosBaseFlow"), + DefenceDDosMaxFlow: intVal(m, "DefenceDDosMaxFlow"), + Remark: strVal(m, "Remark"), + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&bgpIP, "bgp-ip", "", "Optional. Filter by BGP IP address") + flags.IntVar(&offset, "offset", 0, "Optional. Page offset, default 0") + flags.IntVar(&limit, "limit", 20, "Optional. Page size, default 20") + cmd.MarkFlagRequired("resource-id") + return cmd +} diff --git a/products/uddos/internal/mainland/ip/rows.go b/products/uddos/internal/mainland/ip/rows.go new file mode 100644 index 0000000000..3b8b6271dc --- /dev/null +++ b/products/uddos/internal/mainland/ip/rows.go @@ -0,0 +1,27 @@ +// Package ip ... +// +// @Brief 国内高防IP列表行结构体定义 +// +// @File rows.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +// IPRow 高防IP列表行 +type IPRow struct { + DefenceIP string + UserIP string + LineType string + Status string + Cname string + RuleCnt int + DefenceDDosBaseFlow int + DefenceDDosMaxFlow int + Remark string +} diff --git a/products/uddos/internal/mainland/rule/cmd.go b/products/uddos/internal/mainland/rule/cmd.go new file mode 100644 index 0000000000..8bd9b59093 --- /dev/null +++ b/products/uddos/internal/mainland/rule/cmd.go @@ -0,0 +1,57 @@ +// Package rule ... +// +// @Brief 国内高防转发规则管理命令聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand 构建 uddos mainland rule 命令组 +// +// @Brief 构建国内高防 rule 命令组并挂载子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "rule", + Short: "Manage BGP forwarding rules", + Long: "List, create, delete and update BGP DDoS protection forwarding rules for mainland services", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newUpdate(ctx)) + return cmd +} + +func strVal(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func intVal(m map[string]interface{}, key string) int { + v, _ := m[key].(float64) + return int(v) +} diff --git a/products/uddos/internal/mainland/rule/create.go b/products/uddos/internal/mainland/rule/create.go new file mode 100644 index 0000000000..1217404314 --- /dev/null +++ b/products/uddos/internal/mainland/rule/create.go @@ -0,0 +1,101 @@ +// Package rule ... +// +// @Brief 创建国内高防转发规则命令 +// +// @File create.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newCreate 构建 uddos mainland rule create 命令 +// +// @Brief 构建国内高防 rule create 子命令,调用 CreateBGPServiceFwdRule +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newCreate(ctx *cli.Context) *cobra.Command { + var resourceID, sourceIP, bgpIP, loadBalance, fwdType, remark string + var bgpIPPort, sourceDetect int + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a BGP forwarding rule", + Long: "Create a forwarding rule for a mainland BGP DDoS protection service", + Example: " ucloud uddos mainland rule create --resource-id ghp-xxxxx --bgp-ip 103.216.x.x --source-ip 10.0.0.1", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "CreateBGPServiceFwdRule", + "ResourceId": resourceID, + "BgpIP": bgpIP, + "LoadBalance": loadBalance, + "FwdType": fwdType, + "BgpIPPort": bgpIPPort, + "SourceDetect": sourceDetect, + } + if cmd.Flags().Changed("source-ip") { + params["SourceAddrArr"] = []string{sourceIP} + params["SourcePortArr"] = []string{"0"} + params["SourceToaIDArr"] = []string{"0"} + } + if cmd.Flags().Changed("remark") { + params["Remark"] = remark + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("CreateBGPServiceFwdRule: %w", err)) + return + } + payload := resp.GetPayload() + ruleIndex := intVal(payload, "RuleIndex") + fmt.Fprintf(ctx.ProgressWriter(), "rule[%d] created for service[%s]\n", ruleIndex, resourceID) + ctx.EmitResult(cli.OpResultRow{ + ResourceID: strconv.Itoa(ruleIndex), + Action: "create", + Status: "Created", + }) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&bgpIP, "bgp-ip", "", "Required. BGP IP address for this rule") + flags.StringVar(&sourceIP, "source-ip", "", "Required. Origin server IP address") + flags.StringVar(&loadBalance, "load-balance", "No", "Optional. Enable load balance: Yes or No, default No") + flags.StringVar(&fwdType, "fwd-type", "IP", "Optional. Forwarding protocol: IP, TCP or UDP, default IP") + flags.IntVar(&bgpIPPort, "bgp-ip-port", 0, "Optional. BGP IP port (0 for IP protocol)") + flags.IntVar(&sourceDetect, "source-detect", 0, "Optional. Source detection: 0=disabled, 1=enabled, default 0") + flags.StringVar(&remark, "remark", "", "Optional. Remark for this rule") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("bgp-ip") + cmd.MarkFlagRequired("source-ip") + return cmd +} diff --git a/products/uddos/internal/mainland/rule/delete.go b/products/uddos/internal/mainland/rule/delete.go new file mode 100644 index 0000000000..590f497150 --- /dev/null +++ b/products/uddos/internal/mainland/rule/delete.go @@ -0,0 +1,89 @@ +// Package rule ... +// +// @Brief 删除国内高防转发规则命令 +// +// @File delete.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete 构建 uddos mainland rule delete 命令 +// +// @Brief 构建国内高防 rule delete 子命令,调用 DeleteBGPServiceFwdRule +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newDelete(ctx *cli.Context) *cobra.Command { + var resourceID string + var ruleIndex int + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a BGP forwarding rule", + Long: "Delete a forwarding rule from a mainland BGP DDoS protection service", + Example: " ucloud uddos mainland rule delete --resource-id ghp-xxxxx --rule-index 0", + Run: func(cmd *cobra.Command, args []string) { + confirmed, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure to delete rule index %d from service %s?", ruleIndex, resourceID)) + if err != nil { + ctx.HandleError(fmt.Errorf("confirm: %w", err)) + return + } + if !confirmed { + return + } + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DeleteBGPServiceFwdRule", + "ResourceId": resourceID, + "RuleIndex": ruleIndex, + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + _, invokeErr := client.GenericInvoke(req) + if invokeErr != nil { + ctx.HandleError(fmt.Errorf("DeleteBGPServiceFwdRule: %w", invokeErr)) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "rule[%d] deleted from service[%s]\n", ruleIndex, resourceID) + ctx.EmitResult(cli.OpResultRow{ + ResourceID: fmt.Sprintf("%d", ruleIndex), + Action: "delete", + Status: "Deleted", + }) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.IntVar(&ruleIndex, "rule-index", 0, "Required. Rule index to delete") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip confirmation prompt") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("rule-index") + return cmd +} diff --git a/products/uddos/internal/mainland/rule/list.go b/products/uddos/internal/mainland/rule/list.go new file mode 100644 index 0000000000..6016116f59 --- /dev/null +++ b/products/uddos/internal/mainland/rule/list.go @@ -0,0 +1,104 @@ +// Package rule ... +// +// @Brief 查询国内高防转发规则列表命令 +// +// @File list.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList 构建 uddos mainland rule list 命令 +// +// @Brief 构建国内高防 rule list 子命令,调用 GetNapFwdRule +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newList(ctx *cli.Context) *cobra.Command { + var resourceID, bgpIP string + var ruleIndex, offset, limit int + + cmd := &cobra.Command{ + Use: "list", + Short: "List BGP forwarding rules", + Long: "List forwarding rules for a mainland BGP DDoS protection service", + Example: " ucloud uddos mainland rule list --resource-id ghp-xxxxx", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "GetBGPServiceFwdRule", + "ResourceId": resourceID, + "Offset": offset, + "Limit": limit, + } + if cmd.Flags().Changed("rule-index") { + params["RuleIndex"] = ruleIndex + } + if bgpIP != "" { + params["NapIP"] = bgpIP + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("GetBGPServiceFwdRule: %w", err)) + return + } + payload := resp.GetPayload() + ruleInfo, _ := payload["RuleInfo"].([]interface{}) + rows := make([]RuleRow, 0, len(ruleInfo)) + for _, item := range ruleInfo { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, RuleRow{ + RuleIndex: strconv.Itoa(intVal(m, "RuleIndex")), + RuleID: strVal(m, "RuleID"), + BgpIP: strVal(m, "BgpIP"), + SourceIP: strVal(m, "SourceIPInfo"), + FwdType: strVal(m, "FwdType"), + BgpIPPort: strconv.Itoa(intVal(m, "BgpIPPort")), + LoadBalance: strVal(m, "LoadBalance"), + SourceDetect: strconv.Itoa(intVal(m, "SourceDetect")), + Remark: strVal(m, "Remark"), + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&bgpIP, "bgp-ip", "", "Optional. Filter by BGP IP address") + flags.IntVar(&ruleIndex, "rule-index", 0, "Optional. Filter by rule index") + flags.IntVar(&offset, "offset", 0, "Optional. Page offset, default 0") + flags.IntVar(&limit, "limit", 32, "Optional. Page size, default 32") + cmd.MarkFlagRequired("resource-id") + return cmd +} diff --git a/products/uddos/internal/mainland/rule/rows.go b/products/uddos/internal/mainland/rule/rows.go new file mode 100644 index 0000000000..8b60472980 --- /dev/null +++ b/products/uddos/internal/mainland/rule/rows.go @@ -0,0 +1,27 @@ +// Package rule ... +// +// @Brief 国内高防转发规则列表行结构体定义 +// +// @File rows.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +// RuleRow 转发规则列表行 +type RuleRow struct { + RuleIndex string + RuleID string + BgpIP string + SourceIP string + FwdType string + BgpIPPort string + LoadBalance string + SourceDetect string + Remark string +} diff --git a/products/uddos/internal/mainland/rule/update.go b/products/uddos/internal/mainland/rule/update.go new file mode 100644 index 0000000000..760bb6b763 --- /dev/null +++ b/products/uddos/internal/mainland/rule/update.go @@ -0,0 +1,101 @@ +// Package rule ... +// +// @Brief 更新国内高防转发规则命令 +// +// @File update.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUpdate 构建 uddos mainland rule update 命令 +// +// @Brief 构建国内高防 rule update 子命令,调用 UpdateBGPServiceFwdRule +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newUpdate(ctx *cli.Context) *cobra.Command { + var resourceID, sourceIP, bgpIP, loadBalance, fwdType, ruleID string + var ruleIndex, bgpIPPort, sourceDetect int + + cmd := &cobra.Command{ + Use: "update", + Short: "Update a BGP forwarding rule", + Long: "Update an existing forwarding rule in a mainland BGP DDoS protection service", + Example: " ucloud uddos mainland rule update --resource-id ghp-xxxxx --bgp-ip 1.2.3.4 --rule-index 0 --source-ip 10.0.0.2", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "UpdateBGPServiceFwdRule", + "ResourceId": resourceID, + "BgpIP": bgpIP, + "RuleIndex": ruleIndex, + "LoadBalance": loadBalance, + "FwdType": fwdType, + "BgpIPPort": bgpIPPort, + "SourceDetect": sourceDetect, + } + if cmd.Flags().Changed("source-ip") { + params["SourceAddrArr"] = []string{sourceIP} + params["SourcePortArr"] = []string{"0"} + params["SourceToaIDArr"] = []string{"0"} + } + if cmd.Flags().Changed("rule-id") { + params["RuleID"] = ruleID + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + _, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("UpdateBGPServiceFwdRule: %w", err)) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "rule[%d] updated for service[%s]\n", ruleIndex, resourceID) + ctx.EmitResult(cli.OpResultRow{ + ResourceID: strconv.Itoa(ruleIndex), + Action: "update", + Status: "Updated", + }) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&bgpIP, "bgp-ip", "", "Required. BGP IP address of the rule") + flags.IntVar(&ruleIndex, "rule-index", 0, "Required. Rule index to update") + flags.StringVar(&sourceIP, "source-ip", "", "Optional. New origin server IP address") + flags.StringVar(&ruleID, "rule-id", "", "Optional. Rule ID (alternative to rule-index)") + flags.StringVar(&loadBalance, "load-balance", "No", "Optional. Enable load balance: Yes or No") + flags.StringVar(&fwdType, "fwd-type", "IP", "Optional. Forwarding protocol: IP, TCP or UDP") + flags.IntVar(&bgpIPPort, "bgp-ip-port", 0, "Optional. BGP IP port") + flags.IntVar(&sourceDetect, "source-detect", 0, "Optional. Source detection: 0=disabled, 1=enabled") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("bgp-ip") + cmd.MarkFlagRequired("rule-index") + return cmd +} diff --git a/products/uddos/internal/mainland/service/cmd.go b/products/uddos/internal/mainland/service/cmd.go new file mode 100644 index 0000000000..13ca062312 --- /dev/null +++ b/products/uddos/internal/mainland/service/cmd.go @@ -0,0 +1,55 @@ +// Package service ... +// +// @Brief 国内高防服务管理命令聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand 构建 uddos mainland service 命令组 +// +// @Brief 构建国内高防 service 命令组并挂载子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "service", + Short: "Manage mainland DDoS protection service instances", + Long: "List and create mainland China DDoS high-protection service instances", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + return cmd +} + +func strVal(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func intVal(m map[string]interface{}, key string) int { + v, _ := m[key].(float64) + return int(v) +} diff --git a/products/uddos/internal/mainland/service/create.go b/products/uddos/internal/mainland/service/create.go new file mode 100644 index 0000000000..8aa673b50f --- /dev/null +++ b/products/uddos/internal/mainland/service/create.go @@ -0,0 +1,218 @@ +// Package service ... +// +// @Brief 创建国内高防服务命令 +// +// @File create.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// mainlandAreaLineEngineRooms 国内 area-line → 合法 engine-room 列表 +var mainlandAreaLineEngineRooms = map[string][]string{ + "EastChina": {"Zaozhuang", "Yangzhou"}, + "NorthChina": {"Shijiazhuang"}, +} + +// newCreate 构建 uddos mainland service create 命令 +// +// @Brief 构建国内高防 service create 子命令,调用 BuyHighProtectGameService(国内参数) +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newCreate(ctx *cli.Context) *cobra.Command { + var chargeType, areaLine, engineRoom, name string + var quantity, srcBandwidth, defenceBaseFlow, defenceMaxFlow int + var async bool + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a mainland DDoS high-protection service", + Long: "Create a mainland China DDoS high-protection service via BuyHighProtectGameService (ForwardType=Proxy).", + Example: ` Area line / engine room pairings: + --area-line --engine-room + EastChina Zaozhuang / Yangzhou + NorthChina Shijiazhuang + + Defence flow valid values (Gbps): 30/40/50/60/70/80/100/200/300/400/500/600/700/800 + --defence-max-flow must be >= --defence-base-flow + + # Create a mainland service (East China, Zaozhuang) + ucloud uddos mainland service create --charge-type Month --quantity 1 \ + --area-line EastChina --engine-room Zaozhuang --src-bandwidth 100 \ + --defence-base-flow 30 --defence-max-flow 50 --name my-service + + # Create a mainland service (North China, Shijiazhuang) + ucloud uddos mainland service create --charge-type Month --quantity 1 \ + --area-line NorthChina --engine-room Shijiazhuang --src-bandwidth 100 \ + --defence-base-flow 30 --defence-max-flow 30 --name my-service`, + Run: func(cmd *cobra.Command, args []string) { + if chargeType != "Month" && chargeType != "Year" { + ctx.HandleError(fmt.Errorf(`invalid --charge-type %q, must be "Month" or "Year"`, chargeType)) + return + } + + validRooms, isValidAreaLine := mainlandAreaLineEngineRooms[areaLine] + if !isValidAreaLine { + ctx.HandleError(fmt.Errorf(`invalid --area-line %q, must be "EastChina" or "NorthChina"`, areaLine)) + return + } + validSet := make(map[string]bool, len(validRooms)) + for _, r := range validRooms { + validSet[r] = true + } + if !validSet[engineRoom] { + desc := "" + for i, r := range validRooms { + if i > 0 { + desc += "/" + } + desc += r + } + ctx.HandleError(fmt.Errorf( + "invalid --engine-room %q for --area-line %q, valid values: %s", + engineRoom, areaLine, desc, + )) + return + } + + if srcBandwidth < 50 { + ctx.HandleError(fmt.Errorf("--src-bandwidth minimum is 50 for mainland, got %d", srcBandwidth)) + return + } + if srcBandwidth%10 != 0 { + ctx.HandleError(fmt.Errorf("--src-bandwidth must be a multiple of 10 for mainland, got %d", srcBandwidth)) + return + } + + validFlows := map[int]bool{ + 30: true, 40: true, 50: true, 60: true, 70: true, 80: true, + 100: true, 200: true, 300: true, 400: true, 500: true, + 600: true, 700: true, 800: true, + } + const validFlowDesc = "30/40/50/60/70/80/100/200/300/400/500/600/700/800" + if !validFlows[defenceBaseFlow] { + ctx.HandleError(fmt.Errorf("invalid --defence-base-flow %d, must be one of: %s", defenceBaseFlow, validFlowDesc)) + return + } + if !validFlows[defenceMaxFlow] { + ctx.HandleError(fmt.Errorf("invalid --defence-max-flow %d, must be one of: %s", defenceMaxFlow, validFlowDesc)) + return + } + if defenceMaxFlow < defenceBaseFlow { + ctx.HandleError(fmt.Errorf("--defence-max-flow (%d) must be >= --defence-base-flow (%d)", defenceMaxFlow, defenceBaseFlow)) + return + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "BuyHighProtectGameService", + "ChargeType": chargeType, + "Quantity": quantity, + "LineType": "BGP", + "SrcBandwidth": srcBandwidth, + "EngineRoom": []string{engineRoom}, + "AreaLine": areaLine, + "ForwardType": "Proxy", + "DefenceDDosBaseFlowArr": []int{defenceBaseFlow}, + "DefenceDDosMaxFlowArr": []int{defenceMaxFlow}, + "HighProtectGameServiceName": name, + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("BuyHighProtectGameService: %w", err)) + return + } + payload := resp.GetPayload() + resInfo, _ := payload["ResourceInfo"].(map[string]interface{}) + resourceID := strVal(resInfo, "ResourceId") + if resourceID == "" { + ctx.HandleError(fmt.Errorf("BuyHighProtectGameService returned no ResourceId; the purchase may have failed, check the console")) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "mainland DDoS service created: %s\n", resourceID) + if !async { + ctx.PollerTo(ctx.ProgressWriter(), describeMainlandService(ctx)).Spoll( + resourceID, + fmt.Sprintf("service[%s] is initializing", resourceID), + []string{napServiceStatusStarted}, + ) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resourceID, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&chargeType, "charge-type", "", `Required. Billing type: "Month" or "Year"`) + flags.IntVar(&quantity, "quantity", 0, "Required. Billing duration") + flags.StringVar(&areaLine, "area-line", "", `Required. "EastChina" or "NorthChina"`) + flags.StringVar(&engineRoom, "engine-room", "", "Required. Zaozhuang/Yangzhou (EastChina) or Shijiazhuang (NorthChina)") + flags.IntVar(&srcBandwidth, "src-bandwidth", 0, "Required. Source bandwidth (Mbps), min 50, multiple of 10") + flags.IntVar(&defenceBaseFlow, "defence-base-flow", 0, "Required. Base defence flow (Gbps): 30/40/50/60/70/80/100/200/300/400/500/600/700/800") + flags.IntVar(&defenceMaxFlow, "defence-max-flow", 0, "Required. Max defence flow (Gbps), must be >= defence-base-flow") + flags.StringVar(&name, "name", "", "Required. Service name") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for the service to become available.") + cmd.MarkFlagRequired("charge-type") + cmd.MarkFlagRequired("quantity") + cmd.MarkFlagRequired("area-line") + cmd.MarkFlagRequired("engine-room") + cmd.MarkFlagRequired("src-bandwidth") + cmd.MarkFlagRequired("defence-base-flow") + cmd.MarkFlagRequired("defence-max-flow") + cmd.MarkFlagRequired("name") + return cmd +} + +// describeMainlandService 返回 poller 用的服务状态查询函数, +// 调用 DescribeHighProtectGameServiceInfo 按 ResourceId 查询,返回带 Status 字段的结构体。 +func describeMainlandService(ctx *cli.Context) func(string, *request.CommonBase) (interface{}, error) { + return func(id string, _ *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(map[string]interface{}{ + "Action": "DescribeHighProtectGameServiceInfo", + "ResourceId": id, + "Offset": 0, + "Limit": 1, + }); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, fmt.Errorf("DescribeHighProtectGameServiceInfo: %w", err) + } + gameInfo, _ := resp.GetPayload()["GameInfo"].([]interface{}) + if len(gameInfo) == 0 { + return nil, nil // 尚未可见,poller 视为 pending 继续轮询 + } + m, _ := gameInfo[0].(map[string]interface{}) + return &serviceStatusRow{Status: strVal(m, "DefenceStatus")}, nil + } +} diff --git a/products/uddos/internal/mainland/service/list.go b/products/uddos/internal/mainland/service/list.go new file mode 100644 index 0000000000..74ed20806a --- /dev/null +++ b/products/uddos/internal/mainland/service/list.go @@ -0,0 +1,100 @@ +// Package service ... +// +// @Brief 查询国内高防服务列表命令 +// +// @File list.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList 构建 uddos mainland service list 命令 +// +// @Brief 构建国内高防 service list 子命令,调用 DescribeHighProtectGameServiceInfo +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newList(ctx *cli.Context) *cobra.Command { + var resourceID string + var offset, limit int + + cmd := &cobra.Command{ + Use: "list", + Short: "List mainland DDoS protection service instances", + Long: "List mainland China DDoS high-protection service instances via DescribeHighProtectGameServiceInfo.", + Example: ` # List all mainland services + ucloud uddos mainland service list + + # Filter by resource ID + ucloud uddos mainland service list --resource-id ghp-xxxxx`, + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DescribeHighProtectGameServiceInfo", + "Offset": offset, + "Limit": limit, + } + if resourceID != "" { + params["ResourceId"] = resourceID + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("DescribeHighProtectGameServiceInfo: %w", err)) + return + } + payload := resp.GetPayload() + gameInfo, _ := payload["GameInfo"].([]interface{}) + rows := make([]ServiceRow, 0, len(gameInfo)) + for _, item := range gameInfo { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + expireTime := "" + if ts := intVal(m, "ExpiredTime"); ts > 0 { + expireTime = time.Unix(int64(ts), 0).Format("2006-01-02 15:04:05") + } + rows = append(rows, ServiceRow{ + ResourceID: strVal(m, "ResourceId"), + Name: strVal(m, "HighProtectGameServiceName"), + DefenceStatus: strVal(m, "DefenceStatus"), + ExpireTime: expireTime, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Optional. Filter by resource ID") + flags.IntVar(&offset, "offset", 0, "Optional. Page offset, default 0") + flags.IntVar(&limit, "limit", 20, "Optional. Page size, default 20") + return cmd +} diff --git a/products/uddos/internal/mainland/service/rows.go b/products/uddos/internal/mainland/service/rows.go new file mode 100644 index 0000000000..3ec9d31899 --- /dev/null +++ b/products/uddos/internal/mainland/service/rows.go @@ -0,0 +1,22 @@ +// Package service ... +// +// @Brief 国内高防服务列表行结构体定义 +// +// @File rows.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +// ServiceRow 国内高防服务列表行 +type ServiceRow struct { + ResourceID string + Name string + DefenceStatus string + ExpireTime string +} diff --git a/products/uddos/internal/mainland/service/status.go b/products/uddos/internal/mainland/service/status.go new file mode 100644 index 0000000000..9b0a6ff82f --- /dev/null +++ b/products/uddos/internal/mainland/service/status.go @@ -0,0 +1,28 @@ +// Package service ... +// +// @Brief 国内高防服务生命周期状态定义与轮询辅助 +// +// @File status.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/14 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +// 服务生命周期状态:API 响应字段 DefenceStatus 为字符串,由 nap-api 的 +// NapServiceStatus2Str 映射(NAP_SERVICE_STATUS_IS_NORMAL=1 -> "Started" 等)。 +const ( + napServiceStatusStarted = "Started" // NAP_SERVICE_STATUS_IS_NORMAL(1):创建完成、可用 + napServiceStatusStopped = "Stopped" // NAP_SERVICE_STATUS_IS_STOPPED(2):已停用 + napServiceStatusExpired = "Expired" // NAP_SERVICE_STATUS_IS_EXPIRED(3):已过期 +) + +// serviceStatusRow 是 poller 反射读取的最小结构体:它读取 Status 字段(字符串) +// 与 targetStates 比较(见 pkg/cli/poller.go state(),仅识别 State/Status 字段)。 +type serviceStatusRow struct { + Status string +} diff --git a/products/uddos/internal/overseas/cmd.go b/products/uddos/internal/overseas/cmd.go new file mode 100644 index 0000000000..5f91a4d631 --- /dev/null +++ b/products/uddos/internal/overseas/cmd.go @@ -0,0 +1,47 @@ +// Package overseas ... +// +// @Brief 海外高防命令组聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package overseas + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + overseasip "github.com/ucloud/ucloud-cli/products/uddos/internal/overseas/ip" + overseassvc "github.com/ucloud/ucloud-cli/products/uddos/internal/overseas/service" +) + +// NewCommand 构建 uddos overseas 命令组 +// +// @Brief 构建海外高防命令组并挂载 service、ip、rule 子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "overseas", + Short: "Manage overseas DDoS high-protection services", + Long: "Manage UCloud overseas (海外高防) DDoS protection services and IPs", + Args: cobra.NoArgs, + } + cmd.AddCommand(overseasip.NewCommand(ctx)) + cmd.AddCommand(overseassvc.NewCommand(ctx)) + return cmd +} diff --git a/products/uddos/internal/overseas/ip/cmd.go b/products/uddos/internal/overseas/ip/cmd.go new file mode 100644 index 0000000000..9af27d1ac9 --- /dev/null +++ b/products/uddos/internal/overseas/ip/cmd.go @@ -0,0 +1,56 @@ +// Package ip ... +// +// @Brief 海外高防IP管理命令聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand 构建 uddos overseas ip 命令组 +// +// @Brief 构建海外高防 ip 命令组并挂载子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ip", + Short: "Manage overseas DDoS protection IPs", + Long: "List, create and delete overseas BGP DDoS protection IPs", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + return cmd +} + +func strVal(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func intVal(m map[string]interface{}, key string) int { + v, _ := m[key].(float64) + return int(v) +} diff --git a/products/uddos/internal/overseas/ip/create.go b/products/uddos/internal/overseas/ip/create.go new file mode 100644 index 0000000000..747e39026c --- /dev/null +++ b/products/uddos/internal/overseas/ip/create.go @@ -0,0 +1,163 @@ +// Package ip ... +// +// @Brief 创建海外高防IP命令 +// +// @File create.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newCreate 构建 uddos overseas ip create 命令 +// +// @Brief 构建海外高防 ip create 子命令,调用 CreateBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newCreate(ctx *cli.Context) *cobra.Command { + var resourceID, typeIP, remark, tag string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an overseas BGP high-protection IP", + Long: "Create a new BGP DDoS protection IP for the specified overseas service", + Example: " ucloud uddos overseas ip create --resource-id nap-xxxxx", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + + resolvedEIPRegion, err := lookupEIPRegion(client, resourceID) + if err != nil { + ctx.HandleError(err) + return + } + + params := map[string]interface{}{ + "Action": "CreateBGPServiceIP", + "ResourceId": resourceID, + "TypeIP": typeIP, + "EIPRegion": resolvedEIPRegion, + } + if cmd.Flags().Changed("remark") { + params["Remark"] = remark + } + if cmd.Flags().Changed("tag") { + params["Tag"] = tag + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("CreateBGPServiceIP: %w", err)) + return + } + payload := resp.GetPayload() + defenceIP, _ := payload["DefenceIP"].(string) + fmt.Fprintf(ctx.ProgressWriter(), "BGP IP created: %s\n", defenceIP) + ctx.EmitResult(cli.OpResultRow{ResourceID: defenceIP, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&typeIP, "type-ip", "TypeFree", "Optional. IP type: TypeFree or TypeCharge, default TypeFree") + flags.StringVar(&remark, "remark", "", "Optional. Remark for this IP") + flags.StringVar(&tag, "tag", "", "Optional. Business group tag") + cmd.MarkFlagRequired("resource-id") + return cmd +} + +// lookupEIPRegion resolves the EIPRegion for an overseas service by querying +// DescribeHighProtectGameServiceInfo then GetNapServiceConfig. +func lookupEIPRegion(client *uaccount.UAccountClient, resourceID string) (string, error) { + // Step 1: fetch service details to get EngineRoom and LineType + svcReq := client.NewGenericRequest() + if err := svcReq.SetPayload(map[string]interface{}{ + "Action": "DescribeNapServiceInfo", + "ResourceId": resourceID, + "NapType": 2, // APAC / overseas + "Limit": 1, + }); err != nil { + return "", fmt.Errorf("DescribeNapServiceInfo set payload: %w", err) + } + svcResp, err := client.GenericInvoke(svcReq) + if err != nil { + return "", fmt.Errorf("DescribeNapServiceInfo: %w", err) + } + svcPayload := svcResp.GetPayload() + serviceInfo, _ := svcPayload["ServiceInfo"].([]interface{}) + if len(serviceInfo) == 0 { + return "", fmt.Errorf("service %s not found", resourceID) + } + svc, ok := serviceInfo[0].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected service info format") + } + // EngineRoom is returned as []interface{} (comma-split array); take the first element. + engineRoom := "" + if rooms, ok := svc["EngineRoom"].([]interface{}); ok && len(rooms) > 0 { + engineRoom, _ = rooms[0].(string) + } + lineType := strVal(svc, "LineType") + areaLine := strVal(svc, "AreaLine") + + // Step 2: fetch service config to get IpInfo region list + cfgReq := client.NewGenericRequest() + if err := cfgReq.SetPayload(map[string]interface{}{ + "Action": "GetNapServiceConfig", + "AreaLine": areaLine, + "EngineRoom": engineRoom, + "LineType": lineType, + }); err != nil { + return "", fmt.Errorf("GetNapServiceConfig set payload: %w", err) + } + cfgResp, err := client.GenericInvoke(cfgReq) + if err != nil { + return "", fmt.Errorf("GetNapServiceConfig: %w", err) + } + cfgPayload := cfgResp.GetPayload() + configs, _ := cfgPayload["NapServiceConfig"].([]interface{}) + if len(configs) == 0 { + return "", fmt.Errorf("no service config found for areaLine=%s engineRoom=%s lineType=%s", areaLine, engineRoom, lineType) + } + cfg, ok := configs[0].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected service config format") + } + ipInfoList, _ := cfg["IpInfo"].([]interface{}) + if len(ipInfoList) == 0 { + return "", fmt.Errorf("IpInfo is empty in service config") + } + first, ok := ipInfoList[0].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected IpInfo entry format") + } + region := strVal(first, "Region") + if region == "" { + return "", fmt.Errorf("IpInfo Region is empty") + } + return region, nil +} diff --git a/products/uddos/internal/overseas/ip/delete.go b/products/uddos/internal/overseas/ip/delete.go new file mode 100644 index 0000000000..dc368fbfb6 --- /dev/null +++ b/products/uddos/internal/overseas/ip/delete.go @@ -0,0 +1,84 @@ +// Package ip ... +// +// @Brief 删除海外高防IP命令 +// +// @File delete.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete 构建 uddos overseas ip delete 命令 +// +// @Brief 构建海外高防 ip delete 子命令,调用 DeleteBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newDelete(ctx *cli.Context) *cobra.Command { + var resourceID, defenceIP string + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete an overseas BGP high-protection IP", + Long: "Delete a BGP DDoS protection IP from the specified overseas service", + Example: " ucloud uddos overseas ip delete --resource-id nap-xxxxx --defence-ip 1.2.3.4", + Run: func(cmd *cobra.Command, args []string) { + confirmed, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure to delete defence IP %s from service %s?", defenceIP, resourceID)) + if err != nil { + ctx.HandleError(fmt.Errorf("confirm: %w", err)) + return + } + if !confirmed { + return + } + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DeleteBGPServiceIP", + "ResourceId": resourceID, + "DefenceIp": defenceIP, + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + _, invokeErr := client.GenericInvoke(req) + if invokeErr != nil { + ctx.HandleError(fmt.Errorf("DeleteBGPServiceIP: %w", invokeErr)) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "BGP IP deleted: %s\n", defenceIP) + ctx.EmitResult(cli.OpResultRow{ResourceID: defenceIP, Action: "delete", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&defenceIP, "defence-ip", "", "Required. BGP defence IP to delete") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip confirmation prompt") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("defence-ip") + return cmd +} diff --git a/products/uddos/internal/overseas/ip/list.go b/products/uddos/internal/overseas/ip/list.go new file mode 100644 index 0000000000..2dbd1aa81a --- /dev/null +++ b/products/uddos/internal/overseas/ip/list.go @@ -0,0 +1,102 @@ +// Package ip ... +// +// @Brief 查询海外高防IP列表命令 +// +// @File list.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList 构建 uddos overseas ip list 命令 +// +// @Brief 构建海外高防 ip list 子命令,调用 GetBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newList(ctx *cli.Context) *cobra.Command { + var resourceID, napIP string + var offset, limit int + + cmd := &cobra.Command{ + Use: "list", + Short: "List overseas BGP high-protection IPs", + Long: "List BGP DDoS protection IP addresses for an overseas service instance (Passthrough mode)", + Example: " ucloud uddos overseas ip list --resource-id nap-xxxxx", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DescribePassthroughNapIP", + "ResourceId": resourceID, + "Offset": offset, + "Limit": limit, + } + if napIP != "" { + params["NapIp"] = napIP + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("DescribePassthroughNapIP: %w", err)) + return + } + payload := resp.GetPayload() + ipInfo, _ := payload["IPInfo"].([]interface{}) + rows := make([]IPRow, 0, len(ipInfo)) + for _, item := range ipInfo { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + eipIP := "" + if addrs, ok := m["EIPAddr"].([]interface{}); ok && len(addrs) > 0 { + if first, ok := addrs[0].(map[string]interface{}); ok { + eipIP = strVal(first, "IP") + } + } + rows = append(rows, IPRow{ + EIPIP: eipIP, + EIPID: strVal(m, "EIPId"), + Status: strVal(m, "Status"), + EIPRegion: strVal(m, "EIPRegion"), + Tag: strVal(m, "Tag"), + Remark: strVal(m, "Remark"), + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&napIP, "nap-ip", "", "Optional. Filter by NAP IP address") + flags.IntVar(&offset, "offset", 0, "Optional. Page offset, default 0") + flags.IntVar(&limit, "limit", 20, "Optional. Page size, default 20") + cmd.MarkFlagRequired("resource-id") + return cmd +} diff --git a/products/uddos/internal/overseas/ip/rows.go b/products/uddos/internal/overseas/ip/rows.go new file mode 100644 index 0000000000..b4749e4af0 --- /dev/null +++ b/products/uddos/internal/overseas/ip/rows.go @@ -0,0 +1,24 @@ +// Package ip ... +// +// @Brief 海外高防IP列表行结构体定义 +// +// @File rows.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +// IPRow 高防IP列表行(透传模式) +type IPRow struct { + EIPIP string + EIPID string + Status string + EIPRegion string + Tag string + Remark string +} diff --git a/products/uddos/internal/overseas/service/cmd.go b/products/uddos/internal/overseas/service/cmd.go new file mode 100644 index 0000000000..6620075f98 --- /dev/null +++ b/products/uddos/internal/overseas/service/cmd.go @@ -0,0 +1,55 @@ +// Package service ... +// +// @Brief 海外高防服务管理命令聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand 构建 uddos overseas service 命令组 +// +// @Brief 构建海外高防 service 命令组并挂载子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "service", + Short: "Manage overseas DDoS protection service instances", + Long: "List and create overseas DDoS high-protection service instances", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + return cmd +} + +func strVal(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func intVal(m map[string]interface{}, key string) int { + v, _ := m[key].(float64) + return int(v) +} diff --git a/products/uddos/internal/overseas/service/create.go b/products/uddos/internal/overseas/service/create.go new file mode 100644 index 0000000000..dae189eb09 --- /dev/null +++ b/products/uddos/internal/overseas/service/create.go @@ -0,0 +1,204 @@ +// Package service ... +// +// @Brief 创建海外高防服务命令 +// +// @File create.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// overseasCityToCleaningCenter 海外城市 → 清洗中心(API EngineRoom 值) +var overseasCityToCleaningCenter = map[string]string{ + // 亚太:HongKong 清洗中心 + "HongKong": "HongKong", + "Taipei": "HongKong", + "Singapore": "HongKong", + "Tokyo": "HongKong", + "Seoul": "HongKong", + "Bangkok": "HongKong", + "HoChiMinh": "HongKong", + "Jakarta": "HongKong", + "Manila": "HongKong", + "Mumbai": "HongKong", + // 欧洲:Frankfurt 清洗中心 + "Frankfurt": "Frankfurt", + "London": "Frankfurt", + "Moscow": "Frankfurt", + // 北美:Ashburn 清洗中心 + "Ashburn": "Ashburn", + "LosAngeles": "Ashburn", + "Washington": "Ashburn", +} + +// newCreate 构建 uddos overseas service create 命令 +// +// @Brief 构建海外高防 service create 子命令,调用 BuyHighProtectGameService(海外参数,ForwardType=Passthrough) +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newCreate(ctx *cli.Context) *cobra.Command { + var chargeType, areaLine, name string + var quantity, srcBandwidth int + var async bool + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an overseas DDoS high-protection service", + Long: "Create an overseas DDoS high-protection service via BuyHighProtectGameService (ForwardType=Passthrough). Defence flow is fixed at 50 Gbps.", + Example: ` AreaLine (--area-line) EngineRoom (API) + HongKong / Taipei / Singapore / Tokyo / HongKong + Seoul / Bangkok / HoChiMinh / Jakarta / + Manila / Mumbai + Frankfurt / London / Moscow Frankfurt + Ashburn / LosAngeles / Washington Ashburn + + src-bandwidth rules (Mbps): <=300 step 50, 300~1000 step 100, 1000~5000 step 500 + + # Create an overseas service (Asia Pacific, HongKong cleaning center) + ucloud uddos overseas service create --charge-type Month --quantity 1 \ + --area-line HongKong --src-bandwidth 100 --name my-service + + # Create an overseas service (Europe, Frankfurt cleaning center) + ucloud uddos overseas service create --charge-type Month --quantity 1 \ + --area-line Frankfurt --src-bandwidth 100 --name my-service`, + Run: func(cmd *cobra.Command, args []string) { + if chargeType != "Month" && chargeType != "Year" { + ctx.HandleError(fmt.Errorf(`invalid --charge-type %q, must be "Month" or "Year"`, chargeType)) + return + } + + cleaningCenter, ok := overseasCityToCleaningCenter[areaLine] + if !ok { + ctx.HandleError(fmt.Errorf( + "invalid --area-line %q; valid values: HongKong/Taipei/Singapore/Tokyo/Seoul/Bangkok/HoChiMinh/Jakarta/Manila/Mumbai/Frankfurt/London/Moscow/Ashburn/LosAngeles/Washington", + areaLine, + )) + return + } + + switch { + case srcBandwidth < 50: + ctx.HandleError(fmt.Errorf("--src-bandwidth minimum is 50 for overseas")) + return + case srcBandwidth > 5000: + ctx.HandleError(fmt.Errorf("--src-bandwidth maximum is 5000 for overseas")) + return + case srcBandwidth <= 300 && srcBandwidth%50 != 0: + ctx.HandleError(fmt.Errorf("--src-bandwidth must be a multiple of 50 when <= 300 (overseas), got %d", srcBandwidth)) + return + case srcBandwidth > 300 && srcBandwidth <= 1000 && srcBandwidth%100 != 0: + ctx.HandleError(fmt.Errorf("--src-bandwidth must be a multiple of 100 when 300~1000 (overseas), got %d", srcBandwidth)) + return + case srcBandwidth > 1000 && srcBandwidth <= 5000 && srcBandwidth%500 != 0: + ctx.HandleError(fmt.Errorf("--src-bandwidth must be a multiple of 500 when 1000~5000 (overseas), got %d", srcBandwidth)) + return + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "BuyHighProtectGameService", + "ChargeType": chargeType, + "Quantity": quantity, + "LineType": "BGP", + "SrcBandwidth": srcBandwidth, + "EngineRoom": []string{cleaningCenter}, + "AreaLine": areaLine, + "ForwardType": "Passthrough", + "DefenceDDosBaseFlowArr": []int{50}, + "DefenceDDosMaxFlowArr": []int{50}, + "HighProtectGameServiceName": name, + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("BuyHighProtectGameService: %w", err)) + return + } + payload := resp.GetPayload() + resInfo, _ := payload["ResourceInfo"].(map[string]interface{}) + resourceID := strVal(resInfo, "ResourceId") + if resourceID == "" { + ctx.HandleError(fmt.Errorf("BuyHighProtectGameService returned no ResourceId; the purchase may have failed, check the console")) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "overseas DDoS service created: %s\n", resourceID) + if !async { + ctx.PollerTo(ctx.ProgressWriter(), describeOverseasService(ctx)).Spoll( + resourceID, + fmt.Sprintf("service[%s] is initializing", resourceID), + []string{napServiceStatusStarted}, + ) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resourceID, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&chargeType, "charge-type", "", `Required. Billing type: "Month" or "Year"`) + flags.IntVar(&quantity, "quantity", 0, "Required. Billing duration") + flags.StringVar(&areaLine, "area-line", "", "Required. AreaLine: HongKong/Frankfurt/Ashburn and their coverage cities (see --help)") + flags.IntVar(&srcBandwidth, "src-bandwidth", 0, "Required. Source bandwidth (Mbps). <=300 step 50, 300~1000 step 100, 1000~5000 step 500") + flags.StringVar(&name, "name", "", "Required. Service name") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for the service to become available.") + cmd.MarkFlagRequired("charge-type") + cmd.MarkFlagRequired("quantity") + cmd.MarkFlagRequired("area-line") + cmd.MarkFlagRequired("src-bandwidth") + cmd.MarkFlagRequired("name") + return cmd +} + +// describeOverseasService 返回 poller 用的服务状态查询函数, +// 调用 DescribeNapServiceInfo(NapType=2)按 ResourceId 查询,返回带 Status 字段的结构体。 +func describeOverseasService(ctx *cli.Context) func(string, *request.CommonBase) (interface{}, error) { + return func(id string, _ *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(map[string]interface{}{ + "Action": "DescribeNapServiceInfo", + "NapType": 2, + "ResourceId": id, + "Offset": 0, + "Limit": 1, + }); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, fmt.Errorf("DescribeNapServiceInfo: %w", err) + } + serviceInfo, _ := resp.GetPayload()["ServiceInfo"].([]interface{}) + if len(serviceInfo) == 0 { + return nil, nil // 尚未可见,poller 视为 pending 继续轮询 + } + m, _ := serviceInfo[0].(map[string]interface{}) + return &serviceStatusRow{Status: strVal(m, "DefenceStatus")}, nil + } +} diff --git a/products/uddos/internal/overseas/service/list.go b/products/uddos/internal/overseas/service/list.go new file mode 100644 index 0000000000..c333a2a38f --- /dev/null +++ b/products/uddos/internal/overseas/service/list.go @@ -0,0 +1,102 @@ +// Package service ... +// +// @Brief 查询海外高防服务列表命令 +// +// @File list.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList 构建 uddos overseas service list 命令 +// +// @Brief 构建海外高防 service list 子命令,调用 DescribeNapServiceInfo(NapType=2) +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newList(ctx *cli.Context) *cobra.Command { + var resourceID string + var offset, limit int + + cmd := &cobra.Command{ + Use: "list", + Short: "List overseas DDoS protection service instances", + Long: "List overseas DDoS high-protection service instances via DescribeNapServiceInfo (NapType=2).", + Example: ` # List all overseas services + ucloud uddos overseas service list + + # Filter by resource ID + ucloud uddos overseas service list --resource-id nap-xxxxx`, + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DescribeNapServiceInfo", + "NapType": 2, + "Offset": offset, + "Limit": limit, + } + if resourceID != "" { + params["ResourceId"] = resourceID + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("DescribeNapServiceInfo: %w", err)) + return + } + payload := resp.GetPayload() + serviceInfo, _ := payload["ServiceInfo"].([]interface{}) + rows := make([]ServiceRow, 0, len(serviceInfo)) + for _, item := range serviceInfo { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + expireTime := "" + if ts := intVal(m, "ExpiredTime"); ts > 0 { + expireTime = time.Unix(int64(ts), 0).Format("2006-01-02 15:04:05") + } + rows = append(rows, ServiceRow{ + ResourceID: strVal(m, "ResourceId"), + Name: strVal(m, "Name"), + DefenceStatus: strVal(m, "DefenceStatus"), + ExpireTime: expireTime, + Remark: strVal(m, "Remark"), + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Optional. Filter by resource ID") + flags.IntVar(&offset, "offset", 0, "Optional. Page offset, default 0") + flags.IntVar(&limit, "limit", 20, "Optional. Page size, default 20") + return cmd +} diff --git a/products/uddos/internal/overseas/service/rows.go b/products/uddos/internal/overseas/service/rows.go new file mode 100644 index 0000000000..23a0b15354 --- /dev/null +++ b/products/uddos/internal/overseas/service/rows.go @@ -0,0 +1,23 @@ +// Package service ... +// +// @Brief 海外高防服务列表行结构体定义 +// +// @File rows.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +// ServiceRow 海外高防服务列表行 +type ServiceRow struct { + ResourceID string + Name string + DefenceStatus string + ExpireTime string + Remark string +} diff --git a/products/uddos/internal/overseas/service/status.go b/products/uddos/internal/overseas/service/status.go new file mode 100644 index 0000000000..443378947c --- /dev/null +++ b/products/uddos/internal/overseas/service/status.go @@ -0,0 +1,28 @@ +// Package service ... +// +// @Brief 海外高防服务生命周期状态定义与轮询辅助 +// +// @File status.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/14 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +// 服务生命周期状态:API 响应字段 DefenceStatus 为字符串,由 nap-api 的 +// NapServiceStatus2Str 映射(NAP_SERVICE_STATUS_IS_NORMAL=1 -> "Started" 等)。 +const ( + napServiceStatusStarted = "Started" // NAP_SERVICE_STATUS_IS_NORMAL(1):创建完成、可用 + napServiceStatusStopped = "Stopped" // NAP_SERVICE_STATUS_IS_STOPPED(2):已停用 + napServiceStatusExpired = "Expired" // NAP_SERVICE_STATUS_IS_EXPIRED(3):已过期 +) + +// serviceStatusRow 是 poller 反射读取的最小结构体:它读取 Status 字段(字符串) +// 与 targetStates 比较(见 pkg/cli/poller.go state(),仅识别 State/Status 字段)。 +type serviceStatusRow struct { + Status string +} diff --git a/products/uddos/product.go b/products/uddos/product.go new file mode 100644 index 0000000000..eb29ae5fcf --- /dev/null +++ b/products/uddos/product.go @@ -0,0 +1,81 @@ +// Package uddos ... +// +// @Brief UDDoS 高防产品 CLI 入口 +// +// @File product.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/09 +// +// @CopyRights(C) UCloud All rights reserved. +package uddos + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/products/uddos/internal/mainland" + "github.com/ucloud/ucloud-cli/products/uddos/internal/overseas" +) + +type product struct{} + +// New returns the uddos product (registered via hack/gen-products). +// +// @Brief 创建 uddos 产品实例 +// +// @Param +// +// @Return cli.Product +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/09 +func New() cli.Product { return product{} } + +// Metadata returns the product metadata. +// +// @Brief 返回产品元数据 +// +// @Param +// +// @Return cli.Metadata +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/09 +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "uddos", Commands: []string{"uddos"}} +} + +// NewCommand builds the uddos root command and mounts subcommand groups. +// +// @Brief 构建 uddos 根命令及子命令组 +// +// @Param ctx *cli.Context +// +// @Return []*cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/09 +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + cmd := &cobra.Command{ + Use: "uddos", + Short: "Manage UCloud DDoS protection services", + Long: "Manage UCloud DDoS high-protection services for mainland China and overseas", + Args: cobra.NoArgs, + } + cmd.AddCommand(mainland.NewCommand(ctx)) + cmd.AddCommand(overseas.NewCommand(ctx)) + return []*cobra.Command{cmd} +} diff --git a/products/uddos/product.yaml b/products/uddos/product.yaml new file mode 100644 index 0000000000..abf427efba --- /dev/null +++ b/products/uddos/product.yaml @@ -0,0 +1,7 @@ +# products/uddos/product.yaml — UDDoS 产品元数据(归属真源,owner 自治维护) +name: uddos +owners: + - leas.li +commands: + - uddos +enabled: true diff --git a/products/uddos/testdata/cmdtree.golden b/products/uddos/testdata/cmdtree.golden new file mode 100644 index 0000000000..7b2ea2d9d0 --- /dev/null +++ b/products/uddos/testdata/cmdtree.golden @@ -0,0 +1,90 @@ +ucloud uddos use=uddos short=Manage UCloud DDoS protection services +ucloud uddos mainland use=mainland short=Manage mainland China DDoS high-protection services +ucloud uddos mainland ip use=ip short=Manage mainland DDoS protection IPs +ucloud uddos mainland ip create use=create short=Create a mainland BGP high-protection IP + flag=remark short= default= required= + flag=resource-id short= default= required=true + flag=tag short= default= required= + flag=type-ip short= default=TypeFree required= +ucloud uddos mainland ip delete use=delete short=Delete a mainland BGP high-protection IP + flag=defence-ip short= default= required=true + flag=resource-id short= default= required=true + flag=yes short=y default=false required= +ucloud uddos mainland ip list use=list short=List mainland BGP high-protection IPs + flag=bgp-ip short= default= required= + flag=limit short= default=20 required= + flag=offset short= default=0 required= + flag=resource-id short= default= required=true +ucloud uddos mainland rule use=rule short=Manage BGP forwarding rules +ucloud uddos mainland rule create use=create short=Create a BGP forwarding rule + flag=bgp-ip short= default= required=true + flag=bgp-ip-port short= default=0 required= + flag=fwd-type short= default=IP required= + flag=load-balance short= default=No required= + flag=remark short= default= required= + flag=resource-id short= default= required=true + flag=source-detect short= default=0 required= + flag=source-ip short= default= required=true +ucloud uddos mainland rule delete use=delete short=Delete a BGP forwarding rule + flag=resource-id short= default= required=true + flag=rule-index short= default=0 required=true + flag=yes short=y default=false required= +ucloud uddos mainland rule list use=list short=List BGP forwarding rules + flag=bgp-ip short= default= required= + flag=limit short= default=32 required= + flag=offset short= default=0 required= + flag=resource-id short= default= required=true + flag=rule-index short= default=0 required= +ucloud uddos mainland rule update use=update short=Update a BGP forwarding rule + flag=bgp-ip short= default= required=true + flag=bgp-ip-port short= default=0 required= + flag=fwd-type short= default=IP required= + flag=load-balance short= default=No required= + flag=resource-id short= default= required=true + flag=rule-id short= default= required= + flag=rule-index short= default=0 required=true + flag=source-detect short= default=0 required= + flag=source-ip short= default= required= +ucloud uddos mainland service use=service short=Manage mainland DDoS protection service instances +ucloud uddos mainland service create use=create short=Create a mainland DDoS high-protection service + flag=area-line short= default= required=true + flag=async short= default=false required= + flag=charge-type short= default= required=true + flag=defence-base-flow short= default=0 required=true + flag=defence-max-flow short= default=0 required=true + flag=engine-room short= default= required=true + flag=name short= default= required=true + flag=quantity short= default=0 required=true + flag=src-bandwidth short= default=0 required=true +ucloud uddos mainland service list use=list short=List mainland DDoS protection service instances + flag=limit short= default=20 required= + flag=offset short= default=0 required= + flag=resource-id short= default= required= +ucloud uddos overseas use=overseas short=Manage overseas DDoS high-protection services +ucloud uddos overseas ip use=ip short=Manage overseas DDoS protection IPs +ucloud uddos overseas ip create use=create short=Create an overseas BGP high-protection IP + flag=remark short= default= required= + flag=resource-id short= default= required=true + flag=tag short= default= required= + flag=type-ip short= default=TypeFree required= +ucloud uddos overseas ip delete use=delete short=Delete an overseas BGP high-protection IP + flag=defence-ip short= default= required=true + flag=resource-id short= default= required=true + flag=yes short=y default=false required= +ucloud uddos overseas ip list use=list short=List overseas BGP high-protection IPs + flag=limit short= default=20 required= + flag=nap-ip short= default= required= + flag=offset short= default=0 required= + flag=resource-id short= default= required=true +ucloud uddos overseas service use=service short=Manage overseas DDoS protection service instances +ucloud uddos overseas service create use=create short=Create an overseas DDoS high-protection service + flag=area-line short= default= required=true + flag=async short= default=false required= + flag=charge-type short= default= required=true + flag=name short= default= required=true + flag=quantity short= default=0 required=true + flag=src-bandwidth short= default=0 required=true +ucloud uddos overseas service list use=list short=List overseas DDoS protection service instances + flag=limit short= default=20 required= + flag=offset short= default=0 required= + flag=resource-id short= default= required= diff --git a/products/uddos/testdata/completion.golden b/products/uddos/testdata/completion.golden new file mode 100644 index 0000000000..e69de29bb2 diff --git a/products/uddos/testdata/testcases.md b/products/uddos/testdata/testcases.md new file mode 100644 index 0000000000..1b9bae5e9a --- /dev/null +++ b/products/uddos/testdata/testcases.md @@ -0,0 +1,257 @@ +# uddos CLI 测试用例 + +覆盖范围:`ucloud uddos mainland` 与 `ucloud uddos overseas` 下全部子命令。 + +--- + +## 一、国内高防 — mainland service + +### 1.1 service create + +**前置条件**:已登录,账号有购买高防服务的权限。 + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-SVC-C-01 | 正常创建(华东,枣庄机房) | `ucloud uddos mainland service create --charge-type Month --quantity 1 --area-line EastChina --engine-room Zaozhuang --src-bandwidth 100 --defence-base-flow 30 --defence-max-flow 50 --name my-svc` | 输出包含新建服务的 ResourceID,状态 Created | +| M-SVC-C-02 | 正常创建(华东,扬州机房) | 同上,`--engine-room Yangzhou` | 正常返回 ResourceID | +| M-SVC-C-03 | 正常创建(华北,石家庄机房) | `--area-line NorthChina --engine-room Shijiazhuang` | 正常返回 ResourceID | +| M-SVC-C-04 | base-flow = max-flow | `--defence-base-flow 30 --defence-max-flow 30` | 正常创建,不报错 | +| M-SVC-C-05 | 按年计费 | `--charge-type Year --quantity 1` | 正常创建 | +| M-SVC-C-06 | src-bandwidth 为最小值 50 | `--src-bandwidth 50` | 正常创建 | +| M-SVC-C-07 | src-bandwidth 为 10 的整百倍 | `--src-bandwidth 200` | 正常创建 | +| M-SVC-C-08 | **charge-type 非法值** | `--charge-type Daily` | 报错:`invalid --charge-type "Daily", must be "Month" or "Year"` | +| M-SVC-C-09 | **area-line 非法值** | `--area-line SouthChina` | 报错:`invalid --area-line "SouthChina"` | +| M-SVC-C-10 | **engine-room 与 area-line 不匹配** | `--area-line EastChina --engine-room Shijiazhuang` | 报错:`invalid --engine-room "Shijiazhuang" for --area-line "EastChina"` | +| M-SVC-C-11 | **engine-room 与 NorthChina 不匹配** | `--area-line NorthChina --engine-room Zaozhuang` | 报错:`invalid --engine-room "Zaozhuang" for --area-line "NorthChina"` | +| M-SVC-C-12 | **src-bandwidth 低于下限** | `--src-bandwidth 40` | 报错:`--src-bandwidth minimum is 50` | +| M-SVC-C-13 | **src-bandwidth 不是 10 的倍数** | `--src-bandwidth 55` | 报错:`--src-bandwidth must be a multiple of 10` | +| M-SVC-C-14 | **defence-base-flow 不在白名单** | `--defence-base-flow 35` | 报错:`invalid --defence-base-flow 35` | +| M-SVC-C-15 | **defence-max-flow 不在白名单** | `--defence-max-flow 45` | 报错:`invalid --defence-max-flow 45` | +| M-SVC-C-16 | **max-flow 小于 base-flow** | `--defence-base-flow 50 --defence-max-flow 30` | 报错:`--defence-max-flow (30) must be >= --defence-base-flow (50)` | +| M-SVC-C-17 | **缺少 required flag** | 省略 `--name` | cobra 报错:required flag not set | +| M-SVC-C-18 | 默认同步等待就绪 | 正常参数(不带 `--async`) | 先打印 `mainland DDoS service created: ghp-xxxxx`,再轮询 `DescribeHighProtectGameServiceInfo` 直到 `DefenceStatus="Started"`,输出 `service[ghp-xxxxx] is initializing...done` | +| M-SVC-C-19 | `--async` 不等待 | `... --async` | 打印 created 叙述后立即返回,不轮询 | +| M-SVC-C-20 | ResourceId 缺失(下单未成功) | 模拟 API 返回无 ResourceInfo | 报错:`BuyHighProtectGameService returned no ResourceId; ...`,不静默当成功 | + +**defence flow 白名单**:30 / 40 / 50 / 60 / 70 / 80 / 100 / 200 / 300 / 400 / 500 / 600 / 700 / 800 (Gbps) + +**服务生命周期状态**:API 响应 `DefenceStatus` 为字符串,`Started`(=NAP_SERVICE_STATUS_IS_NORMAL,创建完成)/ `Stopped` / `Expired`。轮询目标态 = `Started`。10 分钟超时输出 `...timeout`(购买已完成,仅未等到可用)。 + +--- + +### 1.2 service list + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-SVC-L-01 | 列出全部服务 | `ucloud uddos mainland service list` | 表格输出所有国内高防服务,含 ResourceID / Name / DefenceStatus / ExpireTime | +| M-SVC-L-02 | 按 resource-id 过滤 | `ucloud uddos mainland service list --resource-id ghp-xxxxx` | 仅返回指定服务行,其余不显示 | +| M-SVC-L-03 | 分页 offset/limit | `--offset 10 --limit 5` | 返回从第 11 条起的最多 5 条记录 | +| M-SVC-L-04 | resource-id 不存在 | `--resource-id ghp-notexist` | 输出空表格,不报错 | +| M-SVC-L-05 | JSON 输出格式 | `... --output json` | 输出合法 JSON 数组,字段完整 | + +--- + +## 二、国内高防 — mainland ip + +### 2.1 ip create + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-IP-C-01 | 正常创建,仅必填参数 | `ucloud uddos mainland ip create --resource-id ghp-xxxxx` | 输出新建 BGP IP 地址,状态 Created | +| M-IP-C-02 | 指定 type-ip=TypeCharge | `... --type-ip TypeCharge` | 正常创建计费类型 IP | +| M-IP-C-03 | 指定 remark 和 tag | `... --remark "test" --tag "biz-group"` | 正常创建,API 参数中含 Remark 和 Tag | +| M-IP-C-04 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| M-IP-C-05 | resource-id 不存在 | `--resource-id ghp-notexist` | API 返回业务错误,CLI 打印错误信息 | + +> 可选 flag:`--type-ip`(默认 `TypeFree`)、`--remark`、`--tag`。已移除 `--block-udp` / `--eip-region`。 + +--- + +### 2.2 ip list + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-IP-L-01 | 列出指定服务下所有 IP | `ucloud uddos mainland ip list --resource-id ghp-xxxxx` | 表格输出所有 BGP IP,含 DefenceIP / UserIP / LineType / Status / RuleCnt 等 | +| M-IP-L-02 | 按 bgp-ip 过滤 | `... --bgp-ip 1.2.3.4` | 仅返回匹配行 | +| M-IP-L-03 | 分页 | `... --offset 0 --limit 5` | 最多返回 5 条 | +| M-IP-L-04 | 无 IP 时输出 | `--resource-id ghp-empty` | 输出空表格,不报错 | +| M-IP-L-05 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | + +--- + +### 2.3 ip delete + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-IP-D-01 | 正常删除(带 --yes) | `ucloud uddos mainland ip delete --resource-id ghp-xxxxx --defence-ip 1.2.3.4 --yes` | 跳过确认,输出 `BGP IP deleted: 1.2.3.4`,状态 Deleted | +| M-IP-D-02 | 交互确认输入 y | 不带 `--yes`,stdin 输入 `y` | 执行删除,输出同上 | +| M-IP-D-03 | 交互确认输入 n | 不带 `--yes`,stdin 输入 `n` | 取消删除,无输出,不报错 | +| M-IP-D-04 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| M-IP-D-05 | **缺少 defence-ip** | 省略 `--defence-ip` | cobra 报错:required flag not set | +| M-IP-D-06 | defence-ip 不存在 | `--defence-ip 9.9.9.9` | API 返回业务错误,CLI 打印错误信息 | + +--- + +## 三、国内高防 — mainland rule + +### 3.1 rule create + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-RL-C-01 | 最小参数创建 IP 协议规则 | `ucloud uddos mainland rule create --resource-id ghp-xxxxx --bgp-ip 1.2.3.4 --source-ip 10.0.0.1` | 输出 `rule[N] created for service[ghp-xxxxx]`,状态 Created;API 参数含 SourceAddrArr、SourcePortArr、SourceToaIDArr | +| M-RL-C-02 | TCP 协议 + 端口 | `... --fwd-type TCP --bgp-ip-port 80` | 正常创建 TCP 转发规则 | +| M-RL-C-03 | UDP 协议 + 端口 | `... --fwd-type UDP --bgp-ip-port 53` | 正常创建 UDP 转发规则 | +| M-RL-C-04 | 开启负载均衡 | `... --load-balance Yes` | API 参数 LoadBalance=Yes | +| M-RL-C-05 | 指定 remark | `... --remark "main rule"` | API 参数含 Remark | +| M-RL-C-06 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| M-RL-C-07 | **缺少 bgp-ip** | 省略 `--bgp-ip` | cobra 报错:required flag not set | +| M-RL-C-08 | **缺少 source-ip** | 省略 `--source-ip` | cobra 报错:required flag not set | + +> `--source-ip` 现为**必填**(required=true)。 + +--- + +### 3.2 rule list + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-RL-L-01 | 列出指定服务下所有规则 | `ucloud uddos mainland rule list --resource-id ghp-xxxxx` | 表格输出所有规则,含 RuleIndex / BgpIP / FwdType / SourceIP / LoadBalance 等 | +| M-RL-L-02 | 按 bgp-ip 过滤 | `... --bgp-ip 1.2.3.4` | 仅返回该 IP 下的规则 | +| M-RL-L-03 | 按 rule-index 过滤 | `... --rule-index 0` | 仅返回 index=0 的规则 | +| M-RL-L-04 | 分页 | `... --limit 10 --offset 0` | 最多 10 条,默认 limit=32 | +| M-RL-L-05 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | + +--- + +### 3.3 rule update + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-RL-U-01 | 更新 source-ip | `ucloud uddos mainland rule update --resource-id ghp-xxxxx --bgp-ip 1.2.3.4 --rule-index 0 --source-ip 10.0.0.2` | 输出 `rule[0] updated for service[ghp-xxxxx]`,状态 Updated | +| M-RL-U-02 | 切换转发协议 | `... --fwd-type TCP --bgp-ip-port 443` | 正常更新 | +| M-RL-U-03 | 开启/关闭源地址探测 | `... --source-detect 1` | API 参数 SourceDetect=1 | +| M-RL-U-04 | 通过 rule-id 定位规则 | `... --rule-id rule-abc` | API 参数含 RuleID | +| M-RL-U-05 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| M-RL-U-06 | **缺少 bgp-ip** | 省略 `--bgp-ip` | cobra 报错:required flag not set | +| M-RL-U-07 | **缺少 rule-index** | 省略 `--rule-index` | cobra 报错:required flag not set | + +--- + +### 3.4 rule delete + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-RL-D-01 | 正常删除(带 --yes) | `ucloud uddos mainland rule delete --resource-id ghp-xxxxx --rule-index 0 --yes` | 输出 `rule[0] deleted from service[ghp-xxxxx]`,状态 Deleted | +| M-RL-D-02 | 交互确认输入 y | 不带 `--yes`,stdin 输入 `y` | 执行删除 | +| M-RL-D-03 | 交互确认输入 n | 不带 `--yes`,stdin 输入 `n` | 取消删除,不报错 | +| M-RL-D-04 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| M-RL-D-05 | **缺少 rule-index** | 省略 `--rule-index` | cobra 报错:required flag not set | +| M-RL-D-06 | rule-index 不存在 | `--rule-index 9999` | API 返回业务错误,CLI 打印错误信息 | + +--- + +## 四、海外高防 — overseas service + +### 4.1 service create + +**必填 flag**:`--charge-type`、`--quantity`、`--area-line`、`--src-bandwidth`、`--name`(无 `--engine-room`,已由 `--area-line` 取代;防护流量固定 50 Gbps)。 + +**area-line → 清洗中心(API EngineRoom)映射**: +- HongKong 清洗中心:HongKong / Taipei / Singapore / Tokyo / Seoul / Bangkok / HoChiMinh / Jakarta / Manila / Mumbai +- Frankfurt 清洗中心:Frankfurt / London / Moscow +- Ashburn 清洗中心:Ashburn / LosAngeles / Washington + +**src-bandwidth 步进规则**:≤300 Mbps 步进 50;300~1000 步进 100;1000~5000 步进 500。 + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| O-SVC-C-01 | 亚太清洗中心(HongKong) | `ucloud uddos overseas service create --charge-type Month --quantity 1 --area-line HongKong --src-bandwidth 100 --name my-svc` | 正常返回 ResourceID,状态 Created | +| O-SVC-C-02 | 亚太城市节点(Singapore) | `--area-line Singapore` | 正常创建,EngineRoom 自动映射为 HongKong 清洗中心 | +| O-SVC-C-03 | 欧洲(Frankfurt) | `--area-line Frankfurt` | 正常创建 | +| O-SVC-C-04 | 欧洲城市(London) | `--area-line London` | 正常创建,EngineRoom 映射为 Frankfurt | +| O-SVC-C-05 | 北美(Ashburn) | `--area-line Ashburn` | 正常创建 | +| O-SVC-C-06 | 北美城市(LosAngeles) | `--area-line LosAngeles` | 正常创建,EngineRoom 映射为 Ashburn | +| O-SVC-C-07 | src-bandwidth 最小值 50 | `--src-bandwidth 50` | 正常创建 | +| O-SVC-C-08 | src-bandwidth 300(步进边界) | `--src-bandwidth 300` | 正常创建 | +| O-SVC-C-09 | src-bandwidth 400(进入 100 步进) | `--src-bandwidth 400` | 正常创建 | +| O-SVC-C-10 | src-bandwidth 1000(步进边界) | `--src-bandwidth 1000` | 正常创建 | +| O-SVC-C-11 | src-bandwidth 最大值 5000 | `--src-bandwidth 5000` | 正常创建 | +| O-SVC-C-12 | **charge-type 非法值** | `--charge-type Daily` | 报错:`invalid --charge-type "Daily", must be "Month" or "Year"` | +| O-SVC-C-13 | **area-line 不在映射表** | `--area-line Shanghai` | 报错:`invalid --area-line "Shanghai"; valid values: HongKong/...` | +| O-SVC-C-14 | **src-bandwidth 低于下限** | `--src-bandwidth 49` | 报错:`--src-bandwidth minimum is 50 for overseas` | +| O-SVC-C-15 | **src-bandwidth 超过上限** | `--src-bandwidth 5001` | 报错:`--src-bandwidth maximum is 5000 for overseas` | +| O-SVC-C-16 | **≤300 时不是 50 的倍数** | `--src-bandwidth 75` | 报错:`must be a multiple of 50 when <= 300 (overseas)` | +| O-SVC-C-17 | **300~1000 时不是 100 的倍数** | `--src-bandwidth 350` | 报错:`must be a multiple of 100 when 300~1000 (overseas)` | +| O-SVC-C-18 | **1000~5000 时不是 500 的倍数** | `--src-bandwidth 1200` | 报错:`must be a multiple of 500 when 1000~5000 (overseas)` | +| O-SVC-C-19 | **缺少 required flag** | 省略 `--area-line` | cobra 报错:required flag not set | +| O-SVC-C-20 | 默认同步等待就绪 | 正常参数(不带 `--async`) | 先打印 `overseas DDoS service created: nap-xxxxx`,再轮询 `DescribeNapServiceInfo`(NapType=2) 直到 `DefenceStatus="Started"`,输出 `service[nap-xxxxx] is initializing...done` | +| O-SVC-C-21 | `--async` 不等待 | `... --async` | 打印 created 叙述后立即返回,不轮询 | +| O-SVC-C-22 | ResourceId 缺失(下单未成功) | 模拟 API 返回无 ResourceInfo | 报错:`BuyHighProtectGameService returned no ResourceId; ...`,不静默当成功 | + +**服务生命周期状态**:API 响应 `DefenceStatus` 为字符串,`Started`(创建完成)/ `Stopped` / `Expired`。轮询目标态 = `Started`。 + +--- + +### 4.2 service list + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| O-SVC-L-01 | 列出全部海外服务 | `ucloud uddos overseas service list` | 表格输出所有 NapType=2 的服务,含 ResourceID / Name / DefenceStatus / ExpireTime / Remark | +| O-SVC-L-02 | 按 resource-id 过滤 | `... --resource-id nap-xxxxx` | 仅返回指定服务 | +| O-SVC-L-03 | 分页 | `... --offset 0 --limit 10` | 最多返回 10 条 | +| O-SVC-L-04 | 无服务时输出 | (账号下无海外高防) | 输出空表格,不报错 | + +--- + +## 五、海外高防 — overseas ip + +### 5.1 ip create + +> 海外高防为透传模式(Passthrough),EIPRegion **始终自动解析**:通过 `DescribeNapServiceInfo` + `GetNapServiceConfig` 取服务配置 IpInfo 的 Region。无手动 `--eip-region` / `--block-udp` flag。可选 flag:`--type-ip`(默认 `TypeFree`)、`--remark`、`--tag`。 + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| O-IP-C-01 | 自动解析 EIPRegion(仅必填参数) | `ucloud uddos overseas ip create --resource-id nap-xxxxx` | 自动查询服务配置获取 EIPRegion,创建 IP,输出 `BGP IP created: x.x.x.x`,状态 Created | +| O-IP-C-02 | 指定 type-ip=TypeCharge | `... --type-ip TypeCharge` | 正常创建计费类型 IP | +| O-IP-C-03 | 指定 remark 和 tag | `... --remark "r1" --tag "grp1"` | API 参数含 Remark / Tag | +| O-IP-C-04 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| O-IP-C-05 | resource-id 查不到服务 | `--resource-id nap-notexist` | 报错:`service nap-notexist not found` | +| O-IP-C-06 | 服务配置 IpInfo 为空 | (数据库无 IpInfo 配置的服务) | 报错:`IpInfo is empty in service config` | + +--- + +### 5.2 ip list + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| O-IP-L-01 | 列出指定服务下所有 IP | `ucloud uddos overseas ip list --resource-id nap-xxxxx` | 调用 `DescribePassthroughNapIP`,输出 EIPIP / EIPID / Status / EIPRegion / Tag / Remark | +| O-IP-L-02 | 按 nap-ip 过滤 | `... --nap-ip 1.2.3.4` | 仅返回匹配行,API 参数含 NapIp | +| O-IP-L-03 | 分页 | `... --offset 0 --limit 5` | 最多返回 5 条 | +| O-IP-L-04 | 无 IP 时输出 | `--resource-id nap-empty` | 输出空表格,不报错 | +| O-IP-L-05 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | + +--- + +### 5.3 ip delete + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| O-IP-D-01 | 正常删除(带 --yes) | `ucloud uddos overseas ip delete --resource-id nap-xxxxx --defence-ip 1.2.3.4 --yes` | 输出 `BGP IP deleted: 1.2.3.4`,状态 Deleted | +| O-IP-D-02 | 交互确认输入 y | 不带 `--yes`,stdin 输入 `y` | 执行删除 | +| O-IP-D-03 | 交互确认输入 n | 不带 `--yes`,stdin 输入 `n` | 取消删除,不报错 | +| O-IP-D-04 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| O-IP-D-05 | **缺少 defence-ip** | 省略 `--defence-ip` | cobra 报错:required flag not set | + +--- + +## 六、通用 / 输出格式测试 + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| G-01 | JSON 输出格式 | 任意 list 命令 + `--output json` | 输出合法 JSON 数组,字段名与结构体一致 | +| G-02 | YAML 输出格式 | 任意 list 命令 + `--output yaml` | 输出合法 YAML | +| G-03 | 进度提示去向(JSON 模式) | 带 `--output json` 的 create/delete 命令 | 进度文本输出到 stderr,ResourceID 结果输出到 stdout | +| G-04 | 进度提示去向(Table 模式) | 不带 `--output`(默认表格) | 进度文本与结果均输出到 stdout | +| G-05 | 帮助信息 | `ucloud uddos --help` / `ucloud uddos mainland --help` | 输出可读的帮助文本,Example 字段完整 | +| G-06 | 命令树完整性 | `ucloud uddos mainland rule --help` | 列出 create / list / update / delete 四个子命令 | +| G-07 | 海外命令树完整性 | `ucloud uddos overseas ip --help` | 列出 create / list / delete 三个子命令 | diff --git a/products/udisk/internal/udisk/attach.go b/products/udisk/internal/udisk/attach.go new file mode 100644 index 0000000000..f1e2ef966e --- /dev/null +++ b/products/udisk/internal/udisk/attach.go @@ -0,0 +1,69 @@ +package udisk + +import ( + "fmt" + + "github.com/spf13/cobra" + + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newAttach ucloud udisk attach +func newAttach(ctx *cli.Context) *cobra.Command { + var async *bool + var udiskIDs *[]string + + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewAttachUDiskRequest() + cmd := &cobra.Command{ + Use: "attach", + Short: "Attach udisk instances to an uhost", + Long: "Attach udisk instances to an uhost", + Example: "ucloud udisk attach --uhost-id uhost-xxxx --udisk-id bs-xxx1,bs-xxx2", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *udiskIDs { + id = ctx.PickResourceID(id) + req.UDiskId = &id + *req.UHostId = ctx.PickResourceID(*req.UHostId) + resp, err := client.AttachUDisk(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("udisk[%s] is attaching to uhost uhost[%s]", *req.UDiskId, *req.UHostId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUdiskByID(ctx)).Spoll(resp.UDiskId, text, []string{DISK_INUSE, DISK_FAILED}) + } + results = append(results, cli.OpResultRow{ResourceID: resp.UDiskId, Action: "attach", Status: "Attaching"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.UHostId = flags.String("uhost-id", "", "Required. Resource ID of the uhost instance which you want to attach the disk") + udiskIDs = flags.StringSlice("udisk-id", nil, "Required. Resource ID of the udisk instances to attach") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + + command.SetCompletion(cmd, "udisk-id", func() []string { + return getDiskList(ctx, []string{DISK_AVAILABLE}, *req.ProjectId, *req.Region, *req.Zone) + }) + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) + }) + + cmd.MarkFlagRequired("uhost-id") + cmd.MarkFlagRequired("udisk-id") + + return cmd +} diff --git a/products/udisk/internal/udisk/clone.go b/products/udisk/internal/udisk/clone.go new file mode 100644 index 0000000000..1c8da8ad54 --- /dev/null +++ b/products/udisk/internal/udisk/clone.go @@ -0,0 +1,83 @@ +package udisk + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newClone ucloud udisk clone +func newClone(ctx *cli.Context) *cobra.Command { + var async *bool + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewCloneUDiskRequest() + enableDataArk := sdk.String("false") + cmd := &cobra.Command{ + Use: "clone", + Short: "Clone an udisk", + Long: "Clone an udisk", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + if *enableDataArk == "true" { + req.UDataArkMode = sdk.String("Yes") + } else { + req.UDataArkMode = sdk.String("No") + } + if strings.Index(*req.SourceId, "/") > -1 { + *req.SourceId = strings.SplitN(*req.SourceId, "/", 2)[0] + } + resp, err := client.CloneUDisk(req) + if err != nil { + ctx.HandleError(err) + return + } + if len(resp.UDiskId) == 1 { + text := fmt.Sprintf("cloned udisk:[%s] is initializing", resp.UDiskId[0]) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUdiskByID(ctx)).Spoll(resp.UDiskId[0], text, []string{DISK_AVAILABLE, DISK_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.UDiskId[0], Action: "clone", Status: "Initializing"}) + } else { + fmt.Fprintf(w, "udisk[%v] cloned", resp.UDiskId) + results := []cli.OpResultRow{} + for _, id := range resp.UDiskId { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "clone", Status: "Cloned"}) + } + ctx.EmitResult(results...) + } + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.SourceId = flags.String("source-id", "", "Required. Resource ID of parent udisk") + req.Name = flags.String("name", "", "Required. Name of new udisk") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + req.ChargeType = flags.String("charge-type", "Month", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") + req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") + enableDataArk = flags.String("enable-data-ark", "false", "Optional. DataArk supports real-time backup, which can restore the udisk back to any moment within the last 12 hours.") + req.CouponId = flags.String("coupon-id", "", "Optional. Coupon ID, The Coupon can deduct part of the payment,see https://accountv2.ucloud.cn") + async = flags.Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic", "Trial") + command.SetFlagValues(cmd, "enable-data-ark", "true", "false") + + command.SetCompletion(cmd, "source-id", func() []string { + return getDiskList(ctx, []string{DISK_AVAILABLE}, *req.ProjectId, *req.Region, *req.Zone) + }) + + cmd.MarkFlagRequired("source-id") + cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/products/udisk/internal/udisk/cmd.go b/products/udisk/internal/udisk/cmd.go new file mode 100644 index 0000000000..8e487c8fa8 --- /dev/null +++ b/products/udisk/internal/udisk/cmd.go @@ -0,0 +1,29 @@ +package udisk + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `udisk` root command and mounts the 11 subcommands. +// Mirrors cmd/disk.go NewCmdDisk (same AddCommand order). +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "udisk", + Short: "Read and manipulate udisk instances", + Long: "Read and manipulate udisk instances", + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newAttach(ctx)) + cmd.AddCommand(newDetach(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newClone(ctx)) + cmd.AddCommand(newExpand(ctx)) + cmd.AddCommand(newSnapshot(ctx)) + cmd.AddCommand(newRestore(ctx)) + cmd.AddCommand(newSnapshotList(ctx)) + cmd.AddCommand(newSnapshotDelete(ctx)) + return cmd +} diff --git a/products/udisk/internal/udisk/completion.go b/products/udisk/internal/udisk/completion.go new file mode 100644 index 0000000000..7e4ffbbebe --- /dev/null +++ b/products/udisk/internal/udisk/completion.go @@ -0,0 +1,86 @@ +package udisk + +import ( + "strings" + + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// getUhostList returns "UHostId/Name" completion candidates for the attach +// command's --uhost-id flag. Copied self-contained from cmd/uhost.go +// (base.BizClient → cli.NewServiceClient on the public uhost SDK). +func getUhostList(ctx *cli.Context, states []string, project, region, zone string) []string { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeUHostInstanceRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + req.Limit = sdk.Int(50) + resp, err := client.DescribeUHostInstance(req) + if err != nil { + //todo runtime log + return nil + } + list := []string{} + for _, host := range resp.UHostSet { + if states != nil { + for _, s := range states { + if host.State == s { + list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) + } + } + } else { + list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) + } + } + return list +} + +func getDiskList(ctx *cli.Context, states []string, project, region, zone string) []string { + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewDescribeUDiskRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + req.Limit = sdk.Int(50) + resp, err := client.DescribeUDisk(req) + if err != nil { + //todo runtime log + return nil + } + list := []string{} + for _, disk := range resp.DataSet { + for _, s := range states { + if disk.Status == s { + list = append(list, disk.UDiskId+"/"+strings.Replace(disk.Name, " ", "-", -1)) + } + } + } + return list +} + +func getSnapshotList(ctx *cli.Context, states []string, project, region, zone string) []string { + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewDescribeUDiskSnapshotRequest() + req.Limit = sdk.Int(50) + req.ProjectId = &project + req.Region = ®ion + req.Zone = &zone + resp, err := client.DescribeUDiskSnapshot(req) + if err != nil { + return nil + } + list := []string{} + for _, snapshot := range resp.DataSet { + for _, s := range states { + if snapshot.Status == s { + list = append(list, snapshot.SnapshotId+"/"+strings.Replace(snapshot.Name, " ", "-", -1)) + } + } + } + return list +} diff --git a/products/udisk/internal/udisk/create.go b/products/udisk/internal/udisk/create.go new file mode 100644 index 0000000000..075fb6b5aa --- /dev/null +++ b/products/udisk/internal/udisk/create.go @@ -0,0 +1,131 @@ +package udisk + +import ( + "fmt" + + "github.com/spf13/cobra" + + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud udisk create +func newCreate(ctx *cli.Context) *cobra.Command { + var async *bool + var count *int + var enableDataArk *string + var snapshotID *string + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewCreateUDiskRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create udisk instance", + Long: "Create udisk instance", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + if *count > 10 || *count < 1 { + fmt.Fprintf(w, "Error, count should be between 1 and 10\n") + return + } + if *enableDataArk == "true" { + req.UDataArkMode = sdk.String("Yes") + } else { + req.UDataArkMode = sdk.String("No") + } + + if *req.DiskType == "Oridinary" { + *req.DiskType = "DataDisk" + } else if *req.DiskType == "SSD" { + *req.DiskType = "SSDDataDisk" + } + if *snapshotID != "" { + cloneReq := client.NewCloneUDiskSnapshotRequest() + cloneReq.UDataArkMode = req.UDataArkMode + cloneReq.SourceId = snapshotID + cloneReq.ProjectId = req.ProjectId + cloneReq.Region = req.Region + cloneReq.Zone = req.Zone + cloneReq.Name = req.Name + cloneReq.Size = req.Size + cloneReq.ChargeType = req.ChargeType + cloneReq.Quantity = req.Quantity + for i := 0; i < *count; i++ { + resp, err := client.CloneUDiskSnapshot(cloneReq) + if err != nil { + ctx.HandleError(err) + return + } + if count := len(resp.UDiskId); count == 1 { + text := fmt.Sprintf("udisk:%v is initializing", resp.UDiskId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUdiskByID(ctx)).Spoll(resp.UDiskId[0], text, []string{DISK_AVAILABLE, DISK_FAILED}) + } + results = append(results, cli.OpResultRow{ResourceID: resp.UDiskId[0], Action: "create", Status: "Initializing"}) + } else if count > 1 { + fmt.Fprintf(w, "udisk:%v created\n", resp.UDiskId) + for _, id := range resp.UDiskId { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "create", Status: "Created"}) + } + } else { + ctx.HandleError(fmt.Errorf("none udisk created")) + } + } + } else { + for i := 0; i < *count; i++ { + resp, err := client.CreateUDisk(req) + if err != nil { + ctx.HandleError(err) + return + } + if count := len(resp.UDiskId); count == 1 { + text := fmt.Sprintf("udisk:%v is initializing", resp.UDiskId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUdiskByID(ctx)).Spoll(resp.UDiskId[0], text, []string{DISK_AVAILABLE, DISK_FAILED}) + } + results = append(results, cli.OpResultRow{ResourceID: resp.UDiskId[0], Action: "create", Status: "Initializing"}) + } else if count > 1 { + fmt.Fprintf(w, "udisk:%v created\n", resp.UDiskId) + for _, id := range resp.UDiskId { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "create", Status: "Created"}) + } + } else { + ctx.HandleError(fmt.Errorf("none udisk created")) + } + } + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.Name = flags.String("name", "", "Required. Name of the udisk to create") + req.Size = flags.Int("size-gb", 10, "Required. Size of the udisk to create. Unit:GB. Normal udisk [1,8000]; SSD udisk [1,4000] ") + snapshotID = flags.String("snapshot-id", "", "Optional. Resource ID of a snapshot, which will apply to the udisk being created. If you set this option, 'udisk-type' will be omitted.") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + req.ChargeType = flags.String("charge-type", "Dynamic", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") + req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") + enableDataArk = flags.String("enable-data-ark", "false", "Optional. DataArk supports real-time backup, which can restore the udisk back to any moment within the last 12 hours.") + req.Tag = flags.String("group", "Default", "Optional. Business group") + req.DiskType = flags.String("udisk-type", "Oridinary", "Optional. 'Ordinary' or 'SSD'") + async = flags.Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + count = flags.Int("count", 1, "Optional. The count of udisk to create. Range [1,10]") + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic", "Trial") + command.SetFlagValues(cmd, "enable-data-ark", "true", "false") + command.SetFlagValues(cmd, "udisk-type", "Oridinary", "SSD") + + cmd.MarkFlagRequired("size-gb") + cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/products/udisk/internal/udisk/delete.go b/products/udisk/internal/udisk/delete.go new file mode 100644 index 0000000000..9a41b34c57 --- /dev/null +++ b/products/udisk/internal/udisk/delete.go @@ -0,0 +1,65 @@ +package udisk + +import ( + "fmt" + + "github.com/spf13/cobra" + + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud udisk delete +func newDelete(ctx *cli.Context) *cobra.Command { + var yes *bool + var udiskIDs *[]string + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewDeleteUDiskRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete udisk instances", + Long: "Delete udisk instances", + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(*yes, "Are you sure to delete udisk(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *udiskIDs { + id := ctx.PickResourceID(id) + req.UDiskId = &id + _, err := client.DeleteUDisk(req) + if err != nil { + ctx.HandleError(err) + continue + } else { + fmt.Fprintf(w, "udisk[%s] deleted\n", *req.UDiskId) + results = append(results, cli.OpResultRow{ResourceID: *req.UDiskId, Action: "delete", Status: "Deleted"}) + } + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + udiskIDs = flags.StringSlice("udisk-id", nil, "Required. The Resource ID of udisks to delete") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + yes = flags.BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + + command.SetCompletion(cmd, "udisk-id", func() []string { + return getDiskList(ctx, []string{DISK_AVAILABLE, DISK_FAILED}, *req.ProjectId, *req.Region, *req.Zone) + }) + + cmd.MarkFlagRequired("udisk-id") + + return cmd +} diff --git a/products/udisk/internal/udisk/delete_snapshot.go b/products/udisk/internal/udisk/delete_snapshot.go new file mode 100644 index 0000000000..ca6645c19c --- /dev/null +++ b/products/udisk/internal/udisk/delete_snapshot.go @@ -0,0 +1,49 @@ +package udisk + +import ( + "fmt" + + "github.com/spf13/cobra" + + puhost "github.com/ucloud/ucloud-sdk-go/private/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSnapshotDelete ucloud udisk delete-snapshot +func newSnapshotDelete(ctx *cli.Context) *cobra.Command { + var snapshotIds *[]string + client := cli.NewServiceClient(ctx, puhost.NewClient) + req := client.NewDeleteSnapshotRequest() + cmd := &cobra.Command{ + Use: "delete-snapshot", + Short: "Delete snapshots", + Long: "Delete snapshots", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, snapshotID := range *snapshotIds { + req.SnapshotId = sdk.String(ctx.PickResourceID(snapshotID)) + resp, err := client.DeleteSnapshot(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(w, "snapshot[%s] deleted\n", resp.SnapshotId) + results = append(results, cli.OpResultRow{ResourceID: resp.SnapshotId, Action: "delete-snapshot", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + snapshotIds = flags.StringSlice("snapshot-id", nil, "Required. Resource ID of snapshots to delete") + cmd.MarkFlagRequired("snapshot-id") + return cmd +} diff --git a/products/udisk/internal/udisk/describe.go b/products/udisk/internal/udisk/describe.go new file mode 100644 index 0000000000..f18cb427be --- /dev/null +++ b/products/udisk/internal/udisk/describe.go @@ -0,0 +1,54 @@ +package udisk + +import ( + puhost "github.com/ucloud/ucloud-sdk-go/private/services/uhost" + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// describeUdiskByID returns the poller's describe func, closing over ctx so it +// can build an authed udisk client. Mirrors cmd/disk.go's describeUdiskByID. +func describeUdiskByID(ctx *cli.Context) func(udiskID string, commonBase *request.CommonBase) (interface{}, error) { + return func(udiskID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewDescribeUDiskRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.UDiskId = sdk.String(udiskID) + req.Limit = sdk.Int(50) + resp, err := client.DescribeUDisk(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) < 1 { + return nil, nil + } + return &resp.DataSet[0], nil + } +} + +// describeSnapshotByID returns the poller's describe func for udisk snapshots. +// Mirrors cmd/disk.go's describeSnapshotByID (private uhost DescribeSnapshot). +func describeSnapshotByID(ctx *cli.Context) func(snapshotID string, commonBase *request.CommonBase) (interface{}, error) { + return func(snapshotID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, puhost.NewClient) + req := client.NewDescribeSnapshotRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.SnapshotIds = append(req.SnapshotIds, snapshotID) + req.Limit = sdk.Int(50) + resp, err := client.DescribeSnapshot(req) + if err != nil { + return nil, err + } + if len(resp.UHostSnapshotSet) != 1 { + return nil, nil + } + return &resp.UHostSnapshotSet[0], nil + } +} diff --git a/products/udisk/internal/udisk/detach.go b/products/udisk/internal/udisk/detach.go new file mode 100644 index 0000000000..af0ad669aa --- /dev/null +++ b/products/udisk/internal/udisk/detach.go @@ -0,0 +1,97 @@ +package udisk + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDetach ucloud udisk detach +func newDetach(ctx *cli.Context) *cobra.Command { + var async, yes *bool + var udiskIDs *[]string + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewDetachUDiskRequest() + cmd := &cobra.Command{ + Use: "detach", + Short: "Detach udisk instances from an uhost", + Long: "Detach udisk instances from an uhost", + Run: func(cmd *cobra.Command, args []string) { + text := `Please confirm that you have already unmounted file system corresponding to this hard drive,(See "https://docs.ucloud.cn/storage_cdn/udisk/userguide/umount" for help), otherwise it will cause file system damage and UHost cannot be normally shut down. Sure to detach?` + ok, err := ctx.Confirm(*yes, text) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *udiskIDs { + id = ctx.PickResourceID(id) + err := DetachUdisk(ctx, *async, id, w) + if err != nil { + ctx.HandleError(err) + continue + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "detach", Status: "Detaching"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + udiskIDs = flags.StringSlice("udisk-id", nil, "Required. Resource ID of the udisk instances to detach") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + yes = flags.BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + + command.SetCompletion(cmd, "udisk-id", func() []string { + return getDiskList(ctx, []string{DISK_INUSE}, *req.ProjectId, *req.Region, *req.Zone) + }) + + cmd.MarkFlagRequired("udisk-id") + return cmd +} + +// DetachUdisk detaches a udisk from its uhost, narrating progress to out. +// Ported from cmd/disk.go's detachUdisk (base.BizClient → cli.NewServiceClient); +// exported so the restore command and (legacy) callers share one copy. +func DetachUdisk(ctx *cli.Context, async bool, udiskID string, out io.Writer) error { + any, err := describeUdiskByID(ctx)(udiskID, nil) + if err != nil { + return err + } + if any == nil { + return fmt.Errorf("udisk[%v] is not exist", any) + } + ins, ok := any.(*udisksdk.UDiskDataSet) + if !ok { + return fmt.Errorf("%#v convert to udisk failed", any) + } + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewDetachUDiskRequest() + req.UHostId = sdk.String(ins.UHostId) + req.UDiskId = sdk.String(udiskID) + resp, err := client.DetachUDisk(req) + if err != nil { + return err + } + text := fmt.Sprintf("udisk[%s] is detaching from uhost[%s]", resp.UDiskId, resp.UHostId) + if async { + fmt.Fprintln(out, text) + } else { + ctx.PollerTo(out, describeUdiskByID(ctx)).Spoll(udiskID, text, []string{DISK_AVAILABLE, DISK_FAILED}) + } + return nil +} diff --git a/products/udisk/internal/udisk/expand.go b/products/udisk/internal/udisk/expand.go new file mode 100644 index 0000000000..d47e5c2f87 --- /dev/null +++ b/products/udisk/internal/udisk/expand.go @@ -0,0 +1,56 @@ +package udisk + +import ( + "fmt" + + "github.com/spf13/cobra" + + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newExpand ucloud udisk expand +func newExpand(ctx *cli.Context) *cobra.Command { + var udiskIDs *[]string + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewResizeUDiskRequest() + cmd := &cobra.Command{ + Use: "expand", + Short: "Expand udisk size", + Long: "Expand udisk size", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *udiskIDs { + id = ctx.PickResourceID(id) + req.UDiskId = &id + _, err := client.ResizeUDisk(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(w, "udisk:[%s] expanded to %d GB\n", *req.UDiskId, *req.Size) + results = append(results, cli.OpResultRow{ResourceID: *req.UDiskId, Action: "expand", Status: "Expanded"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + udiskIDs = flags.StringSlice("udisk-id", nil, "Required. Resource ID of the udisks to expand") + req.Size = flags.Int("size-gb", 0, "Required. Size of the udisk after expanded. Unit: GB. Range [1,8000]") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + command.SetCompletion(cmd, "udisk-id", func() []string { + return getDiskList(ctx, []string{DISK_AVAILABLE}, *req.ProjectId, *req.Region, *req.Zone) + }) + + cmd.MarkFlagRequired("udisk-id") + cmd.MarkFlagRequired("size-gb") + + return cmd +} diff --git a/products/udisk/internal/udisk/list.go b/products/udisk/internal/udisk/list.go new file mode 100644 index 0000000000..34512a1e82 --- /dev/null +++ b/products/udisk/internal/udisk/list.go @@ -0,0 +1,77 @@ +package udisk + +import ( + "fmt" + + "github.com/spf13/cobra" + + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList ucloud udisk list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewDescribeUDiskRequest() + typeMap := map[string]string{ + "DataDisk": "Oridinary-Data-Disk", + "SystemDisk": "Oridinary-System-Disk", + "SSDDataDisk": "SSD-Data-Disk", + } + arkModeMap := map[string]string{ + "Yes": "true", + "No": "false", + } + cmd := &cobra.Command{ + Use: "list", + Short: "List udisk instance", + Long: "List udisk instance", + Run: func(cmd *cobra.Command, args []string) { + for key, val := range typeMap { + if *req.DiskType == val { + *req.DiskType = key + } + } + resp, err := client.DescribeUDisk(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []DiskRow{} + for _, disk := range resp.DataSet { + row := DiskRow{ + ResourceID: disk.UDiskId, + Name: disk.Name, + Group: disk.Tag, + Size: fmt.Sprintf("%dGB", disk.Size), + Type: typeMap[disk.DiskType], + EnableDataArk: arkModeMap[disk.UDataArkMode], + MountUHost: fmt.Sprintf("%s/%s", disk.UHostName, disk.UHostIP), + MountPoint: disk.DeviceName, + State: disk.Status, + CreationTime: common.FormatDate(disk.CreateTime), + ExpirationTime: common.FormatDate(disk.ExpiredTime), + } + if disk.UHostIP == "" { + row.MountUHost = "" + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + req.UDiskId = flags.String("udisk-id", "", "Optional. Resource ID of the udisk to search") + req.DiskType = flags.String("udisk-type", "", "Optional. Optional. Type of the udisk to search. 'Oridinary-Data-Disk','Oridinary-System-Disk' or 'SSD-Data-Disk'") + req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset") + req.Limit = cmd.Flags().Int("limit", 50, "Optional. Limit") + command.SetFlagValues(cmd, "udisk-type", "Oridinary-Data-Disk", "Oridinary-System-Disk", "SSD-Data-Disk") + return cmd +} diff --git a/products/udisk/internal/udisk/list_snapshot.go b/products/udisk/internal/udisk/list_snapshot.go new file mode 100644 index 0000000000..d7bc17963b --- /dev/null +++ b/products/udisk/internal/udisk/list_snapshot.go @@ -0,0 +1,62 @@ +package udisk + +import ( + "fmt" + + "github.com/spf13/cobra" + + puhost "github.com/ucloud/ucloud-sdk-go/private/services/uhost" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSnapshotList ucloud udisk list-snapshot +func newSnapshotList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, puhost.NewClient) + req := client.NewDescribeSnapshotRequest() + cmd := &cobra.Command{ + Use: "list-snapshot", + Short: "List snapshots", + Long: "List snapshots", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeSnapshot(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []SnapshotRow{} + for _, snapshot := range resp.UHostSnapshotSet { + row := SnapshotRow{ + Name: snapshot.SnapshotName, + ResourceID: snapshot.SnapshotId, + AvailabilityZone: snapshot.Zone, + BoundUDisk: snapshot.DiskId, + Size: fmt.Sprintf("%dGB", snapshot.Size), + State: snapshot.State, + UDiskType: snapshot.DiskType, + CreationTime: common.FormatDate(snapshot.CreateTime), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + // StringSliceVar binds the flag to req.SnapshotIds so Cobra fills it during + // parse; dereferencing StringSlice() here would freeze it to the initial nil + // slice and drop the --snapshot-id filter. + flags.StringSliceVar(&req.SnapshotIds, "snapshot-id", nil, "Optional. Resource ID of snapshots to list") + req.UHostId = flags.String("uhost-id", "", "Optional. Snapshots of the uhost") + req.DiskId = flags.String("disk-id", "", "Optional. Snapshots of the udisk") + req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset") + req.Limit = cmd.Flags().Int("limit", 50, "Optional. Limit, length of snapshot list") + + return cmd +} diff --git a/products/udisk/internal/udisk/restore.go b/products/udisk/internal/udisk/restore.go new file mode 100644 index 0000000000..c45284064b --- /dev/null +++ b/products/udisk/internal/udisk/restore.go @@ -0,0 +1,78 @@ +package udisk + +import ( + "fmt" + + "github.com/spf13/cobra" + + puhost "github.com/ucloud/ucloud-sdk-go/private/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestore ucloud udisk restore +func newRestore(ctx *cli.Context) *cobra.Command { + var snapshotIDs *[]string + var yes *bool + client := cli.NewServiceClient(ctx, puhost.NewClient) + req := client.NewRestoreUHostDiskRequest() + cmd := &cobra.Command{ + Use: "restore", + Short: "Restore udisk from snapshot", + Long: "Restore udisk from snapshot", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, snapshotID := range *snapshotIDs { + snapshotID = ctx.PickResourceID(snapshotID) + any, err := describeSnapshotByID(ctx)(snapshotID, nil) + if err != nil { + ctx.HandleError(err) + continue + } + snapshot, ok := any.(*puhost.SnapshotSet) + if !ok { + fmt.Fprintf(w, "snapshot[%s] doesn't exist\n", snapshotID) + continue + } + if snapshot.UHostId != "" { + text := fmt.Sprintf("can we detach udisk[%s] from uhost[%s]?", snapshot.DiskId, snapshot.UHostId) + ok, err := ctx.Confirm(*yes, text) + if err != nil { + ctx.HandleError(err) + continue + } + if !ok { + continue + } + DetachUdisk(ctx, false, snapshot.DiskId, w) + } + req.SnapshotIds = append(req.SnapshotIds, snapshotID) + _, err = client.RestoreUHostDisk(req) + + if err != nil { + ctx.HandleError(err) + return + } + + text := fmt.Sprintf("udisk[%s] has been restored from snapshot[%s]", snapshot.DiskId, snapshot.SnapshotId) + fmt.Fprintln(w, text) + results = append(results, cli.OpResultRow{ResourceID: snapshot.DiskId, Action: "restore", Status: "Restored"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + snapshotIDs = flags.StringSlice("snapshot-id", nil, "Required. Resourece ID of the snapshots to restore from") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + yes = flags.BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + command.SetCompletion(cmd, "snapshot-id", func() []string { + return getSnapshotList(ctx, []string{SNAPSHOT_NORMAL}, *req.ProjectId, *req.Region, *req.Zone) + }) + cmd.MarkFlagRequired("snapshot-id") + return cmd +} diff --git a/products/udisk/internal/udisk/rows.go b/products/udisk/internal/udisk/rows.go new file mode 100644 index 0000000000..7b23b62fa0 --- /dev/null +++ b/products/udisk/internal/udisk/rows.go @@ -0,0 +1,28 @@ +package udisk + +// DiskRow TableRow +type DiskRow struct { + ResourceID string + Name string + Group string + Size string + Type string + MountUHost string + MountPoint string + EnableDataArk string + State string + CreationTime string + ExpirationTime string +} + +// SnapshotRow 表格行 +type SnapshotRow struct { + Name string + ResourceID string + AvailabilityZone string + BoundUDisk string + Size string + State string + UDiskType string + CreationTime string +} diff --git a/products/udisk/internal/udisk/snapshot.go b/products/udisk/internal/udisk/snapshot.go new file mode 100644 index 0000000000..cda042c1e0 --- /dev/null +++ b/products/udisk/internal/udisk/snapshot.go @@ -0,0 +1,68 @@ +package udisk + +import ( + "fmt" + + "github.com/spf13/cobra" + + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newSnapshot ucloud udisk snapshot +func newSnapshot(ctx *cli.Context) *cobra.Command { + var async *bool + var udiskIDs *[]string + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewCreateUDiskSnapshotRequest() + cmd := &cobra.Command{ + Use: "snapshot", + Short: "Create shapshots for udisks", + Long: "Create shapshots for udisks", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *udiskIDs { + id = ctx.PickResourceID(id) + req.UDiskId = &id + resp, err := client.CreateUDiskSnapshot(req) + if err != nil { + ctx.HandleError(err) + return + } + if len(resp.SnapshotId) == 1 { + text := fmt.Sprintf("snapshot[%s] is creating", resp.SnapshotId[0]) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeSnapshotByID(ctx)).Spoll(resp.SnapshotId[0], text, []string{SNAPSHOT_NORMAL}) + } + results = append(results, cli.OpResultRow{ResourceID: resp.SnapshotId[0], Action: "snapshot", Status: "Creating"}) + } else { + fmt.Fprintf(w, "snapshot%v is creating. expect snapshot count 1, accept %d\n", resp.SnapshotId, len(resp.SnapshotId)) + for _, sid := range resp.SnapshotId { + results = append(results, cli.OpResultRow{ResourceID: sid, Action: "snapshot", Status: "Creating"}) + } + } + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + udiskIDs = flags.StringSlice("udisk-id", nil, "Required. Resource ID of udisks to snapshot") + req.Name = flags.String("name", "", "Required. Name of snapshots") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + req.Comment = flags.String("comment", "", "Optional. Description of snapshots") + async = flags.BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + command.SetCompletion(cmd, "udisk-id", func() []string { + return getDiskList(ctx, []string{DISK_AVAILABLE, DISK_INUSE}, *req.ProjectId, *req.Region, *req.Zone) + }) + cmd.MarkFlagRequired("udisk-id") + cmd.MarkFlagRequired("name") + return cmd +} diff --git a/products/udisk/internal/udisk/status.go b/products/udisk/internal/udisk/status.go new file mode 100644 index 0000000000..fc65f5e80f --- /dev/null +++ b/products/udisk/internal/udisk/status.go @@ -0,0 +1,14 @@ +package udisk + +// UDisk-domain state constants plus constants this product depends on, +// product-owned copies (formerly model/status). +const ( + HOST_RUNNING = "Running" + HOST_STOPPED = "Stopped" + + DISK_INUSE = "InUse" + DISK_AVAILABLE = "Available" + DISK_FAILED = "Failed" + + SNAPSHOT_NORMAL = "Normal" +) diff --git a/products/udisk/product.go b/products/udisk/product.go new file mode 100644 index 0000000000..13438292cf --- /dev/null +++ b/products/udisk/product.go @@ -0,0 +1,21 @@ +package udisk + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internaludisk "github.com/ucloud/ucloud-cli/products/udisk/internal/udisk" +) + +type product struct{} + +// New returns the udisk product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "udisk", Commands: []string{"udisk"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internaludisk.NewCommand(ctx)} +} diff --git a/products/udisk/product.yaml b/products/udisk/product.yaml new file mode 100644 index 0000000000..66e2b8ba60 --- /dev/null +++ b/products/udisk/product.yaml @@ -0,0 +1,7 @@ +# products/udisk/product.yaml — udisk 产品元数据(归属真源,owner 自治维护) +name: udisk +owners: + - pearlinpan +commands: + - udisk +enabled: true diff --git a/products/udisk/testdata/cmdtree.golden b/products/udisk/testdata/cmdtree.golden new file mode 100644 index 0000000000..1f288afa23 --- /dev/null +++ b/products/udisk/testdata/cmdtree.golden @@ -0,0 +1,88 @@ +ucloud udisk use=udisk short=Read and manipulate udisk instances +ucloud udisk attach use=attach short=Attach udisk instances to an uhost + flag=async short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udisk-id short= default=[] required=true + flag=uhost-id short= default= required=true + flag=zone short= default= required= +ucloud udisk clone use=clone short=Clone an udisk + flag=async short= default=false required= + flag=charge-type short= default=Month required= + flag=coupon-id short= default= required= + flag=enable-data-ark short= default=false required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=source-id short= default= required=true + flag=zone short= default= required= +ucloud udisk create use=create short=Create udisk instance + flag=async short= default=false required= + flag=charge-type short= default=Dynamic required= + flag=count short= default=1 required= + flag=enable-data-ark short= default=false required= + flag=group short= default=Default required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=size-gb short= default=10 required=true + flag=snapshot-id short= default= required= + flag=udisk-type short= default=Oridinary required= + flag=zone short= default= required= +ucloud udisk delete use=delete short=Delete udisk instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=udisk-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud udisk delete-snapshot use=delete-snapshot short=Delete snapshots + flag=project-id short= default= required= + flag=region short= default= required= + flag=snapshot-id short= default=[] required=true + flag=zone short= default= required= +ucloud udisk detach use=detach short=Detach udisk instances from an uhost + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udisk-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud udisk expand use=expand short=Expand udisk size + flag=project-id short= default= required= + flag=region short= default= required= + flag=size-gb short= default=0 required=true + flag=udisk-id short= default=[] required=true + flag=zone short= default= required= +ucloud udisk list use=list short=List udisk instance + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udisk-id short= default= required= + flag=udisk-type short= default= required= + flag=zone short= default= required= +ucloud udisk list-snapshot use=list-snapshot short=List snapshots + flag=disk-id short= default= required= + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=snapshot-id short= default=[] required= + flag=uhost-id short= default= required= + flag=zone short= default= required= +ucloud udisk restore use=restore short=Restore udisk from snapshot + flag=project-id short= default= required= + flag=region short= default= required= + flag=snapshot-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud udisk snapshot use=snapshot short=Create shapshots for udisks + flag=async short=a default=false required= + flag=comment short= default= required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=udisk-id short= default=[] required=true + flag=zone short= default= required= diff --git a/products/udisk/testdata/completion.golden b/products/udisk/testdata/completion.golden new file mode 100644 index 0000000000..3d49711303 --- /dev/null +++ b/products/udisk/testdata/completion.golden @@ -0,0 +1,14 @@ +ucloud udisk attach udisk-id dynamic +ucloud udisk attach uhost-id dynamic +ucloud udisk clone charge-type static Dynamic,Month,Trial,Year +ucloud udisk clone enable-data-ark static false,true +ucloud udisk clone source-id dynamic +ucloud udisk create charge-type static Dynamic,Month,Trial,Year +ucloud udisk create enable-data-ark static false,true +ucloud udisk create udisk-type static Oridinary,SSD +ucloud udisk delete udisk-id dynamic +ucloud udisk detach udisk-id dynamic +ucloud udisk expand udisk-id dynamic +ucloud udisk list udisk-type static Oridinary-Data-Disk,Oridinary-System-Disk,SSD-Data-Disk +ucloud udisk restore snapshot-id dynamic +ucloud udisk snapshot udisk-id dynamic diff --git a/products/udns/internal/udns/associate_vpc.go b/products/udns/internal/udns/associate_vpc.go new file mode 100644 index 0000000000..58dd862f26 --- /dev/null +++ b/products/udns/internal/udns/associate_vpc.go @@ -0,0 +1,42 @@ +package udns + +import ( + "fmt" + + "github.com/spf13/cobra" + + udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newAssociateVPCCommand(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udnssdk.NewClient) + req := client.NewAssociateUDNSZoneVPCRequest() + cmd := &cobra.Command{ + Use: "associate-vpc", + Short: "Associate a UDNS zone with a VPC", + Long: "Associate a UDNS zone with a VPC", + Run: func(cmd *cobra.Command, args []string) { + zoneID := ctx.PickResourceID(*req.DNSZoneId) + req.DNSZoneId = &zoneID + _, err := client.AssociateUDNSZoneVPC(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "zone[%s] associated with vpc[%s]\n", zoneID, *req.VPCId) + ctx.EmitResult(cli.OpResultRow{ResourceID: zoneID, Action: "associate-vpc", Status: "Associated"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID") + req.VPCId = flags.String("vpc-id", "", "Required. VPC resource ID") + req.VPCProjectId = flags.String("vpc-project-id", "", "Required. Project ID that owns the VPC") + ctx.BindRegion(cmd, req) + cmd.MarkFlagRequired("zone-id") + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("vpc-project-id") + return cmd +} diff --git a/products/udns/internal/udns/cmd.go b/products/udns/internal/udns/cmd.go new file mode 100644 index 0000000000..551d60c4ee --- /dev/null +++ b/products/udns/internal/udns/cmd.go @@ -0,0 +1,22 @@ +package udns + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func NewCommand(ctx *cli.Context) *cobra.Command { + root := &cobra.Command{ + Use: "udns", + Short: "List and manipulate ucloud private dns(udns) instance and record", + Long: "List and manipulate ucloud private dns(udns) instance and record", + } + root.AddCommand(newCreateCommand(ctx)) + root.AddCommand(newListCommand(ctx)) + root.AddCommand(newModifyCommand(ctx)) + root.AddCommand(newAssociateVPCCommand(ctx)) + root.AddCommand(newDisassociateVPCCommand(ctx)) + root.AddCommand(newRecordCommand(ctx)) + return root +} diff --git a/products/udns/internal/udns/create.go b/products/udns/internal/udns/create.go new file mode 100644 index 0000000000..781599be6c --- /dev/null +++ b/products/udns/internal/udns/create.go @@ -0,0 +1,48 @@ +package udns + +import ( + "fmt" + + "github.com/spf13/cobra" + + udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newCreateCommand(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udnssdk.NewClient) + req := client.NewCreateUDNSZoneRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a UDNS private DNS zone", + Long: "Create a UDNS private DNS zone", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.CreateUDNSZone(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "zone[%s] created\n", resp.DNSZoneId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.DNSZoneId, Action: "create", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.DNSZoneName = flags.String("zone-name", "", "Required. Domain name string") + req.Type = flags.String("type", "", "Required. Zone type: private or public") + req.ChargeType = flags.String("charge-type", "Month", "Optional. Year, Month, or Dynamic; default Month") + req.Quantity = flags.Int("quantity", 1, "Optional. Purchase duration; default 1") + req.IsRecursionEnabled = flags.String("recursion", "", "Optional. enable or disable") + req.Tag = flags.String("tag", "", "Optional. Business group") + req.Remark = flags.String("remark", "", "Optional. Remark") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + command.SetFlagValues(cmd, "type", "private", "public") + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic") + command.SetFlagValues(cmd, "recursion", "enable", "disable") + cmd.MarkFlagRequired("zone-name") + cmd.MarkFlagRequired("type") + return cmd +} diff --git a/products/udns/internal/udns/disassociate_vpc.go b/products/udns/internal/udns/disassociate_vpc.go new file mode 100644 index 0000000000..2c6773091d --- /dev/null +++ b/products/udns/internal/udns/disassociate_vpc.go @@ -0,0 +1,42 @@ +package udns + +import ( + "fmt" + + "github.com/spf13/cobra" + + udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newDisassociateVPCCommand(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udnssdk.NewClient) + req := client.NewDisassociateUDNSZoneVPCRequest() + cmd := &cobra.Command{ + Use: "disassociate-vpc", + Short: "Disassociate a UDNS zone from a VPC", + Long: "Disassociate a UDNS zone from a VPC", + Run: func(cmd *cobra.Command, args []string) { + zoneID := ctx.PickResourceID(*req.DNSZoneId) + req.DNSZoneId = &zoneID + _, err := client.DisassociateUDNSZoneVPC(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "zone[%s] disassociated from vpc[%s]\n", zoneID, *req.VPCId) + ctx.EmitResult(cli.OpResultRow{ResourceID: zoneID, Action: "disassociate-vpc", Status: "Disassociated"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID") + req.VPCId = flags.String("vpc-id", "", "Required. VPC resource ID") + req.VPCProjectId = flags.String("vpc-project-id", "", "Required. Project ID that owns the VPC") + ctx.BindRegion(cmd, req) + cmd.MarkFlagRequired("zone-id") + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("vpc-project-id") + return cmd +} diff --git a/products/udns/internal/udns/list.go b/products/udns/internal/udns/list.go new file mode 100644 index 0000000000..b6fdb0935d --- /dev/null +++ b/products/udns/internal/udns/list.go @@ -0,0 +1,62 @@ +package udns + +import ( + "strings" + "time" + + "github.com/spf13/cobra" + + udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newListCommand(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udnssdk.NewClient) + req := client.NewDescribeUDNSZoneRequest() + var zoneIDs []string + cmd := &cobra.Command{ + Use: "list", + Short: "List UDNS zones", + Long: "List UDNS zones", + Run: func(cmd *cobra.Command, args []string) { + req.DNSZoneIds = zoneIDs + resp, err := client.DescribeUDNSZone(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]zoneRow, 0, len(resp.DNSZoneInfos)) + for _, z := range resp.DNSZoneInfos { + rows = append(rows, toZoneRow(z)) + } + ctx.PrintList(rows) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&zoneIDs, "zone-id", nil, "Optional. Filter by zone ID (repeatable)") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.Offset = flags.Int("offset", 0, "Optional. Pagination offset; default 0") + req.Limit = flags.Int("limit", 20, "Optional. Pagination limit; default 20") + return cmd +} + +func toZoneRow(z udnssdk.ZoneInfo) zoneRow { + vpcIDs := make([]string, 0, len(z.VPCInfos)) + for _, v := range z.VPCInfos { + vpcIDs = append(vpcIDs, v.VPCId) + } + return zoneRow{ + ZoneID: z.DNSZoneId, + Name: z.DNSZoneName, + ChargeType: z.ChargeType, + Recursion: z.IsRecursionEnabled, + VPCs: strings.Join(vpcIDs, ","), + Tag: z.Tag, + Remark: z.Remark, + CreateTime: time.Unix(int64(z.CreateTime), 0).Format("2006-01-02"), + ExpireTime: time.Unix(int64(z.ExpireTime), 0).Format("2006-01-02"), + } +} diff --git a/products/udns/internal/udns/modify.go b/products/udns/internal/udns/modify.go new file mode 100644 index 0000000000..2b7585bb80 --- /dev/null +++ b/products/udns/internal/udns/modify.go @@ -0,0 +1,43 @@ +package udns + +import ( + "fmt" + + "github.com/spf13/cobra" + + udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newModifyCommand(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udnssdk.NewClient) + req := client.NewModifyUDNSZoneRequest() + cmd := &cobra.Command{ + Use: "modify", + Short: "Modify a UDNS zone", + Long: "Modify a UDNS zone (recursion and remark only)", + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*req.DNSZoneId) + req.DNSZoneId = &id + _, err := client.ModifyUDNSZone(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "zone[%s] modified\n", id) + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "modify", Status: "Modified"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID") + req.IsRecursionEnabled = flags.String("recursion", "", "Optional. enable or disable") + req.Remark = flags.String("remark", "", "Optional. Remark") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + command.SetFlagValues(cmd, "recursion", "enable", "disable") + cmd.MarkFlagRequired("zone-id") + return cmd +} diff --git a/products/udns/internal/udns/record.go b/products/udns/internal/udns/record.go new file mode 100644 index 0000000000..dbf9e0d953 --- /dev/null +++ b/products/udns/internal/udns/record.go @@ -0,0 +1,20 @@ +package udns + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newRecordCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "record", + Short: "Manage DNS records within a UDNS zone", + Long: "Manage DNS records within a UDNS zone", + } + cmd.AddCommand(newRecordListCommand(ctx)) + cmd.AddCommand(newRecordCreateCommand(ctx)) + cmd.AddCommand(newRecordModifyCommand(ctx)) + cmd.AddCommand(newRecordDeleteCommand(ctx)) + return cmd +} diff --git a/products/udns/internal/udns/record_create.go b/products/udns/internal/udns/record_create.go new file mode 100644 index 0000000000..8deed7fa62 --- /dev/null +++ b/products/udns/internal/udns/record_create.go @@ -0,0 +1,50 @@ +package udns + +import ( + "fmt" + + "github.com/spf13/cobra" + + udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newRecordCreateCommand(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udnssdk.NewClient) + req := client.NewCreateUDNSRecordRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a DNS record in a UDNS zone", + Long: "Create a DNS record in a UDNS zone", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.CreateUDNSRecord(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "record[%s] created\n", resp.DNSRecordId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.DNSRecordId, Action: "create", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID") + req.Name = flags.String("name", "", "Required. Host record (subdomain prefix)") + req.Type = flags.String("type", "", "Required. Record type: A, AAAA, CNAME, MX, TXT, SRV, PTR") + req.Value = flags.String("value", "", `Required. Value string: "IP|weight|enabled,..." e.g. "192.168.1.1|1|1"`) + req.ValueType = flags.String("value-type", "", "Required. Normal or Multivalue") + req.TTL = flags.Int("ttl", 5, "Optional. TTL in seconds (5-600); default 5") + req.Remark = flags.String("remark", "", "Optional. Remark") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + command.SetFlagValues(cmd, "type", "A", "AAAA", "CNAME", "MX", "TXT", "SRV", "PTR") + command.SetFlagValues(cmd, "value-type", "Normal", "Multivalue") + cmd.MarkFlagRequired("zone-id") + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("type") + cmd.MarkFlagRequired("value") + cmd.MarkFlagRequired("value-type") + return cmd +} diff --git a/products/udns/internal/udns/record_delete.go b/products/udns/internal/udns/record_delete.go new file mode 100644 index 0000000000..f59915d6e0 --- /dev/null +++ b/products/udns/internal/udns/record_delete.go @@ -0,0 +1,58 @@ +package udns + +import ( + "fmt" + + "github.com/spf13/cobra" + + udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newRecordDeleteCommand(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udnssdk.NewClient) + req := client.NewDeleteUDNSRecordRequest() + var recordIDs []string + var yes bool + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete DNS records from a UDNS zone", + Long: "Delete DNS records from a UDNS zone", + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the record(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + for i, id := range recordIDs { + recordIDs[i] = ctx.PickResourceID(id) + } + req.RecordIds = recordIDs + _, err = client.DeleteUDNSRecord(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "records %v deleted from zone[%s]\n", recordIDs, *req.DNSZoneId) + results := make([]cli.OpResultRow, 0, len(recordIDs)) + for _, id := range recordIDs { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID") + flags.StringSliceVar(&recordIDs, "record-id", nil, "Required. Record resource ID (repeatable)") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("zone-id") + cmd.MarkFlagRequired("record-id") + return cmd +} diff --git a/products/udns/internal/udns/record_list.go b/products/udns/internal/udns/record_list.go new file mode 100644 index 0000000000..034d556532 --- /dev/null +++ b/products/udns/internal/udns/record_list.go @@ -0,0 +1,63 @@ +package udns + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newRecordListCommand(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udnssdk.NewClient) + req := client.NewDescribeUDNSRecordRequest() + var recordIDs []string + cmd := &cobra.Command{ + Use: "list", + Short: "List DNS records in a UDNS zone", + Long: "List DNS records in a UDNS zone", + Run: func(cmd *cobra.Command, args []string) { + req.RecordIds = recordIDs + resp, err := client.DescribeUDNSRecord(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]recordRow, 0, len(resp.RecordInfos)) + for _, r := range resp.RecordInfos { + rows = append(rows, toRecordRow(r)) + } + ctx.PrintList(rows) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID") + flags.StringSliceVar(&recordIDs, "record-id", nil, "Optional. Filter by record ID (repeatable)") + req.Query = flags.String("query", "", "Optional. Fuzzy search string") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.Offset = flags.Int("offset", 0, "Optional. Pagination offset; default 0") + req.Limit = flags.Int("limit", 20, "Optional. Pagination limit; default 20") + cmd.MarkFlagRequired("zone-id") + return cmd +} + +func toRecordRow(r udnssdk.RecordInfo) recordRow { + values := make([]string, 0, len(r.ValueSet)) + for _, v := range r.ValueSet { + values = append(values, fmt.Sprintf("%s|%d|%d", v.Data, v.Weight, v.IsEnabled)) + } + return recordRow{ + RecordID: r.RecordId, + Name: r.Name, + Type: r.Type, + TTL: fmt.Sprintf("%d", r.TTL), + Values: strings.Join(values, ","), + ValueType: r.ValueType, + Remark: r.Remark, + } +} diff --git a/products/udns/internal/udns/record_modify.go b/products/udns/internal/udns/record_modify.go new file mode 100644 index 0000000000..f93a72c27c --- /dev/null +++ b/products/udns/internal/udns/record_modify.go @@ -0,0 +1,54 @@ +package udns + +import ( + "fmt" + + "github.com/spf13/cobra" + + udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newRecordModifyCommand(ctx *cli.Context) *cobra.Command { + var ttl int + client := cli.NewServiceClient(ctx, udnssdk.NewClient) + req := client.NewModifyUDNSRecordRequest() + cmd := &cobra.Command{ + Use: "modify", + Short: "Modify a DNS record in a UDNS zone", + Long: "Modify a DNS record in a UDNS zone", + Run: func(cmd *cobra.Command, args []string) { + if cmd.Flags().Changed("ttl") { + req.TTL = &ttl + } + recordID := ctx.PickResourceID(*req.RecordId) + req.RecordId = &recordID + _, err := client.ModifyUDNSRecord(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "record[%s] modified\n", recordID) + ctx.EmitResult(cli.OpResultRow{ResourceID: recordID, Action: "modify", Status: "Modified"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID") + req.RecordId = flags.String("record-id", "", "Required. Record resource ID") + req.Type = flags.String("type", "", "Optional. Record type: A, AAAA, CNAME, MX, TXT, SRV, PTR") + req.Value = flags.String("value", "", `Optional. Value string: "IP|weight|enabled,..."`) + req.ValueType = flags.String("value-type", "", "Optional. Normal or Multivalue") + //req.TTL = flags.Int("ttl", 0, "Optional. TTL in seconds (5-600); 0 means unchanged") + flags.IntVar(&ttl, "ttl", 0, "Optional. TTL in seconds (5-600); 0 means unchanged") + req.Remark = flags.String("remark", "", "Optional. Remark") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + command.SetFlagValues(cmd, "type", "A", "AAAA", "CNAME", "MX", "TXT", "SRV", "PTR") + command.SetFlagValues(cmd, "value-type", "Normal", "Multivalue") + cmd.MarkFlagRequired("zone-id") + cmd.MarkFlagRequired("record-id") + return cmd +} diff --git a/products/udns/internal/udns/rows.go b/products/udns/internal/udns/rows.go new file mode 100644 index 0000000000..2375d56a27 --- /dev/null +++ b/products/udns/internal/udns/rows.go @@ -0,0 +1,23 @@ +package udns + +type zoneRow struct { + ZoneID string + Name string + ChargeType string + Recursion string + VPCs string + Tag string + Remark string + CreateTime string + ExpireTime string +} + +type recordRow struct { + RecordID string + Name string + Type string + TTL string + Values string + ValueType string + Remark string +} diff --git a/products/udns/product.go b/products/udns/product.go new file mode 100644 index 0000000000..5504ff5db3 --- /dev/null +++ b/products/udns/product.go @@ -0,0 +1,27 @@ +package udns + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internaludns "github.com/ucloud/ucloud-cli/products/udns/internal/udns" +) + +type udns struct{} + +func New() cli.Product { + return udns{} +} + +func (u udns) Metadata() cli.Metadata { + return cli.Metadata{ + Name: "udns", + Commands: []string{"udns"}, + } +} + +func (u udns) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internaludns.NewCommand(ctx)} +} + +var _ cli.Product = udns{} diff --git a/products/udns/product.yaml b/products/udns/product.yaml new file mode 100644 index 0000000000..829290591e --- /dev/null +++ b/products/udns/product.yaml @@ -0,0 +1,7 @@ +# products/udns/product.yaml — udisk 产品元数据(归属真源,owner 自治维护) +name: udns +owners: + - mingfeng-ucloud +commands: + - udns +enabled: true diff --git a/products/udns/testdata/cmdtree.golden b/products/udns/testdata/cmdtree.golden new file mode 100644 index 0000000000..ba662323d0 --- /dev/null +++ b/products/udns/testdata/cmdtree.golden @@ -0,0 +1,68 @@ +ucloud udns use=udns short=List and manipulate ucloud private dns(udns) instance and record +ucloud udns associate-vpc use=associate-vpc short=Associate a UDNS zone with a VPC + flag=region short= default= required= + flag=vpc-id short= default= required=true + flag=vpc-project-id short= default= required=true + flag=zone-id short= default= required=true +ucloud udns create use=create short=Create a UDNS private DNS zone + flag=charge-type short= default=Month required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=recursion short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=tag short= default= required= + flag=type short= default= required=true + flag=zone-name short= default= required=true +ucloud udns disassociate-vpc use=disassociate-vpc short=Disassociate a UDNS zone from a VPC + flag=region short= default= required= + flag=vpc-id short= default= required=true + flag=vpc-project-id short= default= required=true + flag=zone-id short= default= required=true +ucloud udns list use=list short=List UDNS zones + flag=limit short= default=20 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone-id short= default=[] required= +ucloud udns modify use=modify short=Modify a UDNS zone + flag=project-id short= default= required= + flag=recursion short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=zone-id short= default= required=true +ucloud udns record use=record short=Manage DNS records within a UDNS zone +ucloud udns record create use=create short=Create a DNS record in a UDNS zone + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=ttl short= default=5 required= + flag=type short= default= required=true + flag=value short= default= required=true + flag=value-type short= default= required=true + flag=zone-id short= default= required=true +ucloud udns record delete use=delete short=Delete DNS records from a UDNS zone + flag=project-id short= default= required= + flag=record-id short= default=[] required=true + flag=region short= default= required= + flag=yes short=y default=false required= + flag=zone-id short= default= required=true +ucloud udns record list use=list short=List DNS records in a UDNS zone + flag=limit short= default=20 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=query short= default= required= + flag=record-id short= default=[] required= + flag=region short= default= required= + flag=zone-id short= default= required=true +ucloud udns record modify use=modify short=Modify a DNS record in a UDNS zone + flag=project-id short= default= required= + flag=record-id short= default= required=true + flag=region short= default= required= + flag=remark short= default= required= + flag=ttl short= default=0 required= + flag=type short= default= required= + flag=value short= default= required= + flag=value-type short= default= required= + flag=zone-id short= default= required=true diff --git a/products/udns/testdata/completion.golden b/products/udns/testdata/completion.golden new file mode 100644 index 0000000000..649ac668a1 --- /dev/null +++ b/products/udns/testdata/completion.golden @@ -0,0 +1,24 @@ +ucloud udns associate-vpc region dynamic +ucloud udns create charge-type static Dynamic,Month,Year +ucloud udns create project-id dynamic +ucloud udns create recursion static disable,enable +ucloud udns create region dynamic +ucloud udns create type static private,public +ucloud udns disassociate-vpc region dynamic +ucloud udns list project-id dynamic +ucloud udns list region dynamic +ucloud udns modify project-id dynamic +ucloud udns modify recursion static disable,enable +ucloud udns modify region dynamic +ucloud udns record create project-id dynamic +ucloud udns record create region dynamic +ucloud udns record create type static A,AAAA,CNAME,MX,PTR,SRV,TXT +ucloud udns record create value-type static Multivalue,Normal +ucloud udns record delete project-id dynamic +ucloud udns record delete region dynamic +ucloud udns record list project-id dynamic +ucloud udns record list region dynamic +ucloud udns record modify project-id dynamic +ucloud udns record modify region dynamic +ucloud udns record modify type static A,AAAA,CNAME,MX,PTR,SRV,TXT +ucloud udns record modify value-type static Multivalue,Normal diff --git a/products/udpn/internal/udpn/cmd.go b/products/udpn/internal/udpn/cmd.go new file mode 100644 index 0000000000..58fd0126f2 --- /dev/null +++ b/products/udpn/internal/udpn/cmd.go @@ -0,0 +1,23 @@ +package udpn + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `udpn` root command. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "udpn", + Short: "List and manipulate udpn instances", + Long: "List and manipulate udpn instances", + } + + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newModifyBW(ctx)) + + return cmd +} diff --git a/products/udpn/internal/udpn/completion.go b/products/udpn/internal/udpn/completion.go new file mode 100644 index 0000000000..3423b9254e --- /dev/null +++ b/products/udpn/internal/udpn/completion.go @@ -0,0 +1,43 @@ +package udpn + +import ( + "fmt" + + udpnsdk "github.com/ucloud/ucloud-sdk-go/services/udpn" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func getAllUDPNIns(ctx *cli.Context, project, region string) ([]udpnsdk.UDPNData, error) { + client := cli.NewServiceClient(ctx, udpnsdk.NewClient) + req := client.NewDescribeUDPNRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + list := make([]udpnsdk.UDPNData, 0) + for offset, limit := 0, 50; ; offset += limit { + req.Offset = sdk.Int(offset) + req.Limit = sdk.Int(limit) + resp, err := client.DescribeUDPN(req) + if err != nil { + return nil, err + } + list = append(list, resp.DataSet...) + if offset+limit > resp.TotalCount { + break + } + } + return list, nil +} + +func getAllUDPNIdNames(ctx *cli.Context, project, region string) []string { + udpnInsList, err := getAllUDPNIns(ctx, project, region) + if err != nil { + return nil + } + idNameList := []string{} + for _, udpn := range udpnInsList { + idNameList = append(idNameList, fmt.Sprintf("%s/%s:%s", udpn.UDPNId, udpn.Peer1, udpn.Peer2)) + } + return idNameList +} diff --git a/products/udpn/internal/udpn/create.go b/products/udpn/internal/udpn/create.go new file mode 100644 index 0000000000..ff8fd496ab --- /dev/null +++ b/products/udpn/internal/udpn/create.go @@ -0,0 +1,68 @@ +package udpn + +import ( + "fmt" + + "github.com/spf13/cobra" + + udpnsdk "github.com/ucloud/ucloud-sdk-go/services/udpn" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud udpn create +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udpnsdk.NewClient) + req := client.NewAllocateUDPNRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create UDPN tunnel", + Long: "Create UDPN tunnel", + Run: func(c *cobra.Command, args []string) { + if *req.Peer1 == *req.Peer2 { + fmt.Fprintln(ctx.ProgressWriter(), "Error, flags peer1 and peer2 can't be equal") + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + resp, err := client.AllocateUDPN(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "udpn[%s] created\n", resp.UDPNId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.UDPNId, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.Peer1 = flags.String("peer1", ctx.DefaultRegion(), "Required. One end of the tunnel to create") + req.Peer2 = flags.String("peer2", "", "Required. The other end of the tunnel create") + req.Bandwidth = flags.Int("bandwidth-mb", 0, "Required. Bandwidth of the tunnel to create. Unit:Mb. Rnange [2,1000]") + req.ChargeType = flags.String("charge-type", "", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") + req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic") + ctx.SetCompletion(cmd, "project-id", ctx.ProjectList) + ctx.SetCompletion(cmd, "peer1", ctx.RegionList) + ctx.SetCompletion(cmd, "peer2", func() []string { + regions := ctx.RegionList() + list := []string{} + for _, r := range regions { + if r != *req.Peer1 { + list = append(list, r) + } + } + return list + }) + + cmd.MarkFlagRequired("peer1") + cmd.MarkFlagRequired("peer2") + cmd.MarkFlagRequired("bandwidth-mb") + + return cmd +} diff --git a/products/udpn/internal/udpn/delete.go b/products/udpn/internal/udpn/delete.go new file mode 100644 index 0000000000..63a584af9a --- /dev/null +++ b/products/udpn/internal/udpn/delete.go @@ -0,0 +1,53 @@ +package udpn + +import ( + "fmt" + + "github.com/spf13/cobra" + + udpnsdk "github.com/ucloud/ucloud-sdk-go/services/udpn" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete ucloud udpn delete +func newDelete(ctx *cli.Context) *cobra.Command { + idNames := []string{} + client := cli.NewServiceClient(ctx, udpnsdk.NewClient) + req := client.NewReleaseUDPNRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "delete udpn instances", + Long: "delete udpn instances", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.UDPNId = sdk.String(id) + _, err := client.ReleaseUDPN(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "udpn[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udpn-id", nil, "Required. Resource ID of udpn instances to delete") + ctx.BindProjectID(cmd, req) + + ctx.SetCompletion(cmd, "udpn-id", func() []string { + return getAllUDPNIdNames(ctx, *req.ProjectId, ctx.DefaultRegion()) + }) + + cmd.MarkFlagRequired("udpn-id") + + return cmd +} diff --git a/products/udpn/internal/udpn/list.go b/products/udpn/internal/udpn/list.go new file mode 100644 index 0000000000..d14671a4ef --- /dev/null +++ b/products/udpn/internal/udpn/list.go @@ -0,0 +1,58 @@ +package udpn + +import ( + "fmt" + + "github.com/spf13/cobra" + + udpnsdk "github.com/ucloud/ucloud-sdk-go/services/udpn" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList ucloud udpn list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, udpnsdk.NewClient) + req := client.NewDescribeUDPNRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List udpn instances", + Long: "List udpn instances", + Run: func(c *cobra.Command, args []string) { + req.UDPNId = sdk.String(ctx.PickResourceID(*req.UDPNId)) + resp, err := client.DescribeUDPN(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []UDPNRow{} + for _, udpn := range resp.DataSet { + list = append(list, UDPNRow{ + ResourceID: udpn.UDPNId, + Peers: fmt.Sprintf("%s <--> %s", udpn.Peer1, udpn.Peer2), + Bandwidth: fmt.Sprintf("%dMb", udpn.Bandwidth), + ChargeType: udpn.ChargeType, + CreationTime: common.FormatDate(udpn.CreateTime), + }) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.UDPNId = flags.String("udpn-id", "", "Optional. Resource ID of udpn instances to list") + ctx.BindOffset(cmd, req) + req.Limit = flags.Int("limit", 50, "Optional. Limit") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + ctx.SetCompletion(cmd, "udpn-id", func() []string { + return getAllUDPNIdNames(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/udpn/internal/udpn/modify_bw.go b/products/udpn/internal/udpn/modify_bw.go new file mode 100644 index 0000000000..5eec0cdf65 --- /dev/null +++ b/products/udpn/internal/udpn/modify_bw.go @@ -0,0 +1,56 @@ +package udpn + +import ( + "fmt" + + "github.com/spf13/cobra" + + udpnsdk "github.com/ucloud/ucloud-sdk-go/services/udpn" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newModifyBW ucloud udpn modify-bw +func newModifyBW(ctx *cli.Context) *cobra.Command { + idNames := []string{} + client := cli.NewServiceClient(ctx, udpnsdk.NewClient) + req := client.NewModifyUDPNBandwidthRequest() + cmd := &cobra.Command{ + Use: "modify-bw", + Short: "Modify bandwidth of UDPN tunnel", + Long: "Modify bandwidth of UDPN tunnel", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.UDPNId = sdk.String(id) + _, err := client.ModifyUDPNBandwidth(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "udpn[%s]'s bandwidth modified\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "modify-bw", Status: "Modified"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "udpn-id", nil, "Required. Resource ID of UDPN to modify bandwidth") + req.Bandwidth = flags.Int("bandwidth-mb", 0, "Required. Bandwidth of UDPN tunnel. Unit:Mb. Range [2,1000]") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Region, see 'ucloud region'") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + ctx.SetCompletion(cmd, "udpn-id", func() []string { + return getAllUDPNIdNames(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("udpn-id") + cmd.MarkFlagRequired("bandwidth-mb") + + return cmd +} diff --git a/products/udpn/internal/udpn/rows.go b/products/udpn/internal/udpn/rows.go new file mode 100644 index 0000000000..1dd4af6d62 --- /dev/null +++ b/products/udpn/internal/udpn/rows.go @@ -0,0 +1,10 @@ +package udpn + +// UDPNRow is the table row for `ucloud udpn list`. +type UDPNRow struct { + ResourceID string + Peers string + Bandwidth string + ChargeType string + CreationTime string +} diff --git a/products/udpn/product.go b/products/udpn/product.go new file mode 100644 index 0000000000..f0504eb686 --- /dev/null +++ b/products/udpn/product.go @@ -0,0 +1,21 @@ +package udpn + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internaludpn "github.com/ucloud/ucloud-cli/products/udpn/internal/udpn" +) + +type product struct{} + +// New returns the udpn product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "udpn", Commands: []string{"udpn"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internaludpn.NewCommand(ctx)} +} diff --git a/products/udpn/product.yaml b/products/udpn/product.yaml new file mode 100644 index 0000000000..06c927ec9d --- /dev/null +++ b/products/udpn/product.yaml @@ -0,0 +1,7 @@ +# products/udpn/product.yaml - UDPN product metadata. +name: udpn +owners: + - Episkey-G +commands: + - udpn +enabled: true diff --git a/products/udpn/testdata/cmdtree.golden b/products/udpn/testdata/cmdtree.golden new file mode 100644 index 0000000000..8d5488e22e --- /dev/null +++ b/products/udpn/testdata/cmdtree.golden @@ -0,0 +1,22 @@ +ucloud udpn use=udpn short=List and manipulate udpn instances +ucloud udpn create use=create short=Create UDPN tunnel + flag=bandwidth-mb short= default=0 required=true + flag=charge-type short= default= required= + flag=peer1 short= default= required=true + flag=peer2 short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= +ucloud udpn delete use=delete short=delete udpn instances + flag=project-id short= default= required= + flag=udpn-id short= default=[] required=true +ucloud udpn list use=list short=List udpn instances + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=udpn-id short= default= required= +ucloud udpn modify-bw use=modify-bw short=Modify bandwidth of UDPN tunnel + flag=bandwidth-mb short= default=0 required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=udpn-id short= default=[] required=true diff --git a/products/udpn/testdata/completion.golden b/products/udpn/testdata/completion.golden new file mode 100644 index 0000000000..9c8c280253 --- /dev/null +++ b/products/udpn/testdata/completion.golden @@ -0,0 +1,10 @@ +ucloud udpn create charge-type static Dynamic,Month,Year +ucloud udpn create peer1 dynamic +ucloud udpn create peer2 dynamic +ucloud udpn create project-id dynamic +ucloud udpn delete project-id dynamic +ucloud udpn delete udpn-id dynamic +ucloud udpn list project-id dynamic +ucloud udpn list region dynamic +ucloud udpn list udpn-id dynamic +ucloud udpn modify-bw udpn-id dynamic diff --git a/products/ufs/internal/ufs/cmd.go b/products/ufs/internal/ufs/cmd.go new file mode 100644 index 0000000000..8fc245f82c --- /dev/null +++ b/products/ufs/internal/ufs/cmd.go @@ -0,0 +1,20 @@ +package ufs + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `ufs` root command and mounts the subcommands. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ufs", + Short: "Manage UFS (UCloud File Storage) volumes", + Long: "Manage UFS (UCloud File Storage) volumes", + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newDelete(ctx)) + return cmd +} diff --git a/products/ufs/internal/ufs/create.go b/products/ufs/internal/ufs/create.go new file mode 100644 index 0000000000..68b84b9c4d --- /dev/null +++ b/products/ufs/internal/ufs/create.go @@ -0,0 +1,58 @@ +package ufs + +import ( + "fmt" + + "github.com/spf13/cobra" + + ufssdk "github.com/ucloud/ucloud-sdk-go/services/ufs" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud ufs create +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ufssdk.NewClient) + req := client.NewCreateUFSVolumeRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a UFS volume", + Long: "Create a UFS volume", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + resp, err := client.CreateUFSVolume(req) + if err != nil { + ctx.HandleError(err) + return + } + + text := fmt.Sprintf("ufs:%v created", resp.VolumeId) + fmt.Fprintln(w, text) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.VolumeId, Action: "create", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.VolumeName = flags.String("name", "", "Required. Name of the UFS volume to create") + req.Size = flags.Int("size-gb", 100, "Required. Size of the UFS volume. Unit: GB") + req.StorageType = flags.String("storage-type", "Basic", "Optional. Storage type: 'Basic' (capacity) or 'Advanced' (performance)") + req.ProtocolType = flags.String("protocol-type", "NFS", "Optional. Protocol type: 'NFS' or 'SMB'") + req.ChargeType = flags.String("charge-type", "Dynamic", "Optional. 'Year', pay yearly; 'Month', pay monthly; 'Dynamic', pay hourly") + req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months") + req.Tag = flags.String("group", "Default", "Optional. Business group") + req.Remark = flags.String("remark", "", "Optional. Remark") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic", "Trial") + command.SetFlagValues(cmd, "storage-type", "Basic", "Advanced") + command.SetFlagValues(cmd, "protocol-type", "NFS", "SMB") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("size-gb") + + return cmd +} diff --git a/products/ufs/internal/ufs/delete.go b/products/ufs/internal/ufs/delete.go new file mode 100644 index 0000000000..d472b2d389 --- /dev/null +++ b/products/ufs/internal/ufs/delete.go @@ -0,0 +1,59 @@ +package ufs + +import ( + "fmt" + + "github.com/spf13/cobra" + + ufssdk "github.com/ucloud/ucloud-sdk-go/services/ufs" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete ucloud ufs delete +func newDelete(ctx *cli.Context) *cobra.Command { + var yes *bool + var volumeIDs *[]string + client := cli.NewServiceClient(ctx, ufssdk.NewClient) + req := client.NewRemoveUFSVolumeRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete UFS volume(s)", + Long: "Delete UFS volume(s)", + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(*yes, "Are you sure to delete UFS volume(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *volumeIDs { + id := ctx.PickResourceID(id) + req.VolumeId = &id + _, err := client.RemoveUFSVolume(req) + if err != nil { + ctx.HandleError(err) + continue + } else { + fmt.Fprintf(w, "ufs[%s] deleted\n", *req.VolumeId) + results = append(results, cli.OpResultRow{ResourceID: *req.VolumeId, Action: "delete", Status: "Deleted"}) + } + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + volumeIDs = flags.StringSlice("volume-id", nil, "Required. The Resource ID of UFS volumes to delete") + yes = flags.BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + + ctx.BindCommonParams(cmd, req) + + cmd.MarkFlagRequired("volume-id") + + return cmd +} diff --git a/products/ufs/internal/ufs/describe.go b/products/ufs/internal/ufs/describe.go new file mode 100644 index 0000000000..afa7eb4ad7 --- /dev/null +++ b/products/ufs/internal/ufs/describe.go @@ -0,0 +1,83 @@ +package ufs + +import ( + "fmt" + + "github.com/spf13/cobra" + + ufssdk "github.com/ucloud/ucloud-sdk-go/services/ufs" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDescribe ucloud ufs describe +func newDescribe(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ufssdk.NewClient) + req := client.NewDescribeUFSVolume2Request() + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe UFS volume(s)", + Long: "Describe UFS volume(s)", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.DescribeUFSVolume2(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []VolumeRow{} + for _, vol := range resp.DataSet { + row := VolumeRow{ + ResourceID: vol.VolumeId, + Name: vol.VolumeName, + Group: vol.Tag, + Size: fmt.Sprintf("%dGB", vol.Size), + UsedSize: fmt.Sprintf("%dGB", vol.UsedSize), + ProtocolType: vol.ProtocolType, + StorageType: vol.StorageType, + MountPoints: fmt.Sprintf("%d/%d", vol.TotalMountPointNum, vol.MaxMountPointNum), + State: vol.IsExpired, + CreationTime: common.FormatDate(vol.CreateTime), + Expiration: common.FormatDate(vol.ExpiredTime), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.VolumeId = flags.String("volume-id", "", "Optional. Resource ID of the UFS volume") + req.Limit = flags.Int("limit", 50, "Optional. Limit") + req.Offset = flags.Int("offset", 0, "Optional. Offset") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + return cmd +} + +// describeUfsByID returns the poller's describe func, closing over ctx so it +// can build an authed ufs client. +func describeUfsByID(ctx *cli.Context) func(volumeID string, commonBase *request.CommonBase) (interface{}, error) { + return func(volumeID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, ufssdk.NewClient) + req := client.NewDescribeUFSVolume2Request() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.VolumeId = &volumeID + limit := 50 + req.Limit = &limit + resp, err := client.DescribeUFSVolume2(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) < 1 { + return nil, nil + } + return &resp.DataSet[0], nil + } +} diff --git a/products/ufs/internal/ufs/rows.go b/products/ufs/internal/ufs/rows.go new file mode 100644 index 0000000000..dcf95e3c5b --- /dev/null +++ b/products/ufs/internal/ufs/rows.go @@ -0,0 +1,16 @@ +package ufs + +// VolumeRow represents a single row in the ufs volume list output. +type VolumeRow struct { + ResourceID string + Name string + Group string + Size string + UsedSize string + ProtocolType string + StorageType string + MountPoints string + State string + CreationTime string + Expiration string +} diff --git a/products/ufs/internal/ufs/status.go b/products/ufs/internal/ufs/status.go new file mode 100644 index 0000000000..6d14f6ed02 --- /dev/null +++ b/products/ufs/internal/ufs/status.go @@ -0,0 +1,8 @@ +package ufs + +// UFS-domain state constants. +const ( + VOLUME_CREATING = "Creating" + VOLUME_AVAILABLE = "Available" + VOLUME_FAILED = "Failed" +) diff --git a/products/ufs/product.go b/products/ufs/product.go new file mode 100644 index 0000000000..9d03bbe6c1 --- /dev/null +++ b/products/ufs/product.go @@ -0,0 +1,21 @@ +package ufs + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalufs "github.com/ucloud/ucloud-cli/products/ufs/internal/ufs" +) + +type product struct{} + +// New returns the ufs product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "ufs", Commands: []string{"ufs"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalufs.NewCommand(ctx)} +} diff --git a/products/ufs/product.yaml b/products/ufs/product.yaml new file mode 100644 index 0000000000..9653453d52 --- /dev/null +++ b/products/ufs/product.yaml @@ -0,0 +1,7 @@ +# products/ufs/product.yaml — ufs 产品元数据(归属真源,owner 自治维护) +name: ufs +owners: + - pearlinpan +commands: + - ufs +enabled: true diff --git a/products/ufs/testdata/cmdtree.golden b/products/ufs/testdata/cmdtree.golden new file mode 100644 index 0000000000..51a05eda54 --- /dev/null +++ b/products/ufs/testdata/cmdtree.golden @@ -0,0 +1,26 @@ +ucloud ufs use=ufs short=Manage UFS (UCloud File Storage) volumes +ucloud ufs create use=create short=Create a UFS volume + flag=charge-type short= default=Dynamic required= + flag=group short= default=Default required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=protocol-type short= default=NFS required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=remark short= default= required= + flag=size-gb short= default=100 required=true + flag=storage-type short= default=Basic required= + flag=zone short= default= required= +ucloud ufs delete use=delete short=Delete UFS volume(s) + flag=project-id short= default= required= + flag=region short= default= required= + flag=volume-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud ufs describe use=describe short=Describe UFS volume(s) + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=volume-id short= default= required= + flag=zone short= default= required= diff --git a/products/ufs/testdata/completion.golden b/products/ufs/testdata/completion.golden new file mode 100644 index 0000000000..d8717824cf --- /dev/null +++ b/products/ufs/testdata/completion.golden @@ -0,0 +1,12 @@ +ucloud ufs create charge-type static Dynamic,Month,Trial,Year +ucloud ufs create project-id dynamic +ucloud ufs create protocol-type static NFS,SMB +ucloud ufs create region dynamic +ucloud ufs create storage-type static Advanced,Basic +ucloud ufs create zone dynamic +ucloud ufs delete project-id dynamic +ucloud ufs delete region dynamic +ucloud ufs delete zone dynamic +ucloud ufs describe project-id dynamic +ucloud ufs describe region dynamic +ucloud ufs describe zone dynamic diff --git a/products/ugn/internal/ugn/bw.go b/products/ugn/internal/ugn/bw.go new file mode 100644 index 0000000000..b164ae6c2a --- /dev/null +++ b/products/ugn/internal/ugn/bw.go @@ -0,0 +1,23 @@ +package ugn + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newBW ucloud ugn bw +func newBW(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "bw", + Short: "List and manipulate ugn bandwidth packages", + Long: "List and manipulate ugn bandwidth packages", + } + + cmd.AddCommand(newBWCreate(ctx)) + cmd.AddCommand(newBWDelete(ctx)) + cmd.AddCommand(newBWList(ctx)) + cmd.AddCommand(newBWModifyBandwidth(ctx)) + + return cmd +} diff --git a/products/ugn/internal/ugn/bw_create.go b/products/ugn/internal/ugn/bw_create.go new file mode 100644 index 0000000000..6b026d2541 --- /dev/null +++ b/products/ugn/internal/ugn/bw_create.go @@ -0,0 +1,66 @@ +package ugn + +import ( + "fmt" + + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBWCreate ucloud ugn bw create +func newBWCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewCreateSimpleUGNBwPackageRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a ugn bandwidth package", + Long: "Create a ugn bandwidth package", + Run: func(c *cobra.Command, args []string) { + _, err := client.CreateSimpleUGNBwPackage(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ugn bw created\n") + ctx.EmitResult(cli.OpResultRow{Action: "create-bw", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance") + req.BandWidth = flags.Int("bandwidth", 0, "Required. Bandwidth value in Mbps") + req.Path = flags.String("path", "IGP", "Path policy: Delay/IGP/TCO") + req.PayMode = flags.String("pay-mode", "", "Required. Pay mode: FixedBw/Max5/Traffic") + req.ChargeType = flags.String("charge-type", "", "Required. Charge type: Month/Postpay") + req.RegionA = flags.String("region-a", "", "Required. Region A of the bandwidth package") + req.RegionB = flags.String("region-b", "", "Required. Region B of the bandwidth package") + req.Name = flags.String("name", "", "Optional. Bandwidth package name") + req.Qos = flags.String("qos", "Platinum", "Optional. QoS: Diamond/Platinum/Gold") + req.CouponId = flags.String("coupon-id", "", "Optional. Coupon ID") + req.Quantity = flags.Float64("quantity", 1, "Optional. Duration in months, default 1") + + ctx.BindProjectID(cmd, req) + ctx.SetCompletion(cmd, "ugn-id", func() []string { + return getAllUGNIdNames(ctx, *req.ProjectId) + }) + ctx.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetFlagValues(cmd, "path", "Delay", "IGP", "TCO") + command.SetFlagValues(cmd, "pay-mode", "FixedBw", "Max5", "Traffic") + command.SetFlagValues(cmd, "charge-type", "Month", "Postpay") + command.SetFlagValues(cmd, "qos", "Diamond", "Platinum", "Gold") + + cmd.MarkFlagRequired("ugn-id") + cmd.MarkFlagRequired("bandwidth") + cmd.MarkFlagRequired("pay-mode") + cmd.MarkFlagRequired("charge-type") + cmd.MarkFlagRequired("region-a") + cmd.MarkFlagRequired("region-b") + + return cmd +} diff --git a/products/ugn/internal/ugn/bw_delete.go b/products/ugn/internal/ugn/bw_delete.go new file mode 100644 index 0000000000..831d479bec --- /dev/null +++ b/products/ugn/internal/ugn/bw_delete.go @@ -0,0 +1,53 @@ +package ugn + +import ( + "fmt" + + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newBWDelete ucloud ugn bw delete +func newBWDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewDeleteUGNBwPackageRequest() + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete ugn bandwidth packages", + Long: "Delete ugn bandwidth packages", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the bandwidth package?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + _, err = client.DeleteUGNBwPackage(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ugn bw[%s] deleted\n", *req.BwPackageID) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.BwPackageID, Action: "delete-bw", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.BwPackageID = flags.String("bw-package-id", "", "Required. Resource ID of the bandwidth package to delete") + req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + + cmd.MarkFlagRequired("bw-package-id") + cmd.MarkFlagRequired("ugn-id") + + return cmd +} diff --git a/products/ugn/internal/ugn/bw_list.go b/products/ugn/internal/ugn/bw_list.go new file mode 100644 index 0000000000..1a22ce04b5 --- /dev/null +++ b/products/ugn/internal/ugn/bw_list.go @@ -0,0 +1,83 @@ +package ugn + +import ( + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// bwRow is the table row for `ucloud ugn bw list`. +type bwRow struct { + ResourceID string + Name string + BandwidthMbps float64 + RegionA string + RegionB string + Path string + QoS string + ChargeType string + CreateTime string + ExpireTime string +} + +// newBWList ucloud ugn bw list +func newBWList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewGetSimpleUGNBwPackagesRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List ugn bandwidth packages", + Long: "List ugn bandwidth packages", + Run: func(c *cobra.Command, args []string) { + *req.UGNID = ctx.PickResourceID(*req.UGNID) + resp, err := client.GetSimpleUGNBwPackages(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]bwRow, 0, len(resp.BwPackages)) + for _, bw := range resp.BwPackages { + path := bw.Path + if path == "None" { + path = "IGP" + } + expireTime := "" + if bw.ExpireTime > 0 { + expireTime = common.FormatDate(bw.ExpireTime) + } + rows = append(rows, bwRow{ + ResourceID: bw.PackageID, + Name: bw.Name, + BandwidthMbps: bw.BandWidth, + RegionA: bw.RegionA, + RegionB: bw.RegionB, + Path: path, + QoS: bw.Qos, + ChargeType: bw.PayMode, + CreateTime: common.FormatDate(bw.CreateTime), + ExpireTime: expireTime, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance") + ctx.BindOffset(cmd, req) + req.Limit = flags.Int("limit", 50, "Optional. Limit") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + cmd.MarkFlagRequired("ugn-id") + ctx.SetCompletion(cmd, "ugn-id", func() []string { + return getAllUGNIdNames(ctx, *req.ProjectId) + }) + ctx.SetCompletion(cmd, "project-id", ctx.ProjectList) + + return cmd +} diff --git a/products/ugn/internal/ugn/bw_modify_bandwidth.go b/products/ugn/internal/ugn/bw_modify_bandwidth.go new file mode 100644 index 0000000000..d1c2502cf4 --- /dev/null +++ b/products/ugn/internal/ugn/bw_modify_bandwidth.go @@ -0,0 +1,44 @@ +package ugn + +import ( + "fmt" + + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newBWModifyBandwidth ucloud ugn bw modify-bandwidth +func newBWModifyBandwidth(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewModifyUGNBandwidthRequest() + cmd := &cobra.Command{ + Use: "modify-bandwidth", + Short: "Modify ugn bandwidth", + Long: "Modify ugn bandwidth", + Run: func(c *cobra.Command, args []string) { + _, err := client.ModifyUGNBandwidth(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ugn bw[%s] bandwidth modified to %d\n", *req.PackageID, *req.BandWidth) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.PackageID, Action: "modify-bandwidth", Status: "Modified"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.BandWidth = flags.Int("bandwidth", 0, "Required. New bandwidth value") + req.PackageID = flags.String("package-id", "", "Required. Bandwidth package ID") + req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance") + + cmd.MarkFlagRequired("bandwidth") + cmd.MarkFlagRequired("package-id") + cmd.MarkFlagRequired("ugn-id") + + return cmd +} diff --git a/products/ugn/internal/ugn/cmd.go b/products/ugn/internal/ugn/cmd.go new file mode 100644 index 0000000000..b2be218dd2 --- /dev/null +++ b/products/ugn/internal/ugn/cmd.go @@ -0,0 +1,27 @@ +package ugn + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `ugn` root command. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ugn", + Short: "List and manipulate ugn instances", + Long: "List and manipulate ugn instances", + } + + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newGet(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newRegion(ctx)) + cmd.AddCommand(newBW(ctx)) + cmd.AddCommand(newNetwork(ctx)) + cmd.AddCommand(newRoute(ctx)) + + return cmd +} diff --git a/products/ugn/internal/ugn/completion.go b/products/ugn/internal/ugn/completion.go new file mode 100644 index 0000000000..557ce5737b --- /dev/null +++ b/products/ugn/internal/ugn/completion.go @@ -0,0 +1,42 @@ +package ugn + +import ( + "fmt" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func getAllUGNIns(ctx *cli.Context, project string) ([]ugnsdk.UGN, error) { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewListUGNRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + list := make([]ugnsdk.UGN, 0) + for offset, limit := 0, 50; offset <= 1000; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.ListUGN(req) + if err != nil { + return nil, err + } + list = append(list, resp.UGNs...) + if offset+limit >= resp.TotalCount { + break + } + } + return list, nil +} + +func getAllUGNIdNames(ctx *cli.Context, project string) []string { + ugnInsList, err := getAllUGNIns(ctx, project) + if err != nil { + return nil + } + idNameList := []string{} + for _, ugn := range ugnInsList { + idNameList = append(idNameList, fmt.Sprintf("%s/%s", ugn.UGNID, ugn.Name)) + } + return idNameList +} diff --git a/products/ugn/internal/ugn/create.go b/products/ugn/internal/ugn/create.go new file mode 100644 index 0000000000..4151bad730 --- /dev/null +++ b/products/ugn/internal/ugn/create.go @@ -0,0 +1,44 @@ +package ugn + +import ( + "fmt" + + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newCreate ucloud ugn create +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewCreateUGNRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a ugn instance", + Long: "Create a ugn instance", + Run: func(c *cobra.Command, args []string) { + resp, err := client.CreateUGN(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ugn[%s] created\n", resp.UGNID) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.UGNID, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.Name = flags.String("name", "", "Required. Name of the ugn instance to create") + req.Remark = flags.String("remark", "", "Optional. Remark") + + ctx.BindProjectID(cmd, req) + ctx.SetCompletion(cmd, "project-id", ctx.ProjectList) + + cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/products/ugn/internal/ugn/delete.go b/products/ugn/internal/ugn/delete.go new file mode 100644 index 0000000000..746c9d5eab --- /dev/null +++ b/products/ugn/internal/ugn/delete.go @@ -0,0 +1,65 @@ +package ugn + +import ( + "fmt" + + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud ugn delete +func newDelete(ctx *cli.Context) *cobra.Command { + idNames := []string{} + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewDelUGNRequest() + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete ugn instances", + Long: "Delete ugn instances", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the ugn instance(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.UGNID = sdk.String(id) + _, err := client.DelUGN(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "ugn[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "ugn-id", nil, "Required. Resource ID of ugn instances to delete") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + cmd.MarkFlagRequired("ugn-id") + command.SetCompletion(cmd, "ugn-id", func() []string { + return getAllUGNIdNames(ctx, *req.ProjectId) + }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + + return cmd +} diff --git a/products/ugn/internal/ugn/get.go b/products/ugn/internal/ugn/get.go new file mode 100644 index 0000000000..49b2df4783 --- /dev/null +++ b/products/ugn/internal/ugn/get.go @@ -0,0 +1,232 @@ +package ugn + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// getNetworkRow is the table row for networks in ugn get. +type getNetworkRow struct { + NetworkID string + Name string + OrgName string + Region string + Type string + CreateTime string +} + +// getBwRow is the table row for bw packages in ugn get. +type getBwRow struct { + PackageID string + Name string + BandwidthMbps float64 + RegionA string + RegionB string + Path string + QoS string + PayMode string + CreateTime string + ExpireTime string +} + +// getRouteRow is the table row for routes in ugn get. +type getRouteRow struct { + DstAddr string + NextHopID string + NextHopType string + NextHopRegion string + Priority int + Conflict string + Deny string + Restrict string +} + +// getPolicyRow is the table row for policies in ugn get. +type getPolicyRow struct { + PolicyID string + Name string + Priority int + Direction string + Action string + RoutePriority int + Enabled string + DstAddrs string + SrcAddrs string + CreateTime string +} + +// newGet ucloud ugn get +func newGet(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewDescribeSimpleUGNRequest() + cmd := &cobra.Command{ + Use: "get", + Short: "Show details of one ugn instance", + Long: "Show details of one ugn instance", + Run: func(c *cobra.Command, args []string) { + *req.UGNID = ctx.PickResourceID(*req.UGNID) + resp, err := client.DescribeSimpleUGN(req) + if err != nil { + ctx.HandleError(err) + return + } + + // 基本信息 + basic := []cli.DescribeRow{ + {Attribute: "ResourceID", Content: resp.UGN.UGNID}, + {Attribute: "Name", Content: resp.UGN.Name}, + {Attribute: "Remark", Content: resp.UGN.Remark}, + {Attribute: "CreateTime", Content: common.FormatDate(resp.UGN.CreateTime)}, + {Attribute: "NetworkCount", Content: fmt.Sprintf("%d", resp.UGN.NetworkCount)}, + {Attribute: "BwPackageCount", Content: fmt.Sprintf("%d", resp.UGN.BwPackageCount)}, + } + printDescribe(ctx, basic) + + // 网络实例列表 + fmt.Fprintln(ctx.ProgressWriter()) + fmt.Fprintf(ctx.ProgressWriter(), "Networks (%d):\n", len(resp.Networks)) + if len(resp.Networks) > 0 { + networkRows := make([]getNetworkRow, 0, len(resp.Networks)) + for _, nw := range resp.Networks { + networkRows = append(networkRows, getNetworkRow{ + NetworkID: nw.NetworkID, + Name: nw.Name, + OrgName: nw.OrgName, + Region: nw.Region, + Type: nw.Type, + CreateTime: common.FormatDate(nw.CreateTime), + }) + } + ctx.PrintList(networkRows) + } + + // 带宽包列表 + fmt.Fprintln(ctx.ProgressWriter()) + fmt.Fprintf(ctx.ProgressWriter(), "Bandwidth Packages (%d):\n", len(resp.BwPackages)) + if len(resp.BwPackages) > 0 { + bwRows := make([]getBwRow, 0, len(resp.BwPackages)) + for _, bw := range resp.BwPackages { + expireTime := "" + if bw.ExpireTime > 0 { + expireTime = common.FormatDate(bw.ExpireTime) + } + path := bw.Path + if path == "None" { + path = "IGP" + } + bwRows = append(bwRows, getBwRow{ + PackageID: bw.PackageID, + Name: bw.Name, + BandwidthMbps: bw.BandWidth, + RegionA: bw.RegionA, + RegionB: bw.RegionB, + Path: path, + QoS: bw.Qos, + PayMode: bw.PayMode, + CreateTime: common.FormatDate(bw.CreateTime), + ExpireTime: expireTime, + }) + } + ctx.PrintList(bwRows) + } + + // 路由列表 + fmt.Fprintln(ctx.ProgressWriter()) + fmt.Fprintf(ctx.ProgressWriter(), "Routes (%d):\n", len(resp.Routes)) + if len(resp.Routes) > 0 { + routeRows := make([]getRouteRow, 0, len(resp.Routes)) + for _, r := range resp.Routes { + routeRows = append(routeRows, getRouteRow{ + DstAddr: r.DstAddr, + NextHopID: r.NextHopID, + NextHopType: r.NextHopType, + NextHopRegion: r.NextHopRegion, + Priority: r.Priority, + Conflict: strconv.FormatBool(r.Conflict), + Deny: strconv.FormatBool(r.Deny), + Restrict: strconv.FormatBool(r.Restrict), + }) + } + ctx.PrintList(routeRows) + } + + // 路由策略列表 + fmt.Fprintln(ctx.ProgressWriter()) + fmt.Fprintf(ctx.ProgressWriter(), "Policies (%d):\n", len(resp.Policies)) + if len(resp.Policies) > 0 { + policyRows := make([]getPolicyRow, 0, len(resp.Policies)) + for _, p := range resp.Policies { + dstAddrs := make([]string, 0, len(p.DstNetworks)) + for _, n := range p.DstNetworks { + if len(n.Prefixes) > 0 { + dstAddrs = append(dstAddrs, fmt.Sprintf("%s(%s)", n.NetworkId, strings.Join(n.Prefixes, ","))) + } else { + dstAddrs = append(dstAddrs, n.NetworkId) + } + } + srcAddrs := make([]string, 0, len(p.SrcNetworks)) + for _, n := range p.SrcNetworks { + if len(n.Prefixes) > 0 { + srcAddrs = append(srcAddrs, fmt.Sprintf("%s(%s)", n.NetworkId, strings.Join(n.Prefixes, ","))) + } else { + srcAddrs = append(srcAddrs, n.NetworkId) + } + } + policyRows = append(policyRows, getPolicyRow{ + PolicyID: p.PolicyId, + Name: p.Name, + Priority: p.Priority, + Direction: p.Direction, + Action: p.Action, + RoutePriority: p.RoutePriority, + Enabled: strconv.FormatBool(p.Enabled), + DstAddrs: strings.Join(dstAddrs, ", "), + SrcAddrs: strings.Join(srcAddrs, ", "), + CreateTime: common.FormatDate(p.CreateTime), + }) + } + ctx.PrintList(policyRows) + } + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance to describe") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + cmd.MarkFlagRequired("ugn-id") + ctx.SetCompletion(cmd, "ugn-id", func() []string { + return getAllUGNIdNames(ctx, *req.ProjectId) + }) + ctx.SetCompletion(cmd, "project-id", ctx.ProjectList) + + return cmd +} + +// printDescribe renders describe rows without column headers in table mode, +// printing each attribute/content pair as an aligned key-value row. +func printDescribe(ctx *cli.Context, rows []cli.DescribeRow) { + if ctx.Format() != cli.OutputTable { + ctx.PrintList(rows) + return + } + maxWidth := 0 + for _, r := range rows { + if len(r.Attribute) > maxWidth { + maxWidth = len(r.Attribute) + } + } + for _, r := range rows { + fmt.Fprintf(ctx.Out(), "%-*s %s\n", maxWidth, r.Attribute, r.Content) + } +} diff --git a/products/ugn/internal/ugn/list.go b/products/ugn/internal/ugn/list.go new file mode 100644 index 0000000000..317bc5a226 --- /dev/null +++ b/products/ugn/internal/ugn/list.go @@ -0,0 +1,49 @@ +package ugn + +import ( + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList ucloud ugn list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewListUGNRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List ugn instances", + Long: "List ugn instances", + Run: func(c *cobra.Command, args []string) { + resp, err := client.ListUGN(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []UGNRow{} + for _, ugn := range resp.UGNs { + list = append(list, UGNRow{ + ResourceID: ugn.UGNID, + Name: ugn.Name, + Remark: ugn.Remark, + NetworkCount: ugn.NetworkCount, + BwPackageCount: ugn.BwPackageCount, + CreateTime: common.FormatDate(ugn.CreateTime), + }) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindOffset(cmd, req) + req.Limit = flags.Int("limit", 50, "Optional. Limit") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + return cmd +} diff --git a/products/ugn/internal/ugn/network.go b/products/ugn/internal/ugn/network.go new file mode 100644 index 0000000000..e7f5cd1984 --- /dev/null +++ b/products/ugn/internal/ugn/network.go @@ -0,0 +1,21 @@ +package ugn + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newNetwork ucloud ugn network +func newNetwork(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "network", + Short: "Manage ugn network instances", + Long: "Manage ugn network instances", + } + + cmd.AddCommand(newNetworkAttach(ctx)) + cmd.AddCommand(newNetworkDetach(ctx)) + + return cmd +} diff --git a/products/ugn/internal/ugn/network_attach.go b/products/ugn/internal/ugn/network_attach.go new file mode 100644 index 0000000000..84e85660bc --- /dev/null +++ b/products/ugn/internal/ugn/network_attach.go @@ -0,0 +1,72 @@ +package ugn + +import ( + "fmt" + + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newNetworkAttach ucloud ugn network attach +func newNetworkAttach(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewAttachUGNNetworksRequest() + + var networkIDs []string + var networkOrgNames, networkRegions, networkTypes []string + + cmd := &cobra.Command{ + Use: "attach", + Short: "Attach network instances to ugn", + Long: "Attach network instances to ugn", + Run: func(c *cobra.Command, args []string) { + n := len(networkIDs) + if len(networkTypes) != n || len(networkRegions) != n || len(networkOrgNames) != n { + ctx.HandleError(fmt.Errorf( + "network-id(%d), network-type(%d), network-region(%d), network-project-id(%d) must be provided in equal numbers", + n, len(networkTypes), len(networkRegions), len(networkOrgNames))) + return + } + networks := make([]ugnsdk.AttachUGNNetworksParamNetworks, 0, n) + for i, id := range networkIDs { + id = ctx.PickResourceID(id) + networks = append(networks, ugnsdk.AttachUGNNetworksParamNetworks{ + NetworkID: sdk.String(id), + OrgName: sdk.String(networkOrgNames[i]), + Region: sdk.String(networkRegions[i]), + Type: sdk.String(networkTypes[i]), + }) + } + req.Networks = networks + + _, err := client.AttachUGNNetworks(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "networks attached to ugn[%s]\n", *req.UGNID) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.UGNID, Action: "attach-network", Status: "Attached"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&networkIDs, "network-id", nil, "Required. Network IDs, e.g. vnet-xxxxx. Repeatable or comma-separated.") + flags.StringSliceVar(&networkOrgNames, "network-project-id", nil, "Required. Project ID of the networks, one per network-id") + flags.StringSliceVar(&networkRegions, "network-region", nil, "Required. Region of the networks, one per network-id") + flags.StringSliceVar(&networkTypes, "network-type", nil, "Required. Network type, e.g. VPC/UCVR, one per network-id") + req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance") + + cmd.MarkFlagRequired("network-id") + cmd.MarkFlagRequired("network-project-id") + cmd.MarkFlagRequired("network-region") + cmd.MarkFlagRequired("network-type") + cmd.MarkFlagRequired("ugn-id") + + return cmd +} diff --git a/products/ugn/internal/ugn/network_detach.go b/products/ugn/internal/ugn/network_detach.go new file mode 100644 index 0000000000..889f59c830 --- /dev/null +++ b/products/ugn/internal/ugn/network_detach.go @@ -0,0 +1,62 @@ +package ugn + +import ( + "fmt" + + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newNetworkDetach ucloud ugn network detach +func newNetworkDetach(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewDetachUGNNetworksRequest() + + var networkIDs []string + var yes bool + + cmd := &cobra.Command{ + Use: "detach", + Short: "Detach network instances from ugn", + Long: "Detach network instances from ugn", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to detach the network instance(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.UGNID = sdk.String(ctx.PickResourceID(*req.UGNID)) + req.Networks = make([]string, 0, len(networkIDs)) + for _, id := range networkIDs { + req.Networks = append(req.Networks, ctx.PickResourceID(id)) + } + + _, err = client.DetachUGNNetworks(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "networks detached from ugn[%s]\n", *req.UGNID) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.UGNID, Action: "detach-network", Status: "Detached"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&networkIDs, "network-id", nil, "Required. Network IDs, e.g. vnet-xxxxx. Repeatable or comma-separated.") + req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + + cmd.MarkFlagRequired("network-id") + cmd.MarkFlagRequired("ugn-id") + + return cmd +} diff --git a/products/ugn/internal/ugn/region.go b/products/ugn/internal/ugn/region.go new file mode 100644 index 0000000000..38afcef1f0 --- /dev/null +++ b/products/ugn/internal/ugn/region.go @@ -0,0 +1,20 @@ +package ugn + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newRegion ucloud ugn region +func newRegion(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "region", + Short: "List and manipulate ugn regions", + Long: "List and manipulate ugn regions", + } + + cmd.AddCommand(newRegionList(ctx)) + + return cmd +} diff --git a/products/ugn/internal/ugn/region_list.go b/products/ugn/internal/ugn/region_list.go new file mode 100644 index 0000000000..71cdd61b85 --- /dev/null +++ b/products/ugn/internal/ugn/region_list.go @@ -0,0 +1,47 @@ +package ugn + +import ( + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// regionRow is the table row for `ucloud ugn region list`. +type regionRow struct { + Region string + RegionID string + IsOnline bool + IsOverseas bool +} + +// newRegionList ucloud ugn region list +func newRegionList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewListUGNRegionsRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List ugn regions", + Long: "List ugn regions", + Run: func(c *cobra.Command, args []string) { + resp, err := client.ListUGNRegions(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]regionRow, 0, len(resp.RegionLIst)) + for _, r := range resp.RegionLIst { + rows = append(rows, regionRow{ + Region: r.Region, + RegionID: r.RegIonId, + IsOnline: r.IsOnline, + IsOverseas: r.IsOverseas, + }) + } + ctx.PrintList(rows) + }, + } + + return cmd +} diff --git a/products/ugn/internal/ugn/route.go b/products/ugn/internal/ugn/route.go new file mode 100644 index 0000000000..71cde471e7 --- /dev/null +++ b/products/ugn/internal/ugn/route.go @@ -0,0 +1,92 @@ +package ugn + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// routeRow is the table row for routes in ugn route. +type routeRow struct { + DstAddr string + NextHopID string + NextHopType string + NextHopRegion string + Priority int + Conflict string + Deny string + Restrict string +} + +// newRoute ucloud ugn route +func newRoute(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ugnsdk.NewClient) + req := client.NewGetUGNRouteTableRequest() + + cmd := &cobra.Command{ + Use: "route", + Short: "Show route table of one ugn instance", + Long: "Show route table of one ugn instance", + Run: func(c *cobra.Command, args []string) { + *req.UGNID = ctx.PickResourceID(*req.UGNID) + resp, err := client.GetUGNRouteTable(req) + if err != nil { + ctx.HandleError(err) + return + } + + if *req.Type == "Final" { + for _, vr := range resp.VRoutes { + fmt.Fprintf(ctx.ProgressWriter(), "Network %s (%d routes):\n", vr.NetworkId, len(vr.Routes)) + printRouteRows(ctx, vr.Routes) + fmt.Fprintln(ctx.ProgressWriter()) + } + } else { + fmt.Fprintf(ctx.ProgressWriter(), "Route Table (%s, %d routes):\n", *req.Type, len(resp.Routes)) + printRouteRows(ctx, resp.Routes) + } + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance") + req.Type = flags.String("type", "Final", "Route table type: Origin/Middle/Final") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'") + + cmd.MarkFlagRequired("ugn-id") + ctx.SetCompletion(cmd, "ugn-id", func() []string { + return getAllUGNIdNames(ctx, *req.ProjectId) + }) + ctx.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetFlagValues(cmd, "type", "Origin", "Middle", "Final") + + return cmd +} + +func printRouteRows(ctx *cli.Context, routes []ugnsdk.SimpleRoute) { + if len(routes) == 0 { + return + } + rows := make([]routeRow, 0, len(routes)) + for _, r := range routes { + rows = append(rows, routeRow{ + DstAddr: r.DstAddr, + NextHopID: r.NextHopID, + NextHopType: r.NextHopType, + NextHopRegion: r.NextHopRegion, + Priority: r.Priority, + Conflict: strconv.FormatBool(r.Conflict), + Deny: strconv.FormatBool(r.Deny), + Restrict: strconv.FormatBool(r.Restrict), + }) + } + ctx.PrintList(rows) +} diff --git a/products/ugn/internal/ugn/rows.go b/products/ugn/internal/ugn/rows.go new file mode 100644 index 0000000000..9c80df6ed3 --- /dev/null +++ b/products/ugn/internal/ugn/rows.go @@ -0,0 +1,17 @@ +package ugn + +import "github.com/ucloud/ucloud-cli/internal/common" + +// UGNRow is the table row for `ucloud ugn list`. +type UGNRow struct { + ResourceID string + Name string + Remark string + NetworkCount int + BwPackageCount int + CreateTime string +} + +func formatUGNCreateTime(t int) string { + return common.FormatDate(t) +} diff --git a/products/ugn/product.go b/products/ugn/product.go new file mode 100644 index 0000000000..1f30a3e129 --- /dev/null +++ b/products/ugn/product.go @@ -0,0 +1,22 @@ +package ugn + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalugn "github.com/ucloud/ucloud-cli/products/ugn/internal/ugn" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "ugn", Commands: []string{"ugn"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalugn.NewCommand(ctx)} +} + +var _ cli.Product = (*product)(nil) diff --git a/products/ugn/product.yaml b/products/ugn/product.yaml new file mode 100644 index 0000000000..b66bd83d0a --- /dev/null +++ b/products/ugn/product.yaml @@ -0,0 +1,6 @@ +name: ugn +owners: + - kyler-67 +commands: + - ugn +enabled: true diff --git a/products/ugn/testdata/cmdtree.golden b/products/ugn/testdata/cmdtree.golden new file mode 100644 index 0000000000..1df7fcbe92 --- /dev/null +++ b/products/ugn/testdata/cmdtree.golden @@ -0,0 +1,60 @@ +ucloud ugn use=ugn short=List and manipulate ugn instances +ucloud ugn bw use=bw short=List and manipulate ugn bandwidth packages +ucloud ugn bw create use=create short=Create a ugn bandwidth package + flag=bandwidth short= default=0 required=true + flag=charge-type short= default= required=true + flag=coupon-id short= default= required= + flag=name short= default= required= + flag=path short= default=IGP required= + flag=pay-mode short= default= required=true + flag=project-id short= default= required= + flag=qos short= default=Platinum required= + flag=quantity short= default=1 required= + flag=region-a short= default= required=true + flag=region-b short= default= required=true + flag=ugn-id short= default= required=true +ucloud ugn bw delete use=delete short=Delete ugn bandwidth packages + flag=bw-package-id short= default= required=true + flag=ugn-id short= default= required=true + flag=yes short=y default=false required= +ucloud ugn bw list use=list short=List ugn bandwidth packages + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=ugn-id short= default= required=true +ucloud ugn bw modify-bandwidth use=modify-bandwidth short=Modify ugn bandwidth + flag=bandwidth short= default=0 required=true + flag=package-id short= default= required=true + flag=ugn-id short= default= required=true +ucloud ugn create use=create short=Create a ugn instance + flag=name short= default= required=true + flag=project-id short= default= required= + flag=remark short= default= required= +ucloud ugn delete use=delete short=Delete ugn instances + flag=project-id short= default= required= + flag=ugn-id short= default=[] required=true + flag=yes short=y default=false required= +ucloud ugn get use=get short=Show details of one ugn instance + flag=project-id short= default= required= + flag=ugn-id short= default= required=true +ucloud ugn list use=list short=List ugn instances + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= +ucloud ugn network use=network short=Manage ugn network instances +ucloud ugn network attach use=attach short=Attach network instances to ugn + flag=network-id short= default=[] required=true + flag=network-project-id short= default=[] required=true + flag=network-region short= default=[] required=true + flag=network-type short= default=[] required=true + flag=ugn-id short= default= required=true +ucloud ugn network detach use=detach short=Detach network instances from ugn + flag=network-id short= default=[] required=true + flag=ugn-id short= default= required=true + flag=yes short=y default=false required= +ucloud ugn region use=region short=List and manipulate ugn regions +ucloud ugn region list use=list short=List ugn regions +ucloud ugn route use=route short=Show route table of one ugn instance + flag=project-id short= default= required= + flag=type short= default=Final required= + flag=ugn-id short= default= required=true diff --git a/products/ugn/testdata/completion.golden b/products/ugn/testdata/completion.golden new file mode 100644 index 0000000000..56aadad966 --- /dev/null +++ b/products/ugn/testdata/completion.golden @@ -0,0 +1,16 @@ +ucloud ugn bw create charge-type static Month,Postpay +ucloud ugn bw create path static Delay,IGP,TCO +ucloud ugn bw create pay-mode static FixedBw,Max5,Traffic +ucloud ugn bw create project-id dynamic +ucloud ugn bw create qos static Diamond,Gold,Platinum +ucloud ugn bw create ugn-id dynamic +ucloud ugn bw list project-id dynamic +ucloud ugn bw list ugn-id dynamic +ucloud ugn create project-id dynamic +ucloud ugn delete project-id dynamic +ucloud ugn delete ugn-id dynamic +ucloud ugn get project-id dynamic +ucloud ugn get ugn-id dynamic +ucloud ugn route project-id dynamic +ucloud ugn route type static Final,Middle,Origin +ucloud ugn route ugn-id dynamic diff --git a/products/uhadoop/TEST.md b/products/uhadoop/TEST.md new file mode 100644 index 0000000000..27591f55ab --- /dev/null +++ b/products/uhadoop/TEST.md @@ -0,0 +1,241 @@ +## UHadoop CLI 测试清单 + +### 准备工作 + +```bash +# 1. 确认凭据已配置 +ucloud config list + +# 2. 查看可用地域 +ucloud region + + +# 3. 替换测试参数 +# --region cn-bj2 --zone cn-bj2-02 +``` + +--- + +### 一、查询命令 + +#### 1.1 list-framework-app — 查询框架和应用 + +```bash +go run . uhadoop list-framework-app --region --zone +go run . uhadoop list-framework-app --region --zone --output json +``` + +#### 1.2 list-node-type — 查询可用机型 + +```bash +go run . uhadoop list-node-type --region --zone +go run . uhadoop list-node-type --region --zone --node-role master +go run . uhadoop list-node-type --region --zone --framework Hadoop --framework-version 3.3.4-udh3.2 +``` + +#### 1.3 list — 列出集群 + +```bash +go run . uhadoop list --region +go run . uhadoop list --region --zone --limit 5 +go run . uhadoop list --region --id-only +go run . uhadoop list --all-region --output json +``` + +#### 1.4 describe — 查询集群详情 + +```bash +go run . uhadoop describe --region --zone +go run . uhadoop describe --region --zone --output json +``` + +--- + +### 二、修改命令 + +> ⚠️ 会对真实资源产生修改 + +#### 2.1 create — 创建集群 + +| 参数 | 必填 | 说明 | +|------|:--:|------| +| `--region` | ✅ | 地域 | +| `--zone` | ✅ | 可用区 | +| `--name` | ✅ | 集群名称 | +| `--framework` | ✅ | Hadoop / HDFS / MR / StarRocks-* | +| `--framework-version` | ✅ | 如 `3.3.4-udh3.2` | +| `--password` | ✅ | 登录密码(明文,自动 base64) | +| `--master-node-type` | | Master 机型,默认 `o.hadoop4m.xlarge` | +| `--core-node-type` | | Core 机型,默认 `o.hadoop2m.xlarge` | +| `--task-node-type` | | Task 机型(可选) | +| `--master-count` | | 默认 2(StarRocks 默认 3) | +| `--core-count` | | 默认 3 | +| `--task-count` | | 默认 0 | +| `--cluster-case` | | Spark / Hbase / Core-Hadoop | +| `--app-config` | | 手动指定组件,格式 `App#Version` | +| `--master-boot-disk-size` | | 系统盘 GB,默认 50 | +| `--master-data-disk-size` | | 数据盘 GB,默认 100 | +| `--core-boot-disk-size` | | 系统盘 GB,默认 50 | +| `--core-data-disk-size` | | 数据盘 GB,默认 200 | +| `--vpc-id` | | 可选 | +| `--subnet-id` | | 可选 | +| `--async` | | 异步模式,不等待创建完成 | + +```bash +# Spark 模板创建 +go run . uhadoop create \ + --region --zone \ + --name test-spark --framework Hadoop \ + --framework-version 3.3.4-udh3.2 \ + --password 'YourPass123!' \ + --cluster-case Spark + +# 手动指定 app-config +go run . uhadoop create \ + --region --zone \ + --name test-manual --framework Hadoop \ + --framework-version 3.3.4-udh3.2 \ + --password 'YourPass123!' \ + --app-config Spark#3.5.3 --app-config Hive#3.1.3 + +# MR 集群(需要 --storage-cluster-id) +go run . uhadoop create \ + --region --zone \ + --name test-mr --framework MR \ + --framework-version 3.3.4-udh3.2 \ + --password 'YourPass123!' \ + --storage-cluster-id \ + --cluster-case Spark +``` + +#### 2.2 delete — 删除集群 + +| 参数 | 必填 | 说明 | +|------|:--:|------| +| `` | ✅ | 位置参数 | +| `--region` | ✅ | 地域 | +| `--zone` | ✅ | 可用区 | +| `--release-eip` | | 释放 EIP | +| `--yes` / `-y` | | 跳过确认 | + +```bash +go run . uhadoop delete --region --zone +go run . uhadoop delete --region --zone --release-eip -y +``` + +#### 2.3 add-node — 扩容节点 + +| 参数 | 必填 | 说明 | +|------|:--:|------| +| `--region` | ✅ | 地域 | +| `--zone` | ✅ | 可用区 | +| `--instance-id` | ✅ | 集群 ID | +| `--node-role` | ✅ | core / task / client | +| `--node-type` | ✅ | 机型 | +| `--node-count` | | 默认 1 | +| `--password` | | Client 角色需要(明文) | +| `--boot-disk-size` | | 系统盘 GB,默认 50 | +| `--data-disk-size` | | 数据盘 GB,默认 200 | +| `--async` | | 异步模式 | + +```bash +go run . uhadoop add-node \ + --region --zone \ + --instance-id --node-role core \ + --node-type o.hadoop2m.xlarge +``` + +#### 2.4 restart-service — 启停服务 + +| 参数 | 必填 | 说明 | +|------|:--:|------| +| `--region` | ✅ | 地域 | +| `--zone` | ✅ | 可用区 | +| `--instance-id` | ✅ | 集群 ID | +| `--service-name` | ✅ | 服务名 | +| `--only-start` / `--only-stop` | | 只启/只停 | +| `--yes` / `-y` | | 跳过确认 | + +```bash +go run . uhadoop restart-service \ + --region --zone \ + --instance-id --service-name Hive +``` + +#### 2.5 upgrade-node — 升级节点 + +| 参数 | 必填 | 说明 | +|------|:--:|------| +| `--region` | ✅ | 地域 | +| `--zone` | ✅ | 可用区 | +| `--instance-id` | ✅ | 集群 ID | +| `--node-role` | ✅ | master / core / task / client | +| `--node-type` | ✅ | 新机型 | +| `--node-name` | | 节点名(非 master 必填) | +| `--yes` / `-y` | | 跳过确认 | + +```bash +go run . uhadoop upgrade-node \ + --region --zone \ + --instance-id --node-role master \ + --node-type o.hadoop2m.xlarge +``` + +#### 2.6 upgrade-disk — 扩容磁盘 + +| 参数 | 必填 | 说明 | +|------|:--:|------| +| `--region` | ✅ | 地域 | +| `--zone` | ✅ | 可用区 | +| `--instance-id` | ✅ | 集群 ID | +| `--node-role` | ✅ | master / core / task / client | +| `--data-disk-size` | ✅ | 新数据盘大小 GB | +| `--boot-disk-size` | | 新系统盘大小 GB | +| `--node-name` | | 节点名(非 master 必填) | +| `--yes` / `-y` | | 跳过确认 | + +```bash +go run . uhadoop upgrade-disk \ + --region --zone \ + --instance-id --node-role core \ + --data-disk-size 500 --node-name -core1 +``` + +--- + +### 三、校验 + +```bash +# 编译 +go build ./... + +# golden 测试 +GOROOT=/Users/user/.g/go GOTOOLCHAIN=local go test ./hack/snapshot -v -run 'uhadoop' + +# 缺参立即报错 +go run . uhadoop create --name test +# → required flag(s) "framework", "framework-version", "password", "region", "zone" not set +``` + +--- + +### 测试结果 + +| # | 命令 | 状态 | +|---|------|------| +| 1 | `list-framework-app` | ✅ | +| 2 | `list-node-type` | ✅ | +| 3 | `list` | ✅ | +| 4 | `describe` | ✅ | +| 5 | `create --cluster-case Spark` | ✅ | +| 6 | `create --app-config ...` | ✅ | +| 7 | `delete` | ✅ | +| 8 | `add-node` | ✅ | +| 9 | `restart-service` | ✅ | +| 10 | `upgrade-node` | ✅ | +| 11 | `upgrade-disk` | ✅ | +| 12 | `create` 缺参 | ✅ | +| 13 | `go build ./...` | ✅ | +| 14 | `go test ./hack/snapshot -v` | ✅ | + +> ⬜ = 待测试   ✅ = 通过   ❌ = 失败 diff --git a/products/uhadoop/internal/uhadoop/add_node.go b/products/uhadoop/internal/uhadoop/add_node.go new file mode 100644 index 0000000000..4619f8108c --- /dev/null +++ b/products/uhadoop/internal/uhadoop/add_node.go @@ -0,0 +1,75 @@ +package uhadoop + +import ( + "encoding/base64" + "fmt" + "time" + + "github.com/spf13/cobra" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newAddNode(ctx *cli.Context) *cobra.Command { + var ( + async *bool + rawPassword string + ) + client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient) + req := client.NewAddUHadoopInstanceNodeRequest() + cmd := &cobra.Command{ + Use: "add-node", + Short: "Add nodes to a UHadoop cluster", + Long: `Add a number of nodes to an existing UHadoop cluster`, + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + if rawPassword != "" { + req.Password = sdk.String(base64.StdEncoding.EncodeToString([]byte(rawPassword))) + } + resp, err := client.AddUHadoopInstanceNode(req) + if err != nil { + ctx.HandleError(err) + return + } + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("[%d] %s", resp.RetCode, resp.Message)) + return + } + text := fmt.Sprintf("uhadoop[%s] adding %d %s node(s)", *req.InstanceId, *req.NodeCount, *req.NodeRole) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeClusterForPoll(ctx, client), cli.WithTimeout(60*time.Minute)).Spoll(*req.InstanceId, text, []string{stateRunning}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceId, Action: "add-node", Status: "Scaling"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", "", "Optional. Assign availability zone") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.InstanceId = flags.String("instance-id", "", "Required. Cluster instance ID") + req.NodeRole = flags.String("node-role", "", "Required. Node role: core|task|client") + req.NodeType = flags.String("node-type", "", "Required. Node type") + req.NodeCount = flags.Int("node-count", 1, "Number of nodes, default 1") + flags.StringVar(&rawPassword, "password", "", "Login password (client role requires)") + req.BootDiskSize = flags.String("boot-disk-size", "50", "Boot disk GB, default 50") + req.BootDiskType = flags.String("boot-disk-type", "CLOUD_RSSD", "Boot disk type, default CLOUD_RSSD") + req.DataDiskSize = flags.String("data-disk-size", "200", "Data disk GB, default 200") + req.DataDiskNum = flags.String("data-disk-num", "1", "Data disk num, default 1") + req.DataDiskType = flags.String("data-disk-type", "CLOUD_RSSD", "Data disk type, default CLOUD_RSSD") + async = flags.Bool("async", false, "Optional. Do not wait for node addition to finish") + command.SetFlagValues(cmd, "node-role", "core", "task", "client") + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("node-role") + cmd.MarkFlagRequired("node-type") + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + return cmd +} diff --git a/products/uhadoop/internal/uhadoop/cmd.go b/products/uhadoop/internal/uhadoop/cmd.go new file mode 100644 index 0000000000..0694aaa552 --- /dev/null +++ b/products/uhadoop/internal/uhadoop/cmd.go @@ -0,0 +1,28 @@ +package uhadoop + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the top-level `uhadoop` command. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "uhadoop", + Short: "List,create,delete,describe UHadoop clusters and manage nodes and services", + Long: `List,create,delete,describe UHadoop clusters and manage nodes and services`, + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newAddNode(ctx)) + cmd.AddCommand(newListNodeType(ctx)) + cmd.AddCommand(newListFrameworkApp(ctx)) + cmd.AddCommand(newRestartService(ctx)) + cmd.AddCommand(newUpgradeNode(ctx)) + cmd.AddCommand(newUpgradeDisk(ctx)) + return cmd +} diff --git a/products/uhadoop/internal/uhadoop/create.go b/products/uhadoop/internal/uhadoop/create.go new file mode 100644 index 0000000000..13a12a0f75 --- /dev/null +++ b/products/uhadoop/internal/uhadoop/create.go @@ -0,0 +1,296 @@ +package uhadoop + +import ( + "encoding/base64" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + "github.com/ucloud/ucloud-sdk-go/ucloud/response" + + uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type instanceGroupConfig struct { + NodeRole string `json:"NodeRole"` + NodeType string `json:"NodeType"` + Count int `json:"Count"` + DataDiskSize int `json:"DataDiskSize"` + DataDiskNum int `json:"DataDiskNum"` + DataDiskType string `json:"DataDiskType"` + BootDiskSize int `json:"BootDiskSize"` + BootDiskType string `json:"BootDiskType"` +} + +type createRequest struct { + request.CommonBase + InstanceName string `json:"InstanceName"` + Framework string `json:"Framework"` + FrameworkVersion string `json:"FrameworkVersion"` + Password string `json:"Password"` + VPCId string `json:"VPCId"` + SubnetId string `json:"SubnetId"` + ChargeType string `json:"ChargeType,omitempty"` + Quantity int `json:"Quantity,omitempty"` + BusinessId string `json:"BusinessId,omitempty"` + StorgeClusterId string `json:"StorgeClusterId,omitempty"` + StandAloneMetaStore string `json:"StandAloneMetaStore,omitempty"` + IsSecurityEnabled string `json:"IsSecurityEnabled,omitempty"` + SecGroupIds string `json:"SecGroupIds,omitempty"` + US3Bucket string `json:"US3Bucket,omitempty"` + US3AccessKey string `json:"US3AccessKey,omitempty"` + US3SecretKey string `json:"US3SecretKey,omitempty"` + US3TokenName string `json:"US3TokenName,omitempty"` + AppConfigs []string `json:"AppConfigs"` + InstanceGroupConfigs []instanceGroupConfig `json:"InstanceGroupConfigs"` +} + +type createResponse struct { + response.CommonBase + InstanceId string `json:"InstanceId"` +} + +var clusterCaseApps = map[string]map[string][]string{ + "3.3.4-udh3.2": { + "Spark": {"Spark#3.5.3", "Hive#3.1.3", "Hue#4.11.0", "Zookeeper#3.8.4", "Mysql#8.0.32", "Yarn#3.3.4"}, + "Hbase": {"Hbase#2.4.18", "Hue#4.11.0", "Zookeeper#3.8.4", "Mysql#8.0.32", "Yarn#3.3.4", "Phoenix#5.2.1"}, + "Core-Hadoop": {"Hive#3.1.3", "Hue#4.11.0", "Zookeeper#3.8.4", "Yarn#3.3.4"}, + }, + "3.3.4-udh3.1": { + "Spark": {"Spark#3.5.3", "Hive#3.1.3", "Hue#4.11.0", "Zookeeper#3.8.4", "Mysql#8.0.32", "Yarn#3.3.4"}, + "Hbase": {"Hbase#2.4.18", "Hue#4.11.0", "Zookeeper#3.8.4", "Mysql#8.0.32", "Yarn#3.3.4", "Phoenix#5.2.1"}, + "Core-Hadoop": {"Hive#3.1.3", "Hue#4.11.0", "Zookeeper#3.8.4", "Yarn#3.3.4"}, + }, + "3.2.1-udh3.0": { + "Spark": {"Spark#3.3.0", "Hive#3.1.3", "Hue#4.7.1", "Zookeeper#3.6.3", "Mysql#5.6.47", "Yarn#3.2.1"}, + "Hbase": {"Hbase#2.2.4", "Hue#4.7.1", "Zookeeper#3.6.3", "Mysql#5.6.47", "Yarn#3.2.1", "Phoenix#5.1.2"}, + "Core-Hadoop": {"Hive#3.1.3", "Hue#4.7.1", "Zookeeper#3.6.3", "Yarn#3.2.1"}, + }, + "2.8.5-udh2.2": { + "Spark": {"Spark#2.4.6", "Hive#2.3.6", "Hue#4.7.1", "Zookeeper#3.4.13", "Mysql#5.6.47", "Yarn#2.8.5"}, + "Hbase": {"Hbase#1.4.10", "Hue#4.7.1", "Zookeeper#3.4.13", "Mysql#5.6.47", "Yarn#2.8.5", "Phoenix#4.14.3"}, + "Core-Hadoop": {"Hive#2.3.6", "Hue#4.7.1", "Zookeeper#3.4.13", "Yarn#2.8.5"}, + }, + "2.6.0-cdh5.13.3": { + "Spark": {"Spark#2.4.3", "Hive#2.3.3", "Hue#3.10.0", "Zookeeper#3.4.5", "Mysql#5.1.73", "Yarn#2.6.0"}, + "Hbase": {"Hbase#1.2.0", "Hue#3.10.0", "Zookeeper#3.4.5", "Mysql#5.1.73", "Yarn#2.6.0", "Phoenix#4.14.0"}, + "Core-Hadoop": {"Hive#2.3.3", "Hue#3.10.0", "Zookeeper#3.4.5", "Yarn#2.6.0"}, + }, + "2.6.0-cdh5.4.9": { + "Spark": {"Spark#2.0.1", "Hive#1.2.1", "Hue#3.10.0", "Zookeeper#3.4.5", "Mysql#5.1.73", "Yarn#2.6.0"}, + "Hbase": {"Hbase#1.0.0", "Hue#3.10.0", "Zookeeper#3.4.5", "Mysql#5.1.73", "Yarn#2.6.0", "Phoenix#4.6.0"}, + "Core-Hadoop": {"Hive#1.2.1", "Hue#3.10.0", "Zookeeper#3.4.5", "Yarn#2.6.0"}, + }, +} + +var hdfsVersionByFramework = map[string]string{ + "3.3.4-udh3.2": "Hdfs#3.3.4", + "3.3.4-udh3.1": "Hdfs#3.3.4", + "3.2.1-udh3.0": "Hdfs#3.2.1", + "2.8.5-udh2.2": "Hdfs#2.8.5", + "2.6.0-cdh5.13.3": "Hdfs#2.6.0", + "2.6.0-cdh5.4.9": "Hdfs#2.6.0", +} + +func newCreate(ctx *cli.Context) *cobra.Command { + var ( + async *bool + rawPassword string + clusterCase string + master instanceGroupConfig + core instanceGroupConfig + task instanceGroupConfig + ) + client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient) + sdkReq := client.NewCreateUHadoopInstanceRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a UHadoop cluster", + Long: `Create a UHadoop cluster with specified configuration`, + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + + if sdkReq.Framework != nil && (*sdkReq.Framework == "StarRocks-Shared-Nothing" || *sdkReq.Framework == "StarRocks-Shared-Data") { + if !cmd.Flags().Changed("master-count") { + master.Count = 3 + } + } + groups := buildGroups(master, core, task) + if len(groups) == 0 { + ctx.HandleError(fmt.Errorf("at least one node group is required")) + return + } + + appConfigs := sdkReq.AppConfigs + if clusterCase != "" { + versionMap, ok := clusterCaseApps[*sdkReq.FrameworkVersion] + if !ok { + ctx.HandleError(fmt.Errorf("unsupported framework-version %q for --cluster-case", *sdkReq.FrameworkVersion)) + return + } + template, ok := versionMap[clusterCase] + if !ok { + ctx.HandleError(fmt.Errorf("no cluster-case %q for framework-version %q", clusterCase, *sdkReq.FrameworkVersion)) + return + } + appConfigs = append([]string(nil), template...) + if sdkReq.Framework != nil && *sdkReq.Framework == "Hadoop" { + if hdfsVer, ok := hdfsVersionByFramework[*sdkReq.FrameworkVersion]; ok { + appConfigs = append(appConfigs, hdfsVer) + } + } + } + + req := &createRequest{ + InstanceName: *sdkReq.InstanceName, + Framework: *sdkReq.Framework, + FrameworkVersion: *sdkReq.FrameworkVersion, + Password: base64.StdEncoding.EncodeToString([]byte(rawPassword)), + VPCId: *sdkReq.VPCId, + SubnetId: *sdkReq.SubnetId, + AppConfigs: appConfigs, + InstanceGroupConfigs: groups, + } + req.Region = sdkReq.Region + req.Zone = sdkReq.Zone + req.ProjectId = sdkReq.ProjectId + if sdkReq.ChargeType != nil { + req.ChargeType = *sdkReq.ChargeType + } + if sdkReq.Quantity != nil { + req.Quantity = *sdkReq.Quantity + } + if sdkReq.BusinessId != nil { + req.BusinessId = *sdkReq.BusinessId + } + if sdkReq.StorgeClusterId != nil { + req.StorgeClusterId = *sdkReq.StorgeClusterId + } + if sdkReq.StandAloneMetaStore != nil { + req.StandAloneMetaStore = *sdkReq.StandAloneMetaStore + } + if sdkReq.IsSecurityEnabled != nil { + req.IsSecurityEnabled = *sdkReq.IsSecurityEnabled + } + if sdkReq.SecGroupIds != nil { + req.SecGroupIds = *sdkReq.SecGroupIds + } + if sdkReq.US3Bucket != nil { + req.US3Bucket = *sdkReq.US3Bucket + } + if sdkReq.US3AccessKey != nil { + req.US3AccessKey = *sdkReq.US3AccessKey + } + if sdkReq.US3SecretKey != nil { + req.US3SecretKey = *sdkReq.US3SecretKey + } + if sdkReq.US3TokenName != nil { + req.US3TokenName = *sdkReq.US3TokenName + } + + var resp createResponse + err := client.InvokeAction("CreateUHadoopInstance", req, &resp) + if err != nil { + ctx.HandleError(err) + return + } + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("[%d] %s", resp.RetCode, resp.Message)) + return + } + text := fmt.Sprintf("uhadoop[%s] is creating", resp.InstanceId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeClusterForPoll(ctx, client), cli.WithTimeout(60*time.Minute)).Spoll(resp.InstanceId, text, []string{stateRunning}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.InstanceId, Action: "create", Status: "Creating"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + sdkReq.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + sdkReq.Zone = flags.String("zone", "", "Optional. Assign availability zone") + sdkReq.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + sdkReq.InstanceName = flags.String("name", "", "Required. Instance name") + sdkReq.Framework = flags.String("framework", "", "Required. Framework") + sdkReq.FrameworkVersion = flags.String("framework-version", "", "Required. Framework version") + flags.StringVar(&rawPassword, "password", "", "Required. Login password") + sdkReq.VPCId = flags.String("vpc-id", "", "Optional. VPC ID") + sdkReq.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID") + sdkReq.ChargeType = flags.String("charge-type", "Month", "Optional. Charge type") + sdkReq.Quantity = flags.Int("quantity", 1, "Optional. Quantity") + sdkReq.BusinessId = flags.String("business-id", "Default", "Optional. Business group") + sdkReq.StorgeClusterId = flags.String("storage-cluster-id", "", "Optional. Storage cluster ID (MR framework)") + sdkReq.StandAloneMetaStore = flags.String("meta-store", "", "Optional. Meta store type") + sdkReq.IsSecurityEnabled = flags.String("security-enabled", "", "Optional. Enable security group") + sdkReq.SecGroupIds = flags.String("sec-group-ids", "", "Optional. Security group IDs") + sdkReq.US3Bucket = flags.String("us3-bucket", "", "Optional. US3 bucket") + sdkReq.US3AccessKey = flags.String("us3-access-key", "", "Optional. US3 access key") + sdkReq.US3SecretKey = flags.String("us3-secret-key", "", "Optional. US3 secret key") + sdkReq.US3TokenName = flags.String("us3-token-name", "", "Optional. US3 token name") + flags.StringVar(&clusterCase, "cluster-case", "", "Cluster use case: Spark|Hbase|Core-Hadoop") + + master.NodeRole = "master" + flags.StringVar(&master.NodeType, "master-node-type", "o.hadoop4m.xlarge", "Master node type") + flags.IntVar(&master.Count, "master-count", 2, "Master node count") + flags.IntVar(&master.DataDiskSize, "master-data-disk-size", 100, "Master data disk GB") + flags.IntVar(&master.DataDiskNum, "master-data-disk-num", 1, "Master data disk num") + flags.StringVar(&master.DataDiskType, "master-data-disk-type", "CLOUD_RSSD", "Master data disk type") + flags.IntVar(&master.BootDiskSize, "master-boot-disk-size", 50, "Master boot disk GB") + flags.StringVar(&master.BootDiskType, "master-boot-disk-type", "CLOUD_RSSD", "Master boot disk type") + + core.NodeRole = "core" + flags.StringVar(&core.NodeType, "core-node-type", "o.hadoop2m.xlarge", "Core node type") + flags.IntVar(&core.Count, "core-count", 3, "Core node count") + flags.IntVar(&core.DataDiskSize, "core-data-disk-size", 200, "Core data disk GB") + flags.IntVar(&core.DataDiskNum, "core-data-disk-num", 1, "Core data disk num") + flags.StringVar(&core.DataDiskType, "core-data-disk-type", "CLOUD_RSSD", "Core data disk type") + flags.IntVar(&core.BootDiskSize, "core-boot-disk-size", 50, "Core boot disk GB") + flags.StringVar(&core.BootDiskType, "core-boot-disk-type", "CLOUD_RSSD", "Core boot disk type") + + task.NodeRole = "task" + flags.StringVar(&task.NodeType, "task-node-type", "o.hadoop2m.xlarge", "Optional. Task node type") + flags.IntVar(&task.Count, "task-count", 0, "Task node count") + flags.IntVar(&task.DataDiskSize, "task-data-disk-size", 200, "Task data disk GB") + flags.IntVar(&task.DataDiskNum, "task-data-disk-num", 1, "Task data disk num") + flags.StringVar(&task.DataDiskType, "task-data-disk-type", "CLOUD_RSSD", "Task data disk type") + flags.IntVar(&task.BootDiskSize, "task-boot-disk-size", 50, "Task boot disk GB") + flags.StringVar(&task.BootDiskType, "task-boot-disk-type", "CLOUD_RSSD", "Task boot disk type") + + async = flags.Bool("async", false, "Optional. Do not wait for creation to finish") + flags.StringSliceVar(&sdkReq.AppConfigs, "app-config", nil, "App configs: App#Version") + + command.SetFlagValues(cmd, "cluster-case", "Spark", "Hbase", "Core-Hadoop") + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic") + command.SetFlagValues(cmd, "security-enabled", "true", "false") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("framework") + cmd.MarkFlagRequired("framework-version") + cmd.MarkFlagRequired("password") + + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + return cmd +} + +func buildGroups(master, core, task instanceGroupConfig) []instanceGroupConfig { + var groups []instanceGroupConfig + if master.NodeType != "" { + groups = append(groups, master) + } + if core.NodeType != "" { + groups = append(groups, core) + } + if task.NodeType != "" && task.Count > 0 { + groups = append(groups, task) + } + return groups +} diff --git a/products/uhadoop/internal/uhadoop/delete.go b/products/uhadoop/internal/uhadoop/delete.go new file mode 100644 index 0000000000..d9101de96c --- /dev/null +++ b/products/uhadoop/internal/uhadoop/delete.go @@ -0,0 +1,58 @@ +package uhadoop + +import ( + "fmt" + + "github.com/spf13/cobra" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newDelete(ctx *cli.Context) *cobra.Command { + var yes bool + client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient) + req := client.NewDeleteUHadoopInstanceRequest() + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a UHadoop cluster", + Long: `Delete a UHadoop cluster by instance ID`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + id := args[0] + ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure you want to delete cluster %s?", id)) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + req.InstanceId = sdk.String(id) + _, err = client.DeleteUHadoopInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(w, "uhadoop[%s] deleted\n", id) + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation") + req.ReleaseEIP = flags.Bool("release-eip", false, "Optional. Release bound EIP after deletion") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetFlagValues(cmd, "release-eip", "true", "false") + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + + return cmd +} diff --git a/products/uhadoop/internal/uhadoop/describe.go b/products/uhadoop/internal/uhadoop/describe.go new file mode 100644 index 0000000000..22cf12ed6c --- /dev/null +++ b/products/uhadoop/internal/uhadoop/describe.go @@ -0,0 +1,78 @@ +package uhadoop + +import ( + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/response" + + uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +type describeClusterResponse struct { + response.CommonBase + ClusterSet []describeClusterInfo `json:"ClusterSet"` +} + +type describeClusterInfo struct { + InstanceId string `json:"InstanceId"` + ClusterInstanceId string `json:"ClusterInstanceId"` + InstanceName string `json:"InstanceName"` + ClusterInstanceName string `json:"ClusterInstanceName"` + FlinkResourceId string `json:"FlinkResourceId"` + Framework string `json:"Framework"` + FrameworkVersion string `json:"FrameworkVersion"` + ReleaseVersion string `json:"ReleaseVersion"` + HadoopVersion string `json:"HadoopVersion"` + State string `json:"State"` + Zone string `json:"Zone"` + VPCId string `json:"VPCId"` + SubnetId string `json:"SubnetId"` + BusinessId string `json:"BusinessId"` + ChargeType string `json:"ChargeType"` + Tag string `json:"Tag"` + CreateTime int64 `json:"CreateTime"` + ExpireTime int64 `json:"ExpireTime"` + RunningTime int64 `json:"RunningTime"` + MasterCount int `json:"MasterCount"` + CoreCount int `json:"CoreCount"` + TaskCount int `json:"TaskCount"` + NodeCount int `json:"NodeCount"` + RedundantCount int `json:"RedundantCount"` + AppConfigCount int `json:"AppConfigCount"` + IsOpenSecGroup bool `json:"IsOpenSecGroup"` + HdfsTotal int `json:"HdfsTotal"` + HdfsUsed int `json:"HdfsUsed"` + NodeSet []interface{} `json:"NodeSet"` + AppConfigSet []interface{} `json:"AppConfigSet"` +} + +func newDescribe(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient) + req := client.NewDescribeUHadoopInstanceRequest() + cmd := &cobra.Command{ + Use: "describe ", + Short: "Describe a UHadoop cluster", + Long: `Describe a UHadoop cluster with detailed information`, + SilenceUsage: true, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + req.InstanceId = sdk.String(args[0]) + var resp describeClusterResponse + err := client.InvokeAction("DescribeUHadoopInstance", req, &resp) + if err != nil { + ctx.HandleError(err) + return + } + ctx.PrintList(resp.ClusterSet) + }, + } + cmd.Flags().SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + return cmd +} diff --git a/products/uhadoop/internal/uhadoop/list.go b/products/uhadoop/internal/uhadoop/list.go new file mode 100644 index 0000000000..8218c10e0b --- /dev/null +++ b/products/uhadoop/internal/uhadoop/list.go @@ -0,0 +1,205 @@ +package uhadoop + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/response" + + uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + sdkerror "github.com/ucloud/ucloud-sdk-go/ucloud/error" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// listClusterResponse mirrors the real API response for ListUHadoopInstance. +// The SDK's ListClusterInfo has CreateTime/ExpireTime as string but the API +// returns Unix timestamps (int), and misses fields like AutoRenew/HdfsTotal. +type listClusterResponse struct { + response.CommonBase + TotalCount int `json:"TotalCount"` + ClusterSet []listClusterInfo `json:"ClusterSet"` +} + +type listClusterInfo struct { + Zone string `json:"Zone"` + InstanceId string `json:"InstanceId"` + ClusterInstanceId string `json:"ClusterInstanceId"` + InstanceName string `json:"InstanceName"` + ClusterInstanceName string `json:"ClusterInstanceName"` + FlinkResourceId string `json:"FlinkResourceId"` + Framework string `json:"Framework"` + FrameworkVersion string `json:"FrameworkVersion"` + Remark string `json:"Remark"` + CreateTime int64 `json:"CreateTime"` + ExpireTime int64 `json:"ExpireTime"` + AutoRenew int `json:"AutoRenew"` + ChargeType string `json:"ChargeType"` + MasterCount int `json:"MasterCount"` + CoreCount int `json:"CoreCount"` + TaskCount int `json:"TaskCount"` + UHostCount int `json:"UHostCount"` + RedundantCount int `json:"RedundantCount"` + State string `json:"State"` + ReleaseVersion string `json:"ReleaseVersion"` + HadoopVersion string `json:"HadoopVersion"` + VPCId string `json:"VPCId"` + SubnetId string `json:"SubnetId"` + BusinessId string `json:"BusinessId"` + HdfsTotal int `json:"HdfsTotal"` + HdfsUsed int `json:"HdfsUsed"` +} + +// newList ucloud uhadoop list +func newList(ctx *cli.Context) *cobra.Command { + var allRegion, idOnly bool + client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient) + req := client.NewListUHadoopInstanceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List all UHadoop clusters", + Long: `List all UHadoop clusters`, + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + clusters, err := getAllClusters(ctx, client, req, allRegion) + if err != nil { + ctx.HandleError(err) + return + } + if idOnly { + listClusterID(ctx, clusters) + } else { + listClusters(ctx, clusters, allRegion) + } + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") + req.Limit = cmd.Flags().Int("limit", 60, "Optional. Limit default 60") + req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0") + cmd.Flags().BoolVar(&allRegion, "all-region", false, "Optional. Accept values: true or false. List clusters of all regions when assigned true") + cmd.Flags().BoolVar(&idOnly, "id-only", false, "Optional. Just display resource id of clusters") + + command.SetFlagValues(cmd, "all-region", "true", "false") + command.SetFlagValues(cmd, "id-only", "true", "false") + + return cmd +} + +func getAllClusters(ctx *cli.Context, client *uhadoopsdk.UHadoopClient, req *uhadoopsdk.ListUHadoopInstanceRequest, allRegion bool) ([]listClusterInfo, error) { + if allRegion { + result := make([]listClusterInfo, 0) + regions, err := ctx.AllRegions() + if err != nil { + return nil, err + } + for _, region := range regions { + _req := *req + _req.Region = sdk.String(region) + _req.Zone = nil // clear zone when fanning out across regions + clusters, err := fetchClustersPageOff(client, &_req) + if e, ok := err.(sdkerror.Error); ok && e.Code() == _RetCodeRegionNoPermission { + continue + } + if err != nil { + return nil, err + } + result = append(result, clusters...) + } + return result, nil + } + + var resp listClusterResponse + // Use InvokeAction directly with our custom response struct because the + // SDK's ListClusterInfo types CreateTime/ExpireTime as string (wrong: int). + err := client.InvokeAction("ListUHadoopInstance", req, &resp) + if err != nil { + return nil, err + } + return resp.ClusterSet, nil +} + +func fetchClustersPageOff(client *uhadoopsdk.UHadoopClient, req *uhadoopsdk.ListUHadoopInstanceRequest) ([]listClusterInfo, error) { + _req := *req + result := make([]listClusterInfo, 0) + for limit, offset := 60, 0; ; offset += limit { + _req.Offset = sdk.Int(offset) + _req.Limit = sdk.Int(limit) + var resp listClusterResponse + err := client.InvokeAction("ListUHadoopInstance", &_req, &resp) + if err != nil { + return nil, err + } + result = append(result, resp.ClusterSet...) + if len(resp.ClusterSet) < limit { + break + } + } + return result, nil +} + +func listClusters(ctx *cli.Context, clusters []listClusterInfo, listAllRegion bool) { + list := make([]listRow, 0, len(clusters)) + for _, c := range clusters { + list = append(list, toListRow(c)) + } + + if ctx.Format() != cli.OutputTable { + ctx.PrintList(list) + return + } + + rows := make([]listRowDefault, 0, len(list)) + for _, r := range list { + rows = append(rows, listRowDefault{ + InstanceId: r.InstanceId, InstanceName: r.InstanceName, + Framework: r.Framework, ReleaseVersion: r.ReleaseVersion, + HadoopVersion: r.HadoopVersion, State: r.State, + Zone: r.Zone, CreateTime: r.CreateTime, ExpireTime: r.ExpireTime, + }) + } + ctx.PrintList(rows) +} + +func toListRow(c listClusterInfo) listRow { + return listRow{ + InstanceId: c.InstanceId, + InstanceName: c.InstanceName, + Framework: c.Framework, + ReleaseVersion: c.ReleaseVersion, + HadoopVersion: c.HadoopVersion, + State: c.State, + Zone: c.Zone, + VPCId: c.VPCId, + SubnetId: c.SubnetId, + ChargeType: c.ChargeType, + CreateTime: formatUnixTime(c.CreateTime), + ExpireTime: formatUnixTime(c.ExpireTime), + } +} + +func formatUnixTime(ts int64) string { + if ts <= 0 { + return "" + } + return time.Unix(ts, 0).Format("2006-01-02") +} + +func listClusterID(ctx *cli.Context, clusters []listClusterInfo) { + ids := make([]string, 0, len(clusters)) + for _, c := range clusters { + ids = append(ids, c.InstanceId) + } + fmt.Fprintln(ctx.Out(), strings.Join(ids, ",")) +} + +// _RetCodeRegionNoPermission is the SDK RetCode when account lacks permission in the current region. +const _RetCodeRegionNoPermission = 230 diff --git a/products/uhadoop/internal/uhadoop/list_framework_app.go b/products/uhadoop/internal/uhadoop/list_framework_app.go new file mode 100644 index 0000000000..daac63d2af --- /dev/null +++ b/products/uhadoop/internal/uhadoop/list_framework_app.go @@ -0,0 +1,105 @@ +package uhadoop + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/response" + + uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// frameworkAppResponse mirrors the real API response for +// ListUHadoopFrameworkAppByUseCase. The SDK's UseCases type has MustHas as +// string and is missing the Apps field, but the API returns both as []string. +// We bypass the SDK-generated response type and unmarshal into our own struct. +type frameworkAppResponse struct { + response.CommonBase + AppConfigSet []frameworkAppConfigVersion `json:"AppConfigSet"` +} + +type frameworkAppConfigVersion struct { + Framework string `json:"Framework"` + FrameworkVersion string `json:"FrameworkVersion"` + HadoopVersion string `json:"HadoopVersion"` + ReleaseVersion string `json:"ReleaseVersion"` + UseCases []frameworkUseCaseRaw `json:"UseCases"` +} + +type frameworkUseCaseRaw struct { + ClusterCase string `json:"ClusterCase"` + Apps []string `json:"Apps"` + MustHas []string `json:"MustHas"` + AppVersion []frameworkAppEntry `json:"AppVersion"` +} + +type frameworkAppEntry struct { + AppName string `json:"AppName"` + AppVersion string `json:"AppVersion"` +} + +// newListFrameworkApp ucloud uhadoop list-framework-app +func newListFrameworkApp(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient) + req := client.NewListUHadoopFrameworkAppByUseCaseRequest() + cmd := &cobra.Command{ + Use: "list-framework-app", + Short: "List UHadoop framework apps by use case", + Long: `List available UHadoop frameworks and their applications organized by use case`, + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + + var resp frameworkAppResponse + // Use InvokeAction directly with our custom response struct because + // the SDK's typed response has MustHas as string (wrong: API returns []string). + err := client.InvokeAction("ListUHadoopFrameworkAppByUseCase", req, &resp) + if err != nil { + ctx.HandleError(err) + return + } + listFrameworkApps(ctx, resp.AppConfigSet) + }, + } + cmd.Flags().SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + return cmd +} + +func listFrameworkApps(ctx *cli.Context, appConfigs []frameworkAppConfigVersion) { + if ctx.Format() != cli.OutputTable { + ctx.PrintList(appConfigs) + return + } + + list := make([]frameworkRow, 0) + for _, ac := range appConfigs { + for _, uc := range ac.UseCases { + var apps []string + var versions []string + for _, av := range uc.AppVersion { + apps = append(apps, av.AppName) + versions = append(versions, av.AppName+"#"+av.AppVersion) + } + list = append(list, frameworkRow{ + Framework: ac.Framework, + FrameworkVersion: ac.FrameworkVersion, + ReleaseVersion: ac.ReleaseVersion, + HadoopVersion: ac.HadoopVersion, + UseCase: uc.ClusterCase, + Apps: strings.Join(apps, ","), + Versions: strings.Join(versions, ","), + MustHas: strings.Join(uc.MustHas, ","), + }) + } + } + ctx.PrintList(list) +} diff --git a/products/uhadoop/internal/uhadoop/list_node_type.go b/products/uhadoop/internal/uhadoop/list_node_type.go new file mode 100644 index 0000000000..10b5a3c16f --- /dev/null +++ b/products/uhadoop/internal/uhadoop/list_node_type.go @@ -0,0 +1,79 @@ +package uhadoop + +import ( + "strings" + + "github.com/spf13/cobra" + + uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListNodeType ucloud uhadoop list-node-type +func newListNodeType(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient) + req := client.NewGetUHadoopNodeTypeRequest() + cmd := &cobra.Command{ + Use: "list-node-type", + Short: "List available node types for UHadoop", + Long: `List available node/instance types for UHadoop clusters`, + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + + resp, err := client.GetUHadoopNodeType(req) + if err != nil { + ctx.HandleError(err) + return + } + listNodeTypes(ctx, resp.InstanceTypeSet) + }, + } + cmd.Flags().SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + req.Framework = cmd.Flags().String("framework", "", "Optional. Filter by framework: Hadoop|HDFS|MR|StarRocks-Shared-Nothing|StarRocks-Shared-Data") + req.FrameworkVersion = cmd.Flags().String("framework-version", "", "Optional. Filter by framework version, e.g. 3.3.4-udh3.2") + req.NodeRole = cmd.Flags().String("node-role", "", "Optional. Filter by node role: master|core|task") + req.NodeType = cmd.Flags().String("node-type", "", "Optional. Filter by node type name") + + command.SetFlagValues(cmd, "node-role", "master", "core", "task", "client") + + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + return cmd +} + +func listNodeTypes(ctx *cli.Context, types []uhadoopsdk.InstanceType) { + list := make([]instanceTypeRow, 0, len(types)) + for _, t := range types { + row := instanceTypeRow{ + NodeType: t.NodeType, + HostType: t.HostType, + CPU: t.CPU, + Memory: t.Memory, + CPUToMemoryRatio: t.CPUToMemoryRatio, + SuitableRole: strings.Join(t.SuitableRole, ","), + IsUsable: t.IsUsable, + GpuType: t.GpuType, + GpuCount: t.GpuCount, + } + if len(t.DiskSet) > 0 { + // Find the Data disk info + for _, d := range t.DiskSet { + if d.Type == "Data" { + row.DiskType = strings.Join(d.DiskType, ",") + row.DiskMinSize = d.DiskMinSize + row.DiskMaxSize = d.DiskMaxSize + row.DiskMinNum = d.DiskMinNum + row.DiskMaxNum = d.DiskMaxNum + break + } + } + } + list = append(list, row) + } + ctx.PrintList(list) +} diff --git a/products/uhadoop/internal/uhadoop/poll.go b/products/uhadoop/internal/uhadoop/poll.go new file mode 100644 index 0000000000..02fe71874b --- /dev/null +++ b/products/uhadoop/internal/uhadoop/poll.go @@ -0,0 +1,26 @@ +package uhadoop + +import ( + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func describeClusterForPoll(ctx *cli.Context, client *uhadoopsdk.UHadoopClient) func(string, *request.CommonBase) (interface{}, error) { + return func(id string, _ *request.CommonBase) (interface{}, error) { + req := client.NewDescribeUHadoopInstanceRequest() + req.InstanceId = sdk.String(id) + var resp describeClusterResponse + err := client.InvokeAction("DescribeUHadoopInstance", req, &resp) + if err != nil { + return nil, err + } + if len(resp.ClusterSet) == 0 { + return nil, nil + } + return resp.ClusterSet[0], nil + } +} diff --git a/products/uhadoop/internal/uhadoop/restart_service.go b/products/uhadoop/internal/uhadoop/restart_service.go new file mode 100644 index 0000000000..f336baa353 --- /dev/null +++ b/products/uhadoop/internal/uhadoop/restart_service.go @@ -0,0 +1,75 @@ +package uhadoop + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newRestartService(ctx *cli.Context) *cobra.Command { + var yes bool + client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient) + req := client.NewRestartUHadoopServiceRequest() + var nodeIds []string + var nodeRoles []string + cmd := &cobra.Command{ + Use: "restart-service", + Short: "Restart/start/stop a UHadoop cluster service", + Long: `Restart, start, or stop a service on a UHadoop cluster`, + Run: func(cmd *cobra.Command, args []string) { + action := "restart" + if req.OnlyStart != nil && *req.OnlyStart { + action = "start" + } + if req.OnlyStop != nil && *req.OnlyStop { + action = "stop" + } + ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure you want to %s service %s on cluster %s?", action, *req.ServiceName, *req.InstanceId)) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + req.NodeId = nodeIds + req.NodeRole = nodeRoles + resp, err := client.RestartUHadoopService(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(w, "uhadoop[%s] service %s %s, state: %s\n", *req.InstanceId, *req.ServiceName, action, resp.State) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceId, Action: action, Status: resp.State}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + req.InstanceId = flags.String("instance-id", "", "Required. Cluster instance ID") + req.ServiceName = flags.String("service-name", "", "Required. Service name") + req.ApplicationVersion = flags.String("application-version", "", "Optional. Application version") + req.OnlyStart = flags.Bool("only-start", false, "Optional. Only start the service") + req.OnlyStop = flags.Bool("only-stop", false, "Optional. Only stop the service") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation") + flags.StringSliceVar(&nodeIds, "node-id", nil, "Optional. Node IDs") + flags.StringSliceVar(&nodeRoles, "node-role", nil, "Optional. Node roles: master|core|task") + + command.SetFlagValues(cmd, "only-start", "true", "false") + command.SetFlagValues(cmd, "only-stop", "true", "false") + command.SetFlagValues(cmd, "node-role", "master", "core", "task") + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("service-name") + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + + return cmd +} diff --git a/products/uhadoop/internal/uhadoop/rows.go b/products/uhadoop/internal/uhadoop/rows.go new file mode 100644 index 0000000000..cedd142968 --- /dev/null +++ b/products/uhadoop/internal/uhadoop/rows.go @@ -0,0 +1,60 @@ +package uhadoop + +// listRow is the full row for ListUHadoopInstance output. +type listRow struct { + InstanceId string + InstanceName string + Framework string + ReleaseVersion string + HadoopVersion string + State string + Zone string + VPCId string + SubnetId string + ChargeType string + CreateTime string + ExpireTime string +} + +// listRowDefault is the default (non-wide) column set for list. +type listRowDefault struct { + InstanceId string + InstanceName string + Framework string + ReleaseVersion string + HadoopVersion string + State string + Zone string + CreateTime string + ExpireTime string +} + +// instanceTypeRow is the row for GetUHadoopNodeType output. +type instanceTypeRow struct { + NodeType string + HostType string + CPU string + Memory string + CPUToMemoryRatio string + SuitableRole string + IsUsable string + GpuType string + GpuCount int + DiskType string + DiskMinSize string + DiskMaxSize string + DiskMinNum string + DiskMaxNum string +} + +// frameworkRow is the row for ListUHadoopFrameworkAppByUseCase output. +type frameworkRow struct { + Framework string + FrameworkVersion string + ReleaseVersion string + HadoopVersion string + UseCase string + Apps string + Versions string + MustHas string +} diff --git a/products/uhadoop/internal/uhadoop/status.go b/products/uhadoop/internal/uhadoop/status.go new file mode 100644 index 0000000000..0f6204686e --- /dev/null +++ b/products/uhadoop/internal/uhadoop/status.go @@ -0,0 +1,3 @@ +package uhadoop + +const stateRunning = "Running" diff --git a/products/uhadoop/internal/uhadoop/upgrade_disk.go b/products/uhadoop/internal/uhadoop/upgrade_disk.go new file mode 100644 index 0000000000..bca5228601 --- /dev/null +++ b/products/uhadoop/internal/uhadoop/upgrade_disk.go @@ -0,0 +1,60 @@ +package uhadoop + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newUpgradeDisk(ctx *cli.Context) *cobra.Command { + var yes *bool + client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient) + req := client.NewUpgradeUHadoopNodeDiskRequest() + var nodeNames []string + cmd := &cobra.Command{ + Use: "upgrade-disk", + Short: "Upgrade UHadoop node disk size", + Long: `Upgrade UHadoop node data disk (and optionally boot disk) size`, + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(*yes, fmt.Sprintf("Upgrade disk on %s nodes of cluster %s to %d GB?", *req.NodeRole, *req.InstanceId, *req.DataDiskSize)) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.NodeNames = nodeNames + _, err = client.UpgradeUHadoopNodeDisk(req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceId, Action: "upgrade-disk", Status: "Upgrading"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", "", "Optional. Assign availability zone") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.InstanceId = flags.String("instance-id", "", "Required. Cluster instance ID") + req.NodeRole = flags.String("node-role", "", "Required. Node role: master|core|task|client") + req.DataDiskSize = flags.Int("data-disk-size", 0, "Required. New data disk size in GB") + req.BootDiskSize = flags.Int("boot-disk-size", 0, "Optional. New boot disk size in GB") + yes = flags.BoolP("yes", "y", false, "Do not prompt for confirmation") + flags.StringSliceVar(&nodeNames, "node-name", nil, "Node names, required when NodeRole != master") + command.SetFlagValues(cmd, "node-role", "master", "core", "task", "client") + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("node-role") + cmd.MarkFlagRequired("data-disk-size") + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + return cmd +} diff --git a/products/uhadoop/internal/uhadoop/upgrade_node.go b/products/uhadoop/internal/uhadoop/upgrade_node.go new file mode 100644 index 0000000000..14aa5fd45b --- /dev/null +++ b/products/uhadoop/internal/uhadoop/upgrade_node.go @@ -0,0 +1,63 @@ +package uhadoop + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newUpgradeNode(ctx *cli.Context) *cobra.Command { + var yes bool + client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient) + req := client.NewUpgradeUHadoopNodeRequest() + var nodeNames []string + cmd := &cobra.Command{ + Use: "upgrade-node", + Short: "Upgrade UHadoop node instance type", + Long: `Upgrade UHadoop node to a new instance type`, + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, fmt.Sprintf("Upgrade %s nodes on cluster %s to %s?", *req.NodeRole, *req.InstanceId, *req.NodeType)) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + req.NodeNames = nodeNames + _, err = client.UpgradeUHadoopNode(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("uhadoop[%s] upgrading %s nodes to %s", *req.InstanceId, *req.NodeRole, *req.NodeType) + fmt.Fprintln(w, text) + ctx.PollerTo(w, describeClusterForPoll(ctx, client), cli.WithTimeout(40*time.Minute)).Spoll(*req.InstanceId, text, []string{stateRunning}) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceId, Action: "upgrade-node", Status: "Upgrading"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + req.InstanceId = flags.String("instance-id", "", "Required. Cluster instance ID") + req.NodeRole = flags.String("node-role", "", "Required. Node role: master|core|task|client") + req.NodeType = flags.String("node-type", "", "Required. New node type") + flags.BoolVarP(&yes, "yes", "y", false, "Do not prompt for confirmation") + flags.StringSliceVar(&nodeNames, "node-name", nil, "Node names, required when NodeRole != master") + command.SetFlagValues(cmd, "node-role", "master", "core", "task", "client") + cmd.MarkFlagRequired("instance-id") + cmd.MarkFlagRequired("node-role") + cmd.MarkFlagRequired("node-type") + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + return cmd +} diff --git a/products/uhadoop/product.go b/products/uhadoop/product.go new file mode 100644 index 0000000000..53ff5cf816 --- /dev/null +++ b/products/uhadoop/product.go @@ -0,0 +1,21 @@ +package uhadoop + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internaluhadoop "github.com/ucloud/ucloud-cli/products/uhadoop/internal/uhadoop" +) + +type product struct{} + +// New returns the uhadoop product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "uhadoop", Commands: []string{"uhadoop"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internaluhadoop.NewCommand(ctx)} +} diff --git a/products/uhadoop/product.yaml b/products/uhadoop/product.yaml new file mode 100644 index 0000000000..d4caea376f --- /dev/null +++ b/products/uhadoop/product.yaml @@ -0,0 +1,6 @@ +name: uhadoop +owners: + - markwei-ucloud +commands: + - uhadoop +enabled: true diff --git a/products/uhadoop/testdata/cmdtree.golden b/products/uhadoop/testdata/cmdtree.golden new file mode 100644 index 0000000000..955457757c --- /dev/null +++ b/products/uhadoop/testdata/cmdtree.golden @@ -0,0 +1,120 @@ +ucloud uhadoop use=uhadoop short=List,create,delete,describe UHadoop clusters and manage nodes and services +ucloud uhadoop add-node use=add-node short=Add nodes to a UHadoop cluster + flag=async short= default=false required= + flag=boot-disk-size short= default=50 required= + flag=boot-disk-type short= default=CLOUD_RSSD required= + flag=data-disk-num short= default=1 required= + flag=data-disk-size short= default=200 required= + flag=data-disk-type short= default=CLOUD_RSSD required= + flag=instance-id short= default= required=true + flag=node-count short= default=1 required= + flag=node-role short= default= required=true + flag=node-type short= default= required=true + flag=password short= default= required= + flag=project-id short= default= required= + flag=region short= default= required=true + flag=zone short= default= required=true +ucloud uhadoop create use=create short=Create a UHadoop cluster + flag=app-config short= default=[] required= + flag=async short= default=false required= + flag=business-id short= default=Default required= + flag=charge-type short= default=Month required= + flag=cluster-case short= default= required= + flag=core-boot-disk-size short= default=50 required= + flag=core-boot-disk-type short= default=CLOUD_RSSD required= + flag=core-count short= default=3 required= + flag=core-data-disk-num short= default=1 required= + flag=core-data-disk-size short= default=200 required= + flag=core-data-disk-type short= default=CLOUD_RSSD required= + flag=core-node-type short= default=o.hadoop2m.xlarge required= + flag=framework short= default= required=true + flag=framework-version short= default= required=true + flag=master-boot-disk-size short= default=50 required= + flag=master-boot-disk-type short= default=CLOUD_RSSD required= + flag=master-count short= default=2 required= + flag=master-data-disk-num short= default=1 required= + flag=master-data-disk-size short= default=100 required= + flag=master-data-disk-type short= default=CLOUD_RSSD required= + flag=master-node-type short= default=o.hadoop4m.xlarge required= + flag=meta-store short= default= required= + flag=name short= default= required=true + flag=password short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required=true + flag=sec-group-ids short= default= required= + flag=security-enabled short= default= required= + flag=storage-cluster-id short= default= required= + flag=subnet-id short= default= required= + flag=task-boot-disk-size short= default=50 required= + flag=task-boot-disk-type short= default=CLOUD_RSSD required= + flag=task-count short= default=0 required= + flag=task-data-disk-num short= default=1 required= + flag=task-data-disk-size short= default=200 required= + flag=task-data-disk-type short= default=CLOUD_RSSD required= + flag=task-node-type short= default=o.hadoop2m.xlarge required= + flag=us3-access-key short= default= required= + flag=us3-bucket short= default= required= + flag=us3-secret-key short= default= required= + flag=us3-token-name short= default= required= + flag=vpc-id short= default= required= + flag=zone short= default= required=true +ucloud uhadoop delete use=delete short=Delete a UHadoop cluster + flag=project-id short= default= required= + flag=region short= default= required=true + flag=release-eip short= default=false required= + flag=yes short=y default=false required= + flag=zone short= default= required=true +ucloud uhadoop describe use=describe short=Describe a UHadoop cluster + flag=region short= default= required=true + flag=zone short= default= required=true +ucloud uhadoop list use=list short=List all UHadoop clusters + flag=all-region short= default=false required= + flag=id-only short= default=false required= + flag=limit short= default=60 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud uhadoop list-framework-app use=list-framework-app short=List UHadoop framework apps by use case + flag=project-id short= default= required= + flag=region short= default= required=true + flag=zone short= default= required=true +ucloud uhadoop list-node-type use=list-node-type short=List available node types for UHadoop + flag=framework short= default= required= + flag=framework-version short= default= required= + flag=node-role short= default= required= + flag=node-type short= default= required= + flag=region short= default= required=true + flag=zone short= default= required=true +ucloud uhadoop restart-service use=restart-service short=Restart/start/stop a UHadoop cluster service + flag=application-version short= default= required= + flag=instance-id short= default= required=true + flag=node-id short= default=[] required= + flag=node-role short= default=[] required= + flag=only-start short= default=false required= + flag=only-stop short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required=true + flag=service-name short= default= required=true + flag=yes short=y default=false required= + flag=zone short= default= required=true +ucloud uhadoop upgrade-disk use=upgrade-disk short=Upgrade UHadoop node disk size + flag=boot-disk-size short= default=0 required= + flag=data-disk-size short= default=0 required=true + flag=instance-id short= default= required=true + flag=node-name short= default=[] required= + flag=node-role short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required=true + flag=yes short=y default=false required= + flag=zone short= default= required=true +ucloud uhadoop upgrade-node use=upgrade-node short=Upgrade UHadoop node instance type + flag=instance-id short= default= required=true + flag=node-name short= default=[] required= + flag=node-role short= default= required=true + flag=node-type short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required=true + flag=yes short=y default=false required= + flag=zone short= default= required=true diff --git a/products/uhadoop/testdata/completion.golden b/products/uhadoop/testdata/completion.golden new file mode 100644 index 0000000000..444fa9fe5f --- /dev/null +++ b/products/uhadoop/testdata/completion.golden @@ -0,0 +1,31 @@ +ucloud uhadoop add-node node-role static client,core,task +ucloud uhadoop create charge-type static Dynamic,Month,Year +ucloud uhadoop create cluster-case static Core-Hadoop,Hbase,Spark +ucloud uhadoop create security-enabled static false,true +ucloud uhadoop delete project-id dynamic +ucloud uhadoop delete region dynamic +ucloud uhadoop delete release-eip static false,true +ucloud uhadoop delete zone dynamic +ucloud uhadoop describe region dynamic +ucloud uhadoop describe zone dynamic +ucloud uhadoop list all-region static false,true +ucloud uhadoop list id-only static false,true +ucloud uhadoop list project-id dynamic +ucloud uhadoop list region dynamic +ucloud uhadoop list-framework-app project-id dynamic +ucloud uhadoop list-framework-app region dynamic +ucloud uhadoop list-framework-app zone dynamic +ucloud uhadoop list-node-type node-role static client,core,master,task +ucloud uhadoop list-node-type region dynamic +ucloud uhadoop list-node-type zone dynamic +ucloud uhadoop restart-service node-role static core,master,task +ucloud uhadoop restart-service only-start static false,true +ucloud uhadoop restart-service only-stop static false,true +ucloud uhadoop restart-service project-id dynamic +ucloud uhadoop restart-service region dynamic +ucloud uhadoop restart-service zone dynamic +ucloud uhadoop upgrade-disk node-role static client,core,master,task +ucloud uhadoop upgrade-node node-role static client,core,master,task +ucloud uhadoop upgrade-node project-id dynamic +ucloud uhadoop upgrade-node region dynamic +ucloud uhadoop upgrade-node zone dynamic diff --git a/products/uhost/internal/uhost/clone.go b/products/uhost/internal/uhost/clone.go new file mode 100644 index 0000000000..d4db01a449 --- /dev/null +++ b/products/uhost/internal/uhost/clone.go @@ -0,0 +1,141 @@ +package uhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newClone ucloud uhost clone +func newClone(ctx *cli.Context) *cobra.Command { + var uhostID *string + var async *bool + + var password string + var keyPairId string + + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + unetClient := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewCreateUHostInstanceRequest() + cmd := &cobra.Command{ + Use: "clone", + Short: "Create an uhost with the same configuration as another uhost, excluding bound eip and udisk", + Long: "Create an uhost with the same configuration as another uhost, excluding bound eip and udisk", + Run: func(com *cobra.Command, args []string) { + w := ctx.ProgressWriter() + if len(password) > 0 { + req.LoginMode = sdk.String("Password") + req.KeyPairId = nil + req.Password = sdk.String(password) + } else if len(keyPairId) > 0 { + req.LoginMode = sdk.String("KeyPair") + req.KeyPairId = sdk.String(keyPairId) + req.Password = nil + } else { + ctx.HandleError(fmt.Errorf("password or key-pair-id is required")) + return + } + *uhostID = ctx.PickResourceID(*uhostID) + queryReq := client.NewDescribeUHostInstanceRequest() + queryReq.ProjectId = req.ProjectId + queryReq.Region = req.Region + queryReq.Zone = req.Zone + queryReq.UHostIds = []string{*uhostID} + queryResp, err := client.DescribeUHostInstance(queryReq) + if err != nil { + ctx.HandleError(err) + return + } + if len(queryResp.UHostSet) < 1 { + ctx.HandleError(fmt.Errorf("uhost[%s] not exist", *uhostID)) + return + } + if queryResp.UHostSet[0].SecGroupInstance == true { + ctx.HandleError(fmt.Errorf("uhost[%s] is in security groups, it is not allowed to clone", *uhostID)) + return + } + queryFirewallReq := unetClient.NewDescribeFirewallRequest() + queryFirewallReq.ProjectId = req.ProjectId + queryFirewallReq.Region = req.Region + queryFirewallReq.ResourceId = uhostID + queryFirewallReq.ResourceType = sdk.String("uhost") + + firewallResp, err := unetClient.DescribeFirewall(queryFirewallReq) + if err != nil { + ctx.HandleError(err) + return + } + + if len(firewallResp.DataSet) == 1 { + req.SecurityGroupId = &firewallResp.DataSet[0].FWId + } + + uhostIns := queryResp.UHostSet[0] + + req.ImageId = &uhostIns.BasicImageId + req.CPU = &uhostIns.CPU + req.Memory = &uhostIns.Memory + for _, ip := range uhostIns.IPSet { + if ip.Type == "Private" { + req.VPCId = &ip.VPCId + req.SubnetId = &ip.SubnetId + } + } + req.ChargeType = &uhostIns.ChargeType + req.UHostType = &uhostIns.UHostType + req.NetCapability = &uhostIns.NetCapability + + for _, disk := range uhostIns.DiskSet { + item := uhostsdk.UHostDisk{ + Size: sdk.Int(disk.Size), + Type: sdk.String(disk.DiskType), + IsBoot: sdk.String(disk.IsBoot), + } + if disk.BackupType != "" { + item.BackupType = sdk.String(disk.BackupType) + } + req.Disks = append(req.Disks, item) + } + req.Tag = &uhostIns.Tag + resp, err := client.CreateUHostInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + if len(resp.UHostIds) == 1 { + text := fmt.Sprintf("cloned uhost:[%s] is initializing", resp.UHostIds[0]) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUHostByID(ctx, *req.ProjectId, *req.Region, *req.Zone)).Spoll(resp.UHostIds[0], text, []string{HOST_RUNNING, HOST_FAIL}) + } + } else { + ctx.HandleError(fmt.Errorf("expect uhost count 1, accept %d", len(resp.UHostIds))) + return + } + }, + } + flags := cmd.Flags() + flags.SortFlags = false + uhostID = flags.String("uhost-id", "", "Required. Resource ID of the uhost to clone from") + flags.StringVar(&password, "password", "", "Optional. Password of the uhost user(root/ubuntu)") + flags.StringVar(&keyPairId, "key-pair-id", "", "Optional. Resource ID of ssh key pair. See 'ucloud api --Action DescribeUHostKeyPairs' Where both password and key-pair-id are set, the key-pair-id is ignored") + + req.Name = flags.String("name", "", "Optional. Name of the uhost to clone") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) + }) + cmd.MarkFlagRequired("uhost-id") + return cmd +} diff --git a/products/uhost/internal/uhost/cmd.go b/products/uhost/internal/uhost/cmd.go new file mode 100644 index 0000000000..5d9c44f673 --- /dev/null +++ b/products/uhost/internal/uhost/cmd.go @@ -0,0 +1,36 @@ +package uhost + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `uhost` root command and mounts the 14 subcommands in +// the same AddCommand order as cmd/uhost.go NewCmdUHost: list, create, delete, +// stop, start, restart, poweroff, resize, clone, reset-password, reinstall-os, +// create-image, isolation-group (subtree), leave-isolation-group. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "uhost", + Short: "List,create,delete,stop,restart,poweroff or resize UHost instance", + Long: `List,create,delete,stop,restart,poweroff or resize UHost instance`, + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newStop(ctx)) + cmd.AddCommand(newStart(ctx)) + cmd.AddCommand(newReboot(ctx)) + cmd.AddCommand(newPoweroff(ctx)) + cmd.AddCommand(newResize(ctx)) + cmd.AddCommand(newClone(ctx)) + cmd.AddCommand(newResetPassword(ctx)) + cmd.AddCommand(newReinstallOS(ctx)) + cmd.AddCommand(newCreateImage(ctx)) + cmd.AddCommand(newIsolationGroup(ctx)) + cmd.AddCommand(newLeaveIsolationGroup(ctx)) + + return cmd +} diff --git a/products/uhost/internal/uhost/completion.go b/products/uhost/internal/uhost/completion.go new file mode 100644 index 0000000000..39ca6b1855 --- /dev/null +++ b/products/uhost/internal/uhost/completion.go @@ -0,0 +1,248 @@ +package uhost + +import ( + "fmt" + "strings" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + "github.com/ucloud/ucloud-sdk-go/services/unet" + "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// completion.go holds the cross-product completion-data fetchers that uhost's +// flags need (--vpc-id, --subnet-id, --firewall-id, --bind-eip, --image-id). +// Each is a self-contained SDK call COPIED from the originating product's cmd +// file (NOT imported — products stay boundary-isolated), with base.BizClient +// swapped for cli.NewServiceClient. Request-logging is dropped; completion funcs +// must stay silent. + +// getAllVPCIns mirrors cmd/vpc.go getAllVPCIns. +func getAllVPCIns(ctx *cli.Context, project, region string) ([]vpc.VPCInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +// getAllVPCIdNames mirrors cmd/vpc.go getAllVPCIdNames (--vpc-id completion). +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + vpcInsList, err := getAllVPCIns(ctx, project, region) + list := []string{} + if err != nil { + return nil + } + for _, vpc := range vpcInsList { + list = append(list, fmt.Sprintf("%s/%s", vpc.VPCId, vpc.Name)) + } + return list +} + +// getAllSubnets mirrors cmd/vpc.go getAllSubnets. +func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpc.SubnetInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + if vpcID != "" { + req.VPCId = sdk.String(cli.PickResourceID(vpcID)) + } + subnets := []vpc.SubnetInfo{} + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.DescribeSubnet(req) + if err != nil { + ctx.HandleError(err) + return nil, err + } + subnets = append(subnets, resp.DataSet...) + if limit+offset >= resp.TotalCount { + break + } + } + return subnets, nil +} + +// getAllSubnetIDNames mirrors cmd/vpc.go getAllSubnetIDNames (--subnet-id completion). +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + subnets, err := getAllSubnets(ctx, vpcID, project, region) + if err != nil { + return nil + } + list := []string{} + for _, s := range subnets { + list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return list +} + +// getAllFirewallIns lists all firewalls in project/region, paging by 100. +// Copied self-contained from cmd/firewall_compat.go (base.BizClient → +// cli.NewServiceClient). +func getAllFirewallIns(ctx *cli.Context, project, region string) ([]unet.FirewallDataSet, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeFirewallRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + list := []unet.FirewallDataSet{} + for offset, limit := 0, 100; ; offset += limit { + req.Offset = sdk.Int(offset) + req.Limit = sdk.Int(limit) + resp, err := client.DescribeFirewall(req) + if err != nil { + return nil, err + } + for _, fw := range resp.DataSet { + list = append(list, fw) + } + if resp.TotalCount < offset+limit { + break + } + } + return list, nil +} + +// getFirewallIDNames returns "FWId/Name" completion candidates (--firewall-id). +// Copied self-contained from cmd/firewall_compat.go. +func getFirewallIDNames(ctx *cli.Context, project, region string) (idNames []string) { + list, err := getAllFirewallIns(ctx, project, region) + if err != nil { + return + } + for _, f := range list { + idNames = append(idNames, f.FWId+"/"+f.Name) + } + return +} + +// getAllEip returns "EIPId/ip1,ip2" completion candidates filtered by states and +// paymodes (nil filter = no filter). Copied self-contained from +// cmd/eip_compat.go; uses the package-local fetchAllEip (eip.go). +func getAllEip(ctx *cli.Context, projectID, region string, states, paymodes []string) []string { + list, err := fetchAllEip(ctx, projectID, region) + if err != nil { + return nil + } + strs := []string{} + for _, item := range list { + rightState := false + if states == nil { + rightState = true + } else { + for _, s := range states { + if item.Status == s { + rightState = true + } + } + } + + rightPayMode := false + if paymodes == nil { + rightPayMode = true + } else { + for _, m := range paymodes { + if item.PayMode == m { + rightPayMode = true + } + } + } + if !rightPayMode || !rightState { + continue + } + + ips := []string{} + for _, ip := range item.EIPAddr { + ips = append(ips, ip.IP) + } + strs = append(strs, item.EIPId+"/"+strings.Join(ips, ",")) + } + return strs +} + +// getImageList returns "ImageId/ImageName" completion candidates filtered by +// states + imageType (--image-id completion on create). Copied self-contained +// from cmd/image_compat.go (base.BizClient → cli.NewServiceClient on the uhost +// SDK, which serves DescribeImage). +func getImageList(ctx *cli.Context, states []string, imageType, project, region, zone string) []string { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeImageRequest() + req.ProjectId = &project + req.Region = ®ion + req.Zone = &zone + req.Limit = sdk.Int(1000) + if imageType != IMAGE_ALL { + req.ImageType = sdk.String(imageType) + } + resp, err := client.DescribeImage(req) + if err != nil { + return nil + } + list := []string{} + for _, image := range resp.ImageSet { + for _, s := range states { + if image.State == s { + list = append(list, image.ImageId+"/"+image.ImageName) + } + } + } + return list +} + +// getUhostList returns "UHostId/Name" completion candidates filtered by states +// (nil = all). Copied self-contained from cmd/uhost.go getUhostList +// (base.BizClient → cli.NewServiceClient on the public uhost SDK). +func getUhostList(ctx *cli.Context, states []string, project, region, zone string) []string { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeUHostInstanceRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + req.Limit = sdk.Int(50) + resp, err := client.DescribeUHostInstance(req) + if err != nil { + //todo runtime log + return nil + } + list := []string{} + for _, host := range resp.UHostSet { + if states != nil { + for _, s := range states { + if host.State == s { + list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) + } + } + } else { + list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) + } + } + return list +} + +// getIsolationGroupList returns "GroupId/Name" completion candidates. Copied +// self-contained from cmd/uhost.go getIsolationGroupList (the original printed +// the fetch error to stdout; that diagnostic is dropped — completion funcs must +// stay silent so they don't corrupt shell completion output). +func getIsolationGroupList(ctx *cli.Context, project, region string) []string { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeIsolationGroupRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Limit = sdk.Int(50) + resp, err := client.DescribeIsolationGroup(req) + if err != nil { + return nil + } + list := []string{} + for _, group := range resp.IsolationGroupSet { + list = append(list, group.GroupId+"/"+strings.Replace(group.GroupName, " ", "-", -1)) + } + return list +} diff --git a/products/uhost/internal/uhost/create.go b/products/uhost/internal/uhost/create.go new file mode 100644 index 0000000000..0d5e8962f9 --- /dev/null +++ b/products/uhost/internal/uhost/create.go @@ -0,0 +1,580 @@ +package uhost + +import ( + "encoding/base64" + "fmt" + "regexp" + "sync" + "time" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// _MaxBoundSecGroupCount caps the --security-group-id count. Verbatim from +// cmd/uhost.go. +const _MaxBoundSecGroupCount = 5 + +// newCreate ucloud uhost create +func newCreate(ctx *cli.Context) *cobra.Command { + var bindEipIDs []string + var hotPlug string + var async bool + var count int + var concurrent int + var hotPlugImageFlag bool + var userData string + var userDataImageFlag bool + var userDataBase64 string + var firewallId string + var secGroupIds []string + var keyPairId string + var password string + + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + unetClient := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewCreateUHostInstanceRequest() + eipReq := uhostsdk.CreateUHostInstanceParamNetworkInterfaceEIP{} + updateEIPReq := unetClient.NewUpdateEIPAttributeRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create UHost instance", + Long: "Create UHost instance", + // SilenceUsage: runtime failures (RunE returning an error below) must not + // dump the full flag usage — aws/gcloud print the error only. Flag/arg + // mistakes still print their own message via cobra. + SilenceUsage: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + if len(userData) > 0 && len(userDataBase64) > 0 { + return fmt.Errorf("%q conflicts with %q, can only set one of both", "user-data", "user-data-base64") + } + + if len(userDataBase64) > 0 { + if !common.IsBase64Encoded([]byte(userDataBase64)) { + return fmt.Errorf("%q must be base64-encoded", "user-data-base64") + } + } + + if concurrent > 50 { + return fmt.Errorf("%q should not be more than 50, current value is %v", "concurrent", concurrent) + } + + // GPU and GpuType must be specified together: if one is set, the other must also be set. + gpuVal, _ := cmd.Flags().GetInt("gpu") + gpuTypeVal, _ := cmd.Flags().GetString("gpu-type") + if gpuVal > 0 && gpuTypeVal == "" { + return fmt.Errorf("--gpu requires --gpu-type, e.g. --gpu-type V100") + } + if gpuTypeVal != "" && gpuVal <= 0 { + return fmt.Errorf("--gpu-type requires --gpu, e.g. --gpu 1") + } + + return nil + }, + + RunE: func(cmd *cobra.Command, args []string) error { + *req.Memory *= 1024 + // If --gpu and --gpu-type are specified, auto-set MachineType to "G" + // unless the user explicitly set --machine-type. + if *req.GPU > 0 && *req.GpuType != "" { + if !cmd.Flags().Changed("machine-type") { + req.MachineType = sdk.String("G") + } + } + if len(password) > 0 { + req.LoginMode = sdk.String("Password") + req.KeyPairId = nil + req.Password = sdk.String(password) + } else if len(keyPairId) > 0 { + req.LoginMode = sdk.String("KeyPair") + req.KeyPairId = sdk.String(keyPairId) + req.Password = nil + } else { + return fmt.Errorf("password or key-pair-id is required") + } + if len(firewallId) > 0 { + req.SecurityGroupId = sdk.String(firewallId) + } else if len(secGroupIds) > 0 { + if len(secGroupIds) > _MaxBoundSecGroupCount { + return fmt.Errorf("security group count should not be more than 5") + } + secGroupList := make([]uhostsdk.CreateUHostInstanceParamSecGroupId, 0) + for idx, secGroupId := range secGroupIds { + secGroupList = append(secGroupList, uhostsdk.CreateUHostInstanceParamSecGroupId{Id: sdk.String(secGroupId), Priority: sdk.Int(1 + idx)}) + } + req.SecGroupId = secGroupList + req.SecurityMode = sdk.String("SecGroup") + } + req.ImageId = sdk.String(ctx.PickResourceID(*req.ImageId)) + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId)) + req.IsolationGroup = sdk.String(ctx.PickResourceID(*req.IsolationGroup)) + if *req.Disks[1].Type == "NONE" || *req.Disks[1].Type == "" { + req.Disks = req.Disks[:1] + } + if hotPlug == "true" || len(userData) > 0 || len(userDataBase64) > 0 { + any, err := describeImageByID(ctx, *req.ProjectId, *req.Region, *req.Zone)(ctx.PickResourceID(*req.ImageId), nil) + if err != nil { + return fmt.Errorf("check image support feaures failed: %v", err) + } else { + image, ok := any.(*uhostsdk.UHostImageSet) + if !ok { + return fmt.Errorf("check image support feaures failed, image %s may not exist", *req.ImageId) + } + for _, feature := range image.Features { + if feature == "HotPlug" { + hotPlugImageFlag = true + } + if feature == "CloudInit" { + userDataImageFlag = true + } + } + } + if !hotPlugImageFlag && hotPlug == "true" { + ctx.LogWarn(fmt.Sprintf("warning. image %s does not support hot-plug", *req.ImageId)) + req.HotplugFeature = sdk.Bool(false) + } + + if !userDataImageFlag && (len(userData) > 0 || len(userDataBase64) > 0) { + return fmt.Errorf("image %s does not support user-data feature", *req.ImageId) + } + + if hotPlug == "true" { + req.HotplugFeature = sdk.Bool(true) + } + + if len(userData) > 0 { + req.UserData = sdk.String(base64.StdEncoding.EncodeToString([]byte(userData))) + } + + if len(userDataBase64) > 0 { + req.UserData = sdk.String(userDataBase64) + } + } + if *eipReq.Bandwidth != 0 || *eipReq.PayMode == "ShareBandwidth" { + if *eipReq.OperatorName == "" { + *eipReq.OperatorName = getEIPLine(*req.Region) + } + req.NetworkInterface = []uhostsdk.CreateUHostInstanceParamNetworkInterface{{EIP: &eipReq}} + } + + prog := ctx.NewProgress() + wg := &sync.WaitGroup{} + tokens := make(chan struct{}, concurrent) + fc := &failCounter{} + rc := &resultCollector{} + wg.Add(count) + batchRename, err := regexp.Match(`\[\d+,\d+\]`, []byte(*req.Name)) + if err != nil || !batchRename { + batchRename = false + } + if batchRename { + var actualRequest uhostsdk.CreateUHostInstanceRequest + actualRequest = *req + if len(bindEipIDs) > 0 { + if len(bindEipIDs) != count { + return fmt.Errorf("bind-eip count should be equal to uhost count") + } + actualRequest.NetworkInterface = nil + } + wg.Add(1 - count) + createMultipleUhostWrapper(ctx, prog, client, unetClient, &actualRequest, count, updateEIPReq, bindEipIDs, async, make(chan bool, 1), wg, tokens, fc, rc) + + } else if count <= 5 { + for i := 0; i < count; i++ { + bindEipID := "" + if len(bindEipIDs) > i { + bindEipID = bindEipIDs[i] + } + var actualRequest uhostsdk.CreateUHostInstanceRequest + actualRequest = *req + if bindEipID != "" { + actualRequest.NetworkInterface = nil + } + createUhostWrapper(ctx, prog, client, unetClient, &actualRequest, updateEIPReq, bindEipID, async, make(chan bool, count), wg, tokens, i, fc, rc) + } + } else { + retCh := make(chan bool, count) + prog.Disable() + + go func(req uhostsdk.CreateUHostInstanceRequest) { + for i := 0; i < count; i++ { + actualRequest := req + bindEipID := "" + if len(bindEipIDs) > i { + bindEipID = bindEipIDs[i] + actualRequest.NetworkInterface = nil + } + go createUhostWrapper(ctx, prog, client, unetClient, &actualRequest, updateEIPReq, bindEipID, async, retCh, wg, tokens, i, fc, rc) + } + }(*req) + + go func() { + var success, fail int + prog.Refresh(fmt.Sprintf("uhost creating, total:%d, success:%d, fail:%d", count, success, fail)) + for ret := range retCh { + if ret { + success++ + } else { + fail++ + } + prog.Refresh(fmt.Sprintf("uhost creating, total:%d, success:%d, fail:%d", count, success, fail)) + if count == success+fail && fail > 0 { + fmt.Fprintf(ctx.ProgressWriter(), "Check logs in %s\n", ctx.LogFilePath()) + } + } + }() + } + wg.Wait() + ctx.EmitResult(rc.all()...) + if n := fc.count(); n > 0 { + return fmt.Errorf("%d of %d uhost create operation(s) failed; see the error(s) above or logs in %s", n, count, ctx.LogFilePath()) + } + return nil + }, + } + + req.Disks = make([]uhostsdk.UHostDisk, 2) + req.Disks[0].IsBoot = sdk.String("True") + req.Disks[1].IsBoot = sdk.String("False") + + flags := cmd.Flags() + flags.SortFlags = false + req.CPU = flags.Int("cpu", 4, "Required. The count of CPU cores. Optional parameters: {1, 2, 4, 8, 12, 16, 24, 32, 64}") + req.Memory = flags.Int("memory-gb", 8, "Required. Memory size. Unit: GB. Range: [1, 512], multiple of 2") + flags.StringVar(&password, "password", "", "Optional. Password of the uhost user(root/ubuntu)") + flags.StringVar(&keyPairId, "key-pair-id", "", "Optional. Resource ID of ssh key pair. See 'ucloud api --Action DescribeUHostKeyPairs' Where both password and key-pair-id are set, the key-pair-id is ignored") + req.ImageId = flags.String("image-id", "", "Required. The ID of image. see 'ucloud image list'") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for the long-running operation to finish.") + flags.IntVar(&count, "count", 1, "Optional. Number of uhost to create.") + flags.IntVar(&concurrent, "concurrent", 20, "Optional. The count of concurrent uhost creation requests.") + req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID. This field is required under VPC2.0. See 'ucloud vpc list'") + req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID. This field is required under VPC2.0. See 'ucloud subnet list'") + req.Name = flags.String("name", "UHost", "Optional. UHost instance name") + flags.StringSliceVar(&bindEipIDs, "bind-eip", nil, "Optional. Resource ID or IP Address of eip that will be bound to the new created uhost") + eipReq.OperatorName = flags.String("create-eip-line", "", "Optional. BGP for regions in the chinese mainland and International for overseas regions") + eipReq.Bandwidth = flags.Int("create-eip-bandwidth-mb", 0, "Optional. Required if you want to create new EIP. Bandwidth(Unit:Mbps).The range of value related to network charge mode. By traffic [1, 300]; by bandwidth [1,800] (Unit: Mbps); it could be 0 if the eip belong to the shared bandwidth") + eipReq.PayMode = flags.String("create-eip-traffic-mode", "Bandwidth", "Optional. 'Traffic','Bandwidth' or 'ShareBandwidth'") + eipReq.ShareBandwidthId = flags.String("shared-bw-id", "", "Optional. Resource ID of shared bandwidth. It takes effect when create-eip-traffic-mode is ShareBandwidth ") + updateEIPReq.Name = flags.String("create-eip-name", "", "Optional. Name of created eip to bind with the uhost") + updateEIPReq.Remark = flags.String("create-eip-remark", "", "Optional.Remark of your EIP.") + + req.ChargeType = flags.String("charge-type", "Month", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") + req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") + // bindProjectID/bindRegion/bindZone (cmd/uhost.go) → ctx.Bind*: these register + // the dynamic project/region/zone completion the golden requires (raw flags + // would drop it) and share the value with req via SetRef. + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + + req.MachineType = flags.String("machine-type", "O", "Optional. Accept values: N, C, G, O, OS. Forward to https://docs.ucloud.cn/api/uhost-api/uhost_type for details") + req.MinimalCpuPlatform = flags.String("minimal-cpu-platform", "", "Optional. Accept values: Intel/Auto, Intel/IvyBridge, Intel/Haswell, Intel/Broadwell, Intel/Skylake, Intel/Cascadelake") + req.UHostType = flags.String("type", "", "Optional. Accept values: N1, N2, N3, G1, G2, G3, I1, I2, C1. Forward to https://docs.ucloud.cn/api/uhost-api/uhost_type for details") + req.GPU = flags.Int("gpu", 0, "Optional. The count of GPU cores.") + req.NetCapability = flags.String("net-capability", "Normal", "Optional. Accept values: Normal, Super and Ultra. 'Normal' will disable network enhancement. 'Super' will enable network enhancement 1.0. 'Ultra' will enable network enhancement 2.0") + flags.StringVar(&hotPlug, "hot-plug", "true", "Optional. Enable hot plug feature or not. Accept values: true or false") + req.Disks[0].Type = flags.String("os-disk-type", "CLOUD_SSD", "Optional. Enumeration value. 'LOCAL_NORMAL', Ordinary local disk; 'CLOUD_NORMAL', Ordinary cloud disk; 'LOCAL_SSD',local ssd disk; 'CLOUD_SSD',cloud ssd disk; 'EXCLUSIVE_LOCAL_DISK',big data. The disk only supports a limited combination.") + req.Disks[0].Size = flags.Int("os-disk-size-gb", 20, "Optional. Default 20G. Windows should be bigger than 40G Unit GB") + req.Disks[0].BackupType = flags.String("os-disk-backup-type", "NONE", "Optional. Enumeration value, 'NONE' or 'DATAARK'. DataArk supports real-time backup, which can restore the disk back to any moment within the last 12 hours. (Normal Local Disk and Normal Cloud Disk Only)") + req.Disks[1].Type = flags.String("data-disk-type", "CLOUD_SSD", "Optional. Accept values: 'LOCAL_NORMAL','LOCAL_SSD','CLOUD_NORMAL',CLOUD_SSD','CLOUD_RSSD','EXCLUSIVE_LOCAL_DISK' and 'NONE'. 'LOCAL_NORMAL', Ordinary local disk; 'CLOUD_NORMAL', Ordinary cloud disk; 'LOCAL_SSD',local ssd disk; 'CLOUD_SSD',cloud ssd disk; 'CLOUD_RSSD', coud rssd disk; 'EXCLUSIVE_LOCAL_DISK',big data. The disk only supports a limited combination. 'NONE', create uhost without data disk. More details https://docs.ucloud.cn/api/uhost-api/disk_type") + req.Disks[1].Size = flags.Int("data-disk-size-gb", 20, "Optional. Disk size. Unit GB") + req.Disks[1].BackupType = flags.String("data-disk-backup-type", "NONE", "Optional. Enumeration value, 'NONE' or 'DATAARK'. DataArk supports real-time backup, which can restore the disk back to any moment within the last 12 hours. (Normal Local Disk and Normal Cloud Disk Only)") + flags.StringVar(&firewallId, "firewall-id", "", "Optional. Firewall Id, default: Web recommended firewall. see 'ucloud firewall list'.") + flags.StringSliceVar(&secGroupIds, "security-group-id", nil, "Optional. Security Group Id. Before using security group function, please confirm the account has such permission. When both firewall-id and security-group-id are set, the security-group-id will be ignored") + req.Tag = flags.String("group", "Default", "Optional. Business group") + req.IsolationGroup = flags.String("isolation-group", "", "Optional. Resource ID of isolation group. see 'ucloud uhost isolation-group list") + req.GpuType = flags.String("gpu-type", "", "Optional. The type of GPU instance. Required if defined the `machine-type` as 'G'. Accept values: 'K80','P40','V100','T4','T4S','T4A','2080Ti','2080Ti-4C','1080Ti','V100S','MI100','2080','2080TiS','2080TiPro','3090','A100','A800','3080Ti','4090','4090Pro','4090_48G','4090LD','MR-V100','MetaX-C500','H800','H20','H100','H200','5090','5090D','5090Pro'. Forward to https://docs.ucloud.cn/api/uhost-api/uhost_type for details.") + flags.StringVar(&userData, "user-data", "", "Optional. Conflicts with `user-data-base64`. ConCustomize the startup behaviors when launching the instance. Forward to https://docs.ucloud.cn/uhost/guide/metadata/userdata for details.") + flags.StringVar(&userDataBase64, "user-data-base64", "", "Optional. Conflicts with `user-data`. Customize the startup behaviors when launching the instance. The value must be base64-encode. Forward to https://docs.ucloud.cn/uhost/guide/metadata/userdata for details.") + + flags.MarkDeprecated("type", "please use --machine-type instead") + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic", "Trial") + command.SetFlagValues(cmd, "hot-plug", "true", "false") + command.SetFlagValues(cmd, "cpu", "1", "2", "4", "8", "12", "16", "24", "32", "64") + command.SetFlagValues(cmd, "type", "N2", "N1", "N3", "I2", "I1", "C1", "G1", "G2", "G3") + command.SetFlagValues(cmd, "machine-type", "N", "C", "G", "O", "OS") + command.SetFlagValues(cmd, "minimal-cpu-platform", "Intel/Auto", "Intel/IvyBridge", "Intel/Haswell", "Intel/Broadwell", "Intel/Skylake", "Intel/Cascadelake") + command.SetFlagValues(cmd, "net-capability", "Normal", "Super", "Ultra") + command.SetFlagValues(cmd, "os-disk-type", "LOCAL_NORMAL", "CLOUD_NORMAL", "LOCAL_SSD", "CLOUD_SSD", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK") + command.SetFlagValues(cmd, "os-disk-backup-type", "NONE", "DATAARK") + command.SetFlagValues(cmd, "data-disk-type", "LOCAL_NORMAL", "CLOUD_NORMAL", "LOCAL_SSD", "CLOUD_SSD", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK", "NONE") + command.SetFlagValues(cmd, "data-disk-backup-type", "NONE", "DATAARK") + command.SetFlagValues(cmd, "create-eip-line", "BGP", "International") + command.SetFlagValues(cmd, "create-eip-traffic-mode", "Bandwidth", "Traffic", "ShareBandwidth") + command.SetFlagValues(cmd, "gpu-type", "K80", "P40", "V100", "T4", "T4S", "T4A", "2080Ti", "2080Ti-4C", "1080Ti", "V100S", "MI100", "2080", "2080TiS", "2080TiPro", "3090", "A100", "A800", "3080Ti", "4090", "4090Pro", "4090_48G", "4090LD", "MR-V100", "MetaX-C500", "H800", "H20", "H100", "H200", "5090", "5090D", "5090Pro") + + command.SetCompletion(cmd, "image-id", func() []string { + return getImageList(ctx, []string{IMAGE_AVAILABLE}, IMAGE_BASE, *req.ProjectId, *req.Region, *req.Zone) + }) + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "bind-eip", func() []string { + return getAllEip(ctx, *req.ProjectId, *req.Region, []string{EIP_FREE}, nil) + }) + command.SetCompletion(cmd, "firewall-id", func() []string { + return getFirewallIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, *req.VPCId, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "isolation-group", func() []string { + return getIsolationGroupList(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("cpu") + cmd.MarkFlagRequired("memory-gb") + cmd.MarkFlagRequired("image-id") + + return cmd +} + +// createMultipleUhostWrapper handles UI + concurrency control for the batch-rename +// path. Mirrors cmd/uhost.go createMultipleUhostWrapper. +func createMultipleUhostWrapper(ctx *cli.Context, prog *cli.Progress, client *uhostsdk.UHostClient, unetClient *unet.UNetClient, req *uhostsdk.CreateUHostInstanceRequest, count int, updateEIPReq *unet.UpdateEIPAttributeRequest, bindEipIDs []string, async bool, retCh chan<- bool, wg *sync.WaitGroup, tokens chan struct{}, fc *failCounter, rc *resultCollector) { + //控制并发数量 + tokens <- struct{}{} + defer func() { + <-tokens + //设置延时,使报错能渲染出来 + time.Sleep(time.Second / 5) + wg.Done() + }() + + success, logs := createMultipleUhost(ctx, prog, client, unetClient, req, count, updateEIPReq, bindEipIDs, async, rc) + if !success { + fc.inc() + } + retCh <- success + logs = append(logs, fmt.Sprintf("result:%t", success)) + ctx.LogInfo(logs...) +} + +// createUhostWrapper handles UI + concurrency control for one uhost. Mirrors +// cmd/uhost.go createUhostWrapper. +func createUhostWrapper(ctx *cli.Context, prog *cli.Progress, client *uhostsdk.UHostClient, unetClient *unet.UNetClient, req *uhostsdk.CreateUHostInstanceRequest, updateEIPReq *unet.UpdateEIPAttributeRequest, bindEipID string, async bool, retCh chan<- bool, wg *sync.WaitGroup, tokens chan struct{}, idx int, fc *failCounter, rc *resultCollector) { + //控制并发数量 + tokens <- struct{}{} + defer func() { + <-tokens + //设置延时,使报错能渲染出来 + time.Sleep(time.Second / 5) + wg.Done() + }() + + success, logs := createUhost(ctx, prog, client, unetClient, req, updateEIPReq, bindEipID, async, rc) + if !success { + fc.inc() + } + retCh <- success + logs = append(logs, fmt.Sprintf("index:%d, result:%t", idx, success)) + ctx.LogInfo(logs...) +} + +func createMultipleUhost(ctx *cli.Context, prog *cli.Progress, client *uhostsdk.UHostClient, unetClient *unet.UNetClient, req *uhostsdk.CreateUHostInstanceRequest, count int, updateEIPReq *unet.UpdateEIPAttributeRequest, bindEipIDs []string, async bool, rc *resultCollector) (bool, []string) { + if req.MaxCount == nil { + req.MaxCount = sdk.Int(1) + } + req.MaxCount = sdk.Int(count) + + resp, err := client.CreateUHostInstance(req) + block := prog.NewBlock() + logs := []string{"=================================================="} + if err != nil { + logs = append(logs, fmt.Sprintf("err:%v", err)) + reportFail(ctx, prog, block, cli.ParseError(err)) + return false, logs + } + if len(bindEipIDs) > 0 && len(bindEipIDs) != count { + reportFail(ctx, prog, block, fmt.Sprintf("expect eip count %d, accept %d", count, len(bindEipIDs))) + return false, logs + } + + logs = append(logs, fmt.Sprintf("resp:%#v", resp)) + + if len(resp.UHostIds) != *req.MaxCount { + reportFail(ctx, prog, block, fmt.Sprintf("expect uhost count %d, accept %d", count, len(resp.UHostIds))) + return false, logs + } + for _, uhostID := range resp.UHostIds { + rc.add(cli.OpResultRow{ResourceID: uhostID, Action: "create", Status: "Initializing"}) + } + for i, uhostID := range resp.UHostIds { + block = prog.NewBlock() + + text := fmt.Sprintf("the uhost[%s]", uhostID) + if len(req.Disks) > 1 { + text = fmt.Sprintf("%s which attached a data disk", text) + if len(req.NetworkInterface) > 0 { + text = fmt.Sprintf("%s and binded an eip", text) + } + } else if len(req.NetworkInterface) > 0 { + text = fmt.Sprintf("%s which binded an eip", text) + } + text = fmt.Sprintf("%s is initializing", text) + + if async { + block.Append(text) + } else { + prog.Sspoll(sdescribeUHostByID(ctx), uhostID, text, []string{HOST_RUNNING, HOST_FAIL}, block, &req.CommonBase) + } + bindEipID := "" + if len(bindEipIDs) > i { + bindEipID = bindEipIDs[i] + } + + if bindEipID != "" { + eip := ctx.PickResourceID(bindEipID) + logs = append(logs, fmt.Sprintf("bind eip: %s", eip)) + eipLogs, err := sbindEIP(ctx, sdk.String(uhostID), sdk.String("uhost"), &eip, req.ProjectId, req.Region) + logs = append(logs, eipLogs...) + if err != nil { + reportFail(ctx, prog, block, fmt.Sprintf("bind eip[%s] with uhost[%s] failed: %v", eip, uhostID, err)) + return false, logs + } + block.Append(fmt.Sprintf("bind eip[%s] with uhost[%s] successfully", eip, uhostID)) + } else if len(req.NetworkInterface) > 0 { + ipSet, err := getEIPByUHostId(ctx, uhostID) + if err != nil { + reportFail(ctx, prog, block, err.Error()) + return false, logs + } + block.Append(fmt.Sprintf("IP:%s Line:%s", ipSet.IP, ipSet.Type)) + if *updateEIPReq.Name != "" || *updateEIPReq.Remark != "" { + var message string + if *updateEIPReq.Name != "" && *updateEIPReq.Remark != "" { + message = "name and remark" + } else if *updateEIPReq.Name != "" { + message = "name" + } else { + message = "remark" + } + + logs = append(logs, fmt.Sprintf("update attribute %s of eip[%s] binded uhost[%s]", message, ipSet.IPId, uhostID)) + updateEIPReq.EIPId = sdk.String(ipSet.IPId) + _, err = unetClient.UpdateEIPAttribute(updateEIPReq) + if err != nil { + reportFail(ctx, prog, block, fmt.Sprintf("update attribute %s of eip[%s] binded uhost[%s] got err, %s", message, ipSet.IPId, uhostID, err)) + return false, logs + } + block.Append(fmt.Sprintf("update attribute %s of eip[%s] binded uhost[%s] successfully", message, ipSet.IPId, uhostID)) + } + } + } + return true, logs +} + +func createUhost(ctx *cli.Context, prog *cli.Progress, client *uhostsdk.UHostClient, unetClient *unet.UNetClient, req *uhostsdk.CreateUHostInstanceRequest, updateEIPReq *unet.UpdateEIPAttributeRequest, bindEipID string, async bool, rc *resultCollector) (bool, []string) { + resp, err := client.CreateUHostInstance(req) + block := prog.NewBlock() + logs := []string{"=================================================="} + if err != nil { + logs = append(logs, fmt.Sprintf("err:%v", err)) + reportFail(ctx, prog, block, cli.ParseError(err)) + return false, logs + } + + logs = append(logs, fmt.Sprintf("resp:%#v", resp)) + if len(resp.UHostIds) != 1 { + reportFail(ctx, prog, block, fmt.Sprintf("expect uhost count 1 , accept %d", len(resp.UHostIds))) + return false, logs + } + rc.add(cli.OpResultRow{ResourceID: resp.UHostIds[0], Action: "create", Status: "Initializing"}) + text := fmt.Sprintf("the uhost[%s]", resp.UHostIds[0]) + if len(req.Disks) > 1 { + text = fmt.Sprintf("%s which attached a data disk", text) + if len(req.NetworkInterface) > 0 { + text = fmt.Sprintf("%s and binded an eip", text) + } + } else if len(req.NetworkInterface) > 0 { + text = fmt.Sprintf("%s which binded an eip", text) + } + text = fmt.Sprintf("%s is initializing", text) + + if async { + block.Append(text) + } else { + prog.Sspoll(sdescribeUHostByID(ctx), resp.UHostIds[0], text, []string{HOST_RUNNING, HOST_FAIL}, block, &req.CommonBase) + } + + if bindEipID != "" { + eip := ctx.PickResourceID(bindEipID) + logs = append(logs, fmt.Sprintf("bind eip: %s", eip)) + eipLogs, err := sbindEIP(ctx, sdk.String(resp.UHostIds[0]), sdk.String("uhost"), &eip, req.ProjectId, req.Region) + logs = append(logs, eipLogs...) + if err != nil { + reportFail(ctx, prog, block, fmt.Sprintf("bind eip[%s] with uhost[%s] failed: %v", eip, resp.UHostIds[0], err)) + return false, logs + } + block.Append(fmt.Sprintf("bind eip[%s] with uhost[%s] successfully", eip, resp.UHostIds[0])) + } else if len(req.NetworkInterface) > 0 { + ipSet, err := getEIPByUHostId(ctx, resp.UHostIds[0]) + if err != nil { + reportFail(ctx, prog, block, err.Error()) + return false, logs + } + block.Append(fmt.Sprintf("IP:%s Line:%s", ipSet.IP, ipSet.Type)) + if *updateEIPReq.Name != "" || *updateEIPReq.Remark != "" { + var message string + if *updateEIPReq.Name != "" && *updateEIPReq.Remark != "" { + message = "name and remark" + } else if *updateEIPReq.Name != "" { + message = "name" + } else { + message = "remark" + } + + logs = append(logs, fmt.Sprintf("update attribute %s of eip[%s] binded uhost[%s]", message, ipSet.IPId, resp.UHostIds[0])) + updateEIPReq.EIPId = sdk.String(ipSet.IPId) + _, err = unetClient.UpdateEIPAttribute(updateEIPReq) + if err != nil { + reportFail(ctx, prog, block, fmt.Sprintf("update attribute %s of eip[%s] binded uhost[%s] got err, %s", message, ipSet.IPId, resp.UHostIds[0], err)) + return false, logs + } + block.Append(fmt.Sprintf("update attribute %s of eip[%s] binded uhost[%s] successfully", message, ipSet.IPId, resp.UHostIds[0])) + } + } + return true, logs +} + +// getEIPByUHostId polls (up to 6 times) for a non-private EIP bound to the uhost. +// Ported verbatim from cmd/uhost.go getEIPByUHostId (base.BizClient → +// cli.NewServiceClient). +func getEIPByUHostId(ctx *cli.Context, uhostId string) (*uhostsdk.UHostIPSet, error) { + if uhostId == "" { + return nil, fmt.Errorf("the uhost[%s] is not found", uhostId) + } + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + for i := 0; i <= 5; i++ { + req := client.NewDescribeUHostInstanceRequest() + req.UHostIds = []string{uhostId} + + resp, err := client.DescribeUHostInstance(req) + if err != nil { + return nil, err + } + if len(resp.UHostSet) < 1 { + return nil, fmt.Errorf("the uhost[%s] is not found", uhostId) + } + + if len(resp.UHostSet[0].IPSet) > 0 { + for _, v := range resp.UHostSet[0].IPSet { + if v.Type != "Private" && v.IPId != "" { + return &v, nil + } + } + } + + time.Sleep(1 * time.Second) + } + + return nil, fmt.Errorf("can not get eip by uhost[%s]", uhostId) +} diff --git a/products/uhost/internal/uhost/create_image.go b/products/uhost/internal/uhost/create_image.go new file mode 100644 index 0000000000..71d2b9f14c --- /dev/null +++ b/products/uhost/internal/uhost/create_image.go @@ -0,0 +1,60 @@ +package uhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreateImage ucloud uhost create-image +func newCreateImage(ctx *cli.Context) *cobra.Command { + var async *bool + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewCreateCustomImageRequest() + cmd := &cobra.Command{ + Use: "create-image", + Short: "Create image from an uhost instance", + Long: "Create image from an uhost instance", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + req.UHostId = sdk.String(ctx.PickResourceID(*req.UHostId)) + resp, err := client.CreateCustomImage(req) + if err != nil { + ctx.HandleError(err) + return + } + // "iamge[%s] is making" typo preserved verbatim from cmd/uhost.go. + text := fmt.Sprintf("iamge[%s] is making", resp.ImageId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeImageByID(ctx, *req.ProjectId, *req.Region, *req.Zone)).Spoll(resp.ImageId, text, []string{IMAGE_AVAILABLE, IMAGE_UNAVAILABLE}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ImageId, Action: "create", Status: "Making"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.UHostId = flags.String("uhost-id", "", "Resource ID of uhost to create image from") + req.ImageName = flags.String("image-name", "", "Required. Name of the image to create") + req.ImageDescription = flags.String("image-desc", "", "Optional. Description of the image to create") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) + }) + + cmd.MarkFlagRequired("uhost-id") + cmd.MarkFlagRequired("image-name") + return cmd +} diff --git a/products/uhost/internal/uhost/delete.go b/products/uhost/internal/uhost/delete.go new file mode 100644 index 0000000000..14573e4917 --- /dev/null +++ b/products/uhost/internal/uhost/delete.go @@ -0,0 +1,163 @@ +package uhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud uhost delete +func newDelete(ctx *cli.Context) *cobra.Command { + var uhostIDs *[]string + var isDestroy = sdk.Bool(false) + var yes *bool + var releaseEIP bool + var releaseUDisk bool + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewTerminateUHostInstanceRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete Uhost instance", + Long: "Delete Uhost instance", + // SilenceUsage: a delete that fails at runtime must not dump flag usage. + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ok, err := ctx.Confirm(*yes, "Are you sure you want to delete the host(s)?") + if err != nil { + return err + } + if !ok { + return nil + } + if *isDestroy { + req.Destroy = sdk.Int(1) + } else { + req.Destroy = sdk.Int(0) + } + req.ReleaseEIP = &releaseEIP + req.ReleaseUDisk = &releaseUDisk + reqs := make([]request.Common, len(*uhostIDs)) + for idx, id := range *uhostIDs { + _req := *req + id = ctx.PickResourceID(id) + _req.UHostId = sdk.String(id) + reqs[idx] = &_req + } + prog := ctx.NewProgress() + // count>5: ctx.ConcurrentAction shows an aggregate counter, so disable + // per-block animation here (mirrors cmd/util.go concurrentAction.Do + // calling ux.Doc.Disable()). + if len(reqs) > 5 { + prog.Disable() + } + fc := &failCounter{} + rc := &resultCollector{} + action := deleteUHost(ctx, prog, client, rc) + ctx.ConcurrentAction(reqs, 50, func(r request.Common) (bool, []string) { + ok, logs := action(r) + if !ok { + fc.inc() + } + return ok, logs + }) + ctx.EmitResult(rc.all()...) + if n := fc.count(); n > 0 { + return fmt.Errorf("%d of %d uhost delete operation(s) failed; see the error(s) above or logs in %s", n, len(reqs), ctx.LogFilePath()) + } + return nil + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "Requried. ResourceIDs(UhostIds) of the uhost instance") + // bindRegion/bindProjectID (cmd/uhost.go) → ctx.Bind*: register dynamic + // region/project completion (golden). --zone stays a raw flag (no completion), + // matching the original delete. + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.Zone = cmd.Flags().String("zone", "", "Optional. availability zone") + isDestroy = cmd.Flags().Bool("destroy", false, "Optional. false,the uhost instance will be thrown to UHost recycle if you have permission; true,the uhost instance will be deleted directly") + cmd.Flags().BoolVar(&releaseEIP, "release-eip", true, "Optional. false,Unbind EIP only; true, Unbind EIP and release it") + cmd.Flags().BoolVar(&releaseUDisk, "delete-cloud-disk", true, "Optional. false, detach cloud disk only; true, detach cloud disk and delete it") + yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + command.SetFlagValues(cmd, "destroy", "true", "false") + command.SetFlagValues(cmd, "release-eip", "true", "false") + command.SetFlagValues(cmd, "delete-cloud-disk", "true", "false") + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_RUNNING, HOST_STOPPED, HOST_FAIL}, *req.ProjectId, *req.Region, *req.Zone) + }) + cmd.MarkFlagRequired("uhost-id") + + return cmd +} + +// deleteUHost returns the per-uhost delete action for ctx.ConcurrentAction. +// Mirrors cmd/uhost.go deleteUHost (the "====" log-separator + LogInfo are added +// by ctx.ConcurrentAction, not here). The ToQueryMap request-log line is dropped +// (platform handler covers it). +func deleteUHost(ctx *cli.Context, prog *cli.Progress, client *uhostsdk.UHostClient, rc *resultCollector) func(request.Common) (bool, []string) { + return func(creq request.Common) (bool, []string) { + req := creq.(*uhostsdk.TerminateUHostInstanceRequest) + block := prog.NewBlock() + logs := []string{} + hostIns, err := sdescribeUHostByID(ctx)(*req.UHostId, nil) + if err != nil { + reportFail(ctx, prog, block, fmt.Sprintf("describe uhost[%s] failed: %s", *req.UHostId, cli.ParseError(err))) + logs = append(logs, fmt.Sprintf("describe uhost[%s] failed: %s", *req.UHostId, cli.ParseError(err))) + return false, logs + } + + if hostIns == nil { + reportFail(ctx, prog, block, fmt.Sprintf("uhost[%s] does not exist", *req.UHostId)) + logs = append(logs, fmt.Sprintf("uhost[%s] does not exist", *req.UHostId)) + return false, logs + } + + ins := hostIns.(*uhostsdk.UHostInstanceSet) + if ins.State == "Running" { + _req := client.NewStopUHostInstanceRequest() + _req.ProjectId = req.ProjectId + _req.Region = req.Region + _req.Zone = req.Zone + _req.UHostId = req.UHostId + stopUhostInsV2(ctx, prog, client, _req, false, block) + } + + resp, err := client.TerminateUHostInstance(req) + if err != nil { + reportFail(ctx, prog, block, cli.ParseError(err)) + logs = append(logs, fmt.Sprintf("delete uhost[%s] failed: %s", *req.UHostId, cli.ParseError(err))) + return false, logs + } + text := fmt.Sprintf("uhost[%s] deleted", resp.UHostId) + logs = append(logs, text) + block.Append(text) + rc.add(cli.OpResultRow{ResourceID: resp.UHostId, Action: "delete", Status: "Deleted"}) + return true, logs + } +} + +// stopUhostInsV2 is the concurrent (block-based) stop used by delete. Mirrors +// cmd/uhost.go stopUhostInsV2. +func stopUhostInsV2(ctx *cli.Context, prog *cli.Progress, client *uhostsdk.UHostClient, req *uhostsdk.StopUHostInstanceRequest, async bool, block *cli.Block) { + resp, err := client.StopUHostInstance(req) + if err != nil { + block.Append(cli.ParseError(err)) + return + } + + text := fmt.Sprintf("uhost[%v] is shutting down", resp.UHostId) + if async { + block.Append(text) + } else { + prog.Sspoll(sdescribeUHostByID(ctx), resp.UHostId, text, []string{HOST_STOPPED, HOST_FAIL}, block, nil) + } +} diff --git a/products/uhost/internal/uhost/describe.go b/products/uhost/internal/uhost/describe.go new file mode 100644 index 0000000000..ef334cc19f --- /dev/null +++ b/products/uhost/internal/uhost/describe.go @@ -0,0 +1,116 @@ +package uhost + +import ( + "fmt" + + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// describeUHostByID mirrors cmd/uhost.go's describeUHostByID (the REGION-aware, +// ERROR-on-not-found variant): it binds projectID/region/zone into the request +// and returns an error (not nil) when the uhost does not exist. The closure +// signature carries a *request.CommonBase only to satisfy ctx.PollerTo's +// describe-func type — it is intentionally ignored, because region/project/zone +// come from the bound args (this is what the sequential pollers and the direct +// resize/reset-password/checkAndCloseUhost/reinstall/leave-isolation callers +// passed at BASE via the 4-arg describe / Poll(id,proj,region,zone)). Returns +// *uhostsdk.UHostInstanceSet. +func describeUHostByID(ctx *cli.Context, projectID, region, zone string) func(uhostID string, commonBase *request.CommonBase) (interface{}, error) { + return func(uhostID string, _ *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeUHostInstanceRequest() + req.UHostIds = []string{uhostID} + req.ProjectId = &projectID + req.Region = ®ion + req.Zone = &zone + resp, err := client.DescribeUHostInstance(req) + if err != nil { + return nil, err + } + if len(resp.UHostSet) < 1 { + return nil, fmt.Errorf("uhost [%s] does not exist", uhostID) + } + return &resp.UHostSet[0], nil + } +} + +// sdescribeUHostByID mirrors cmd/uhost.go's sdescribeUHostByID (the concurrent +// SPOLLER variant): nil-on-not-found and CommonBase-aware (a non-nil commonBase +// carries region/project/zone; nil falls back to the client's default-config +// region, which the SDK marshaler fills when the request region is empty). Used +// by the concurrent create/delete-stop Sspoll path and deleteUHost's lookup — +// the exact sites that used sdescribeUHostByID at BASE. Returns +// *uhostsdk.UHostInstanceSet. +func sdescribeUHostByID(ctx *cli.Context) func(uhostID string, commonBase *request.CommonBase) (interface{}, error) { + return func(uhostID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeUHostInstanceRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.UHostIds = []string{uhostID} + resp, err := client.DescribeUHostInstance(req) + if err != nil { + return nil, err + } + if len(resp.UHostSet) < 1 { + return nil, nil + } + return &resp.UHostSet[0], nil + } +} + +// describeUdiskByID returns the poller's describe func for udisk, used by +// detachUdisk. Copied self-contained from cmd/disk_compat.go (base.BizClient → +// cli.NewServiceClient). +func describeUdiskByID(ctx *cli.Context) func(udiskID string, commonBase *request.CommonBase) (interface{}, error) { + return func(udiskID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewDescribeUDiskRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.UDiskId = sdk.String(udiskID) + req.Limit = sdk.Int(50) + resp, err := client.DescribeUDisk(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) < 1 { + return nil, nil + } + return &resp.DataSet[0], nil + } +} + +// describeImageByID returns the image-feature-probe describe func, closing over +// ctx + project/region/zone. Copied self-contained from cmd/image_compat.go; +// used by create to probe an image's HotPlug/CloudInit features and by +// create-image's poller. Returns *uhostsdk.UHostImageSet. +func describeImageByID(ctx *cli.Context, project, region, zone string) func(imageID string, commonBase *request.CommonBase) (interface{}, error) { + return func(imageID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeImageRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.ImageId = sdk.String(imageID) + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + req.Limit = sdk.Int(50) + resp, err := client.DescribeImage(req) + if err != nil { + return nil, err + } + if len(resp.ImageSet) < 1 { + return nil, nil + } + return &resp.ImageSet[0], nil + } +} diff --git a/products/uhost/internal/uhost/eip.go b/products/uhost/internal/uhost/eip.go new file mode 100644 index 0000000000..0b45bdc88d --- /dev/null +++ b/products/uhost/internal/uhost/eip.go @@ -0,0 +1,98 @@ +package uhost + +import ( + "fmt" + "net" + "strings" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// getEIPLine returns the default EIP line for a region. Product-local copy of +// cmd/util.go getEIPLine (domain logic, D-D: COPIED into the product, never +// promoted to platform). "cn" regions default to BGP, others to International. +func getEIPLine(region string) (line string) { + if strings.HasPrefix(region, "cn") { + line = "BGP" + } else { + line = "International" + } + return +} + +// getEIPIDbyIP resolves an EIP id from an IP address within project/region. +// Copied self-contained from cmd/eip_compat.go (base.BizClient → +// cli.NewServiceClient) so sbindEIP can accept an IP literal. +func getEIPIDbyIP(ctx *cli.Context, ip net.IP, projectID, region string) (string, error) { + eipList, err := fetchAllEip(ctx, projectID, region) + if err != nil { + return "", err + } + for _, eip := range eipList { + for _, addr := range eip.EIPAddr { + if addr.IP == ip.String() { + return eip.EIPId, nil + } + } + } + return "", fmt.Errorf("IP[%s] not exist", ip.String()) +} + +// fetchAllEip lists all EIPs in project/region, paging by 100. Copied +// self-contained from cmd/eip_compat.go (base.BizClient → cli.NewServiceClient). +func fetchAllEip(ctx *cli.Context, projectID, region string) ([]unet.UnetEIPSet, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeEIPRequest() + list := []unet.UnetEIPSet{} + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + for offset, step := 0, 100; ; offset += step { + req.Offset = &offset + req.Limit = &step + resp, err := client.DescribeEIP(req) + if err != nil { + return nil, err + } + for i, size := 0, len(resp.EIPSet); i < size; i++ { + list = append(list, resp.EIPSet[i]) + } + if resp.TotalCount <= offset+step { + break + } + } + return list, nil +} + +// sbindEIP binds an EIP to a resource, returning a log trail instead of printing +// (used for the concurrent create flow). Copied self-contained from +// cmd/eip_compat.go; the base.ToQueryMap request-log line is dropped (platform +// SDK handler logs requests now, D-C). +func sbindEIP(ctx *cli.Context, resourceID, resourceType, eipID, projectID, region *string) ([]string, error) { + logs := make([]string, 0) + ip := net.ParseIP(*eipID) + if ip != nil { + id, err := getEIPIDbyIP(ctx, ip, *projectID, *region) + if err != nil { + ctx.HandleError(err) + } else { + *eipID = id + } + } + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewBindEIPRequest() + req.ResourceId = resourceID + req.ResourceType = resourceType + req.EIPId = sdk.String(ctx.PickResourceID(*eipID)) + req.ProjectId = sdk.String(ctx.PickResourceID(*projectID)) + req.Region = region + _, err := client.BindEIP(req) + if err != nil { + logs = append(logs, fmt.Sprintf("bind eip failed: %v", err)) + return logs, err + } + logs = append(logs, fmt.Sprintf("bind eip[%s] with %s[%s] successfully", *req.EIPId, *req.ResourceType, *req.ResourceId)) + return logs, nil +} diff --git a/products/uhost/internal/uhost/isolation_group.go b/products/uhost/internal/uhost/isolation_group.go new file mode 100644 index 0000000000..8912518b64 --- /dev/null +++ b/products/uhost/internal/uhost/isolation_group.go @@ -0,0 +1,21 @@ +package uhost + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newIsolationGroup ucloud uhost isolation-group +// Mirrors cmd/uhost.go NewCmdIsolation (AddCommand order: list, create, delete). +func newIsolationGroup(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "isolation-group", + Short: "List and manipulate isolation group of uhost", + Long: "List and manipulate isolation group of uhost", + } + cmd.AddCommand(newIsolationList(ctx)) + cmd.AddCommand(newIsolationCreate(ctx)) + cmd.AddCommand(newIsolationDelete(ctx)) + return cmd +} diff --git a/products/uhost/internal/uhost/isolation_group_create.go b/products/uhost/internal/uhost/isolation_group_create.go new file mode 100644 index 0000000000..25821162aa --- /dev/null +++ b/products/uhost/internal/uhost/isolation_group_create.go @@ -0,0 +1,47 @@ +package uhost + +import ( + "fmt" + "regexp" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newIsolationCreate ucloud uhost isolation-group create +func newIsolationCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewCreateIsolationGroupRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create isolation group instance", + Long: "Create isolation group instance", + Run: func(c *cobra.Command, args []string) { + re := regexp.MustCompile(REGEXP_NAME) + if !re.Match([]byte(*req.GroupName)) { + ctx.LogError(fmt.Sprintf("group-name %s is invalid! Length 1~63, only English,Chinese,number and '-_.' are allowed", *req.GroupName)) + return + } + resp, err := client.CreateIsolationGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "isolation group %s created\n", resp.GroupId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.GroupId, Action: "create", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.GroupName = flags.String("group-name", "", "Required. Name of isolation group. Length 1~63, only English,Chinese,number and '-_.' are allowed") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.Remark = flags.String("remark", "", "Optional. Remark ok isolation group") + + cmd.MarkFlagRequired("group-name") + return cmd +} diff --git a/products/uhost/internal/uhost/isolation_group_delete.go b/products/uhost/internal/uhost/isolation_group_delete.go new file mode 100644 index 0000000000..1efbe340e9 --- /dev/null +++ b/products/uhost/internal/uhost/isolation_group_delete.go @@ -0,0 +1,51 @@ +package uhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newIsolationDelete ucloud uhost isolation-group delete +func newIsolationDelete(ctx *cli.Context) *cobra.Command { + var ids []string + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDeleteIsolationGroupRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete isolation group instances", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range ids { + id := ctx.PickResourceID(idname) + req.GroupId = &id + _, err := client.DeleteIsolationGroup(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "isolation group %s deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&ids, "group-id", nil, "Required. Resource ID of isolation groups to be deleted") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("group-id") + command.SetCompletion(cmd, "group-id", func() []string { + return getIsolationGroupList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/uhost/internal/uhost/isolation_group_list.go b/products/uhost/internal/uhost/isolation_group_list.go new file mode 100644 index 0000000000..590f66e77d --- /dev/null +++ b/products/uhost/internal/uhost/isolation_group_list.go @@ -0,0 +1,59 @@ +package uhost + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newIsolationList ucloud uhost isolation-group list +func newIsolationList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeIsolationGroupRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List isolation group of uhost", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeIsolationGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + var list []isolationGroupRow + for _, group := range resp.IsolationGroupSet { + row := isolationGroupRow{ + ResourceID: group.GroupId, + Name: group.GroupName, + Remark: group.Remark, + } + var zones []string + for _, item := range group.SpreadInfoSet { + zones = append(zones, fmt.Sprintf("%s:%d", item.Zone, item.UHostCount)) + } + row.UHostCount = strings.Join(zones, " ") + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.GroupId = flags.String("group-id", "", "Optional. Resource ID of isolation group to describe") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + ctx.BindLimit(cmd, req) + ctx.BindOffset(cmd, req) + + command.SetCompletion(cmd, "group-id", func() []string { + return getIsolationGroupList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/uhost/internal/uhost/leave_isolation_group.go b/products/uhost/internal/uhost/leave_isolation_group.go new file mode 100644 index 0000000000..1f9d746513 --- /dev/null +++ b/products/uhost/internal/uhost/leave_isolation_group.go @@ -0,0 +1,65 @@ +package uhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newLeaveIsolationGroup ucloud uhost leave-isolation-group +func newLeaveIsolationGroup(ctx *cli.Context) *cobra.Command { + var uhostIds []string + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewLeaveIsolationGroupRequest() + cmd := &cobra.Command{ + Use: "leave-isolation-group", + Short: "Detach uhost from its isolation group", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range uhostIds { + id := ctx.PickResourceID(idname) + any, err := describeUHostByID(ctx, *req.ProjectId, *req.Region, *req.Zone)(id, nil) + if err != nil { + ctx.LogError(fmt.Sprintf("fetch uhost %s failed: %v", idname, err)) + continue + } + ins, ok := any.(*uhostsdk.UHostInstanceSet) + if !ok { + ctx.LogError(fmt.Sprintf("uhost %s may not exist", idname)) + continue + } + if ins.IsolationGroup == "" { + fmt.Fprintf(ctx.ProgressWriter(), "uhost %s doesn't attached any isolation group\n", idname) + continue + } + req.GroupId = sdk.String(ins.IsolationGroup) + req.UHostId = &id + _, err = client.LeaveIsolationGroup(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "uhost %s detached from isolation group %s\n", idname, ins.IsolationGroup) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "leave-isolation-group", Status: "Detached"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&uhostIds, "uhost-id", nil, "Required. Resource ID of uhosts to be detech from its isolation group") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + ctx.BindZone(cmd, req) + cmd.MarkFlagRequired("uhost-id") + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, nil, *req.ProjectId, *req.Region, *req.Zone) + }) + return cmd +} diff --git a/products/uhost/internal/uhost/list.go b/products/uhost/internal/uhost/list.go new file mode 100644 index 0000000000..4b8e486fd0 --- /dev/null +++ b/products/uhost/internal/uhost/list.go @@ -0,0 +1,248 @@ +package uhost + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + sdkerror "github.com/ucloud/ucloud-sdk-go/ucloud/error" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList ucloud uhost list +func newList(ctx *cli.Context) *cobra.Command { + var allRegion, pageOff, idOnly bool + var uhostIds []string + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeUHostInstanceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List all UHost Instances", + Long: `List all UHost Instances`, + Run: func(cmd *cobra.Command, args []string) { + *req.VPCId = ctx.PickResourceID(*req.VPCId) + *req.SubnetId = ctx.PickResourceID(*req.SubnetId) + *req.IsolationGroup = ctx.PickResourceID(*req.IsolationGroup) + for _, uhost := range uhostIds { + req.UHostIds = append(req.UHostIds, ctx.PickResourceID(uhost)) + } + + uhosts, err := getAllUHosts(ctx, client, req, pageOff, allRegion) + if err != nil { + ctx.HandleError(err) + return + } + if idOnly { + listUhostID(ctx, uhosts) + } else { + listUhost(ctx, uhosts, allRegion) + } + }, + } + cmd.Flags().SortFlags = false + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region.") + req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") + req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0") + req.Limit = cmd.Flags().Int("limit", 50, "Optional. Limit default 50, max value 100") + req.VPCId = cmd.Flags().String("vpc-id", "", "Optional. Resource ID of VPC. List uhost instances of the specified VPC") + req.SubnetId = cmd.Flags().String("subnet-id", "", "Optional. Resource ID of Subnet. List uhost instances of the specified Subnet") + req.IsolationGroup = cmd.Flags().String("isolation-group", "", "Optional. Resource ID of isolation group. List uhost instances of the specified isolation group") + cmd.Flags().StringSliceVar(&uhostIds, "uhost-id", make([]string, 0), "Optional. Resource ID of uhost instances, multiple values separated by comma(without space)") + cmd.Flags().BoolVar(&allRegion, "all-region", false, "Optional. Accpet values: true or false. List uhost instances of all regions when assigned true") + cmd.Flags().BoolVar(&pageOff, "page-off", false, "Optional. Paging or not. If all-region is specified this flag will be true. Accept values: true or false. If assigned, the limit flag will be disabled and list all uhost instances") + cmd.Flags().BoolVar(&idOnly, "uhost-id-only", false, "Optional. Just display resource id of uhost") + ctx.BindGroup(cmd, req) + + command.SetFlagValues(cmd, "page-off", "true", "false") + command.SetFlagValues(cmd, "uhost-id-only", "true", "false") + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { + return ctx.ZoneList(req.GetRegion()) + }) + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, *req.VPCId, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "isolation-group", func() []string { + return getIsolationGroupList(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, nil, *req.ProjectId, *req.Region, *req.Zone) + }) + + return cmd +} + +// listUhost renders the uhost slice via ctx.PrintList, selecting columns per +// output mode using the per-mode row structs (rows.go). AWS-style: --output +// selects only the format — table shows curated columns (uhostRowDefault, or +// uhostRowAllRegion with a trailing Zone under --all-region); json/yaml always +// emit the full uhostRow so no field is lost (e.g. DiskSet/VPC/Subnet). +func listUhost(ctx *cli.Context, uhosts []uhostsdk.UHostInstanceSet, listAllRegion bool) { + list := make([]uhostRow, 0) + for _, host := range uhosts { + row := uhostRow{} + row.UHostName = host.Name + row.Remark = host.Remark + row.ResourceID = host.UHostId + row.Group = host.Tag + for _, ip := range host.IPSet { + if row.PublicIP != "" { + row.PublicIP += " | " + } + if ip.Type == "Private" { + row.PrivateIP = ip.IP + row.VPC = ip.VPCId + row.Subnet = ip.SubnetId + } else { + row.PublicIP += fmt.Sprintf("%s", ip.IP) + } + } + cupCore := host.CPU + memorySize := host.Memory / 1024 + diskSize := 0 + var disks []string + for _, disk := range host.DiskSet { + if disk.Type == "Data" || disk.Type == "Udisk" { + diskSize += disk.Size + } + disks = append(disks, fmt.Sprintf("%s:%s:%dG", disk.Type, disk.DiskType, disk.Size)) + } + row.Zone = host.Zone + row.DiskSet = strings.Join(disks, "|") + row.Config = fmt.Sprintf("cpu:%d memory:%dG disk:%dG", cupCore, memorySize, diskSize) + row.Image = fmt.Sprintf("%s|%s", host.BasicImageId, host.BasicImageName) + row.CreationTime = common.FormatDate(host.CreateTime) + row.State = host.State + row.Type = host.MachineType + "/" + host.HostType + if host.HotplugFeature { + row.Type += "/HotPlug" + } + list = append(list, row) + } + + // JSON/YAML mode: print the full row set (matches cmd/uhost.go, which + // marshalled the full UHostRow slice in --json mode). ctx.PrintList routes + // json/yaml by format; for table mode we narrow to the per-mode struct. + if ctx.Format() != cli.OutputTable { + ctx.PrintList(list) + return + } + + if listAllRegion { + rows := make([]uhostRowAllRegion, 0, len(list)) + for _, r := range list { + rows = append(rows, uhostRowAllRegion{ + UHostName: r.UHostName, ResourceID: r.ResourceID, Group: r.Group, + PrivateIP: r.PrivateIP, PublicIP: r.PublicIP, Config: r.Config, + Image: r.Image, Type: r.Type, State: r.State, + CreationTime: r.CreationTime, Zone: r.Zone, + }) + } + ctx.PrintList(rows) + return + } + rows := make([]uhostRowDefault, 0, len(list)) + for _, r := range list { + rows = append(rows, uhostRowDefault{ + UHostName: r.UHostName, ResourceID: r.ResourceID, Group: r.Group, + PrivateIP: r.PrivateIP, PublicIP: r.PublicIP, Config: r.Config, + Image: r.Image, Type: r.Type, State: r.State, CreationTime: r.CreationTime, + }) + } + ctx.PrintList(rows) +} + +func listUhostID(ctx *cli.Context, uhosts []uhostsdk.UHostInstanceSet) { + ids := make([]string, 0) + for _, u := range uhosts { + ids = append(ids, u.UHostId) + } + // The id list IS the result of --uhost-id-only, not narration: write it to + // stdout (ctx.Out), never ProgressWriter — otherwise in non-TTY/json mode the + // ids go to stderr and `ids=$(ucloud uhost list --uhost-id-only)` captures + // nothing. + fmt.Fprintln(ctx.Out(), strings.Join(ids, ",")) +} + +func fetchUHosts(client *uhostsdk.UHostClient, req *uhostsdk.DescribeUHostInstanceRequest) ([]uhostsdk.UHostInstanceSet, int, error) { + resp, err := client.DescribeUHostInstance(req) + if err != nil { + return nil, 0, err + } + return resp.UHostSet, resp.TotalCount, nil +} + +func fetchUHostsPageOff(client *uhostsdk.UHostClient, req *uhostsdk.DescribeUHostInstanceRequest) ([]uhostsdk.UHostInstanceSet, error) { + _req := *req + result := make([]uhostsdk.UHostInstanceSet, 0) + for limit, offset := 50, 0; ; offset += limit { + _req.Offset = sdk.Int(offset) + _req.Limit = sdk.Int(limit) + uhosts, total, err := fetchUHosts(client, &_req) + if err != nil { + return nil, err + } + result = append(result, uhosts...) + if offset+limit >= total { + break + } + } + return result, nil +} + +func getAllUHosts(ctx *cli.Context, client *uhostsdk.UHostClient, req *uhostsdk.DescribeUHostInstanceRequest, pageOff bool, allRegion bool) ([]uhostsdk.UHostInstanceSet, error) { + if allRegion { + result := make([]uhostsdk.UHostInstanceSet, 0) + regions, err := ctx.AllRegions() + if err != nil { + return nil, err + } + for _, region := range regions { + _req := *req + _req.Region = sdk.String(region) + //如果要获取所有region的主机,则不分页 + uhosts, err := fetchUHostsPageOff(client, &_req) + // Has no permission in current region for UHost + if e, ok := err.(sdkerror.Error); ok && e.Code() == _RetCodeRegionNoPermission { + continue + } + if err != nil { + return nil, err + } + result = append(result, uhosts...) + } + return result, nil + } + + if pageOff { + _req := *req + uhosts, err := fetchUHostsPageOff(client, &_req) + if err != nil { + return nil, err + } + return uhosts, nil + } + + uhosts, _, err := fetchUHosts(client, req) + if err != nil { + return nil, err + } + return uhosts, nil +} + +// _RetCodeRegionNoPermission is the SDK RetCode returned when the account has no +// permission for UHost in a region; the --all-region path skips such regions. +// Verbatim from cmd/uhost.go. +const _RetCodeRegionNoPermission = 230 diff --git a/products/uhost/internal/uhost/poll.go b/products/uhost/internal/uhost/poll.go new file mode 100644 index 0000000000..d08e55d816 --- /dev/null +++ b/products/uhost/internal/uhost/poll.go @@ -0,0 +1,71 @@ +package uhost + +import ( + "errors" + "fmt" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +var errStopDeclined = errors.New("skip, you do not agree to stop uhost") + +type stopUhostResult struct { + requested bool + stopped bool +} + +// stopUhostIns stops a uhost and (unless async) polls it to Stopped. Mirrors +// cmd/uhost.go stopUhostIns (sequential base.NewPoller → ctx.PollerTo.Spoll). +func stopUhostIns(ctx *cli.Context, client *uhostsdk.UHostClient, req *uhostsdk.StopUHostInstanceRequest, async bool) stopUhostResult { + w := ctx.ProgressWriter() + resp, err := client.StopUHostInstance(req) + if err != nil { + ctx.HandleError(err) + return stopUhostResult{} + } + + text := fmt.Sprintf("uhost[%v] is shutting down", resp.UHostId) + if async { + fmt.Fprintln(w, text) + return stopUhostResult{requested: true} + } + // base.Poller.Poll returned a bool (reached target state) that cmd/uhost.go + // fed back into resize (inst.State = Stopped). The platform Spoll narrates to + // the writer but returns nothing, so a successful synchronous stop request + // that we then polled is treated as "stopped" for the resize state-transition, + // which matches the original intent. + ctx.PollerTo(w, describeUHostByID(ctx, *req.ProjectId, *req.Region, *req.Zone)).Spoll(resp.UHostId, text, []string{HOST_STOPPED, HOST_FAIL}) + return stopUhostResult{requested: true, stopped: true} +} + +// checkAndCloseUhost stops the uhost (with optional prompt) if it is running. +// Mirrors cmd/uhost.go checkAndCloseUhost. +func checkAndCloseUhost(ctx *cli.Context, client *uhostsdk.UHostClient, yes, async bool, uhostID, project, region, zone string) error { + host, err := describeUHostByID(ctx, project, region, zone)(uhostID, nil) + if err != nil { + return err + } + inst, ok := host.(*uhostsdk.UHostInstanceSet) + if ok { + if inst.State == "Running" { + ok, err := ctx.Confirm(yes, fmt.Sprintf("uhost[%s] will be stopped, can we do this?", uhostID)) + if err != nil { + return err + } + if !ok { + return errStopDeclined + } + _req := client.NewStopUHostInstanceRequest() + _req.ProjectId = &project + _req.Region = ®ion + _req.Zone = &zone + _req.UHostId = &uhostID + stopUhostIns(ctx, client, _req, async) + } + } else { + return fmt.Errorf("Something wrong, uhost[%s] may not exist", uhostID) + } + return nil +} diff --git a/products/uhost/internal/uhost/poweroff.go b/products/uhost/internal/uhost/poweroff.go new file mode 100644 index 0000000000..9d07ac6c0a --- /dev/null +++ b/products/uhost/internal/uhost/poweroff.go @@ -0,0 +1,64 @@ +package uhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newPoweroff ucloud uhost poweroff +func newPoweroff(ctx *cli.Context) *cobra.Command { + var yes *bool + var uhostIDs *[]string + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewPoweroffUHostInstanceRequest() + cmd := &cobra.Command{ + Use: "poweroff", + Short: "Analog power off Uhost instnace", + Long: "Analog power off Uhost instnace", + Example: "ucloud uhost poweroff --uhost-id uhost-xxx1,uhost-xxx2", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + confirmText := "Danger, it may affect data integrity. Are you sure you want to poweroff this uhost?" + if len(*uhostIDs) > 1 { + confirmText = "Danger, it may affect data integrity. Are you sure you want to poweroff those uhosts?" + } + ok, err := ctx.Confirm(*yes, confirmText) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + for _, id := range *uhostIDs { + id = ctx.PickResourceID(id) + req.UHostId = &id + resp, err := client.PoweroffUHostInstance(req) + if err != nil { + ctx.HandleError(err) + } else { + fmt.Fprintf(w, "uhost[%v] is power off\n", resp.UHostId) + } + } + }, + } + cmd.Flags().SortFlags = false + uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "ResourceIDs(UHostIds) of the uhost instance") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Assign region") + req.Zone = cmd.Flags().String("zone", "", "Assign availability zone") + yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_FAIL, HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) + }) + cmd.MarkFlagRequired("uhost-id") + + return cmd +} diff --git a/products/uhost/internal/uhost/progress.go b/products/uhost/internal/uhost/progress.go new file mode 100644 index 0000000000..d173431384 --- /dev/null +++ b/products/uhost/internal/uhost/progress.go @@ -0,0 +1,62 @@ +package uhost + +import ( + "fmt" + "sync" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// failCounter is a concurrency-safe tally of failed create/delete operations, so +// RunE can return a non-zero exit when any item fails (aws/gcloud convention: a +// failed command exits non-zero, not 0). +type failCounter struct { + mu sync.Mutex + n int +} + +func (f *failCounter) inc() { + f.mu.Lock() + f.n++ + f.mu.Unlock() +} + +func (f *failCounter) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.n +} + +// resultCollector is a concurrency-safe accumulator of structured operation +// rows, so uhost create/delete (which narrate via the progress block, not +// PrintList) can still emit machine-readable results in --output json/yaml mode +// like the other write commands. +type resultCollector struct { + mu sync.Mutex + rows []cli.OpResultRow +} + +func (rc *resultCollector) add(rows ...cli.OpResultRow) { + rc.mu.Lock() + rc.rows = append(rc.rows, rows...) + rc.mu.Unlock() +} + +func (rc *resultCollector) all() []cli.OpResultRow { + rc.mu.Lock() + defer rc.mu.Unlock() + return rc.rows +} + +// reportFail records a failure message: it appends to the progress block (shown +// on a TTY) and, when the block is NOT being animated (non-TTY writer, or the +// aggregate count>5 path), also writes the message to stderr so scripted/piped +// callers still see the error. Mirrors the aws/gcloud convention that command +// errors always reach stderr regardless of whether stdout is a terminal, while +// the spinner stays TTY-only. +func reportFail(ctx *cli.Context, prog *cli.Progress, block *cli.Block, msg string) { + block.Append(msg) + if !prog.Animated() { + fmt.Fprintln(ctx.Err(), msg) + } +} diff --git a/products/uhost/internal/uhost/reinstall_os.go b/products/uhost/internal/uhost/reinstall_os.go new file mode 100644 index 0000000000..c60f6d550a --- /dev/null +++ b/products/uhost/internal/uhost/reinstall_os.go @@ -0,0 +1,156 @@ +package uhost + +import ( + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + udisksdk "github.com/ucloud/ucloud-sdk-go/services/udisk" + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newReinstallOS ucloud uhost reinstall-os +func newReinstallOS(ctx *cli.Context) *cobra.Command { + var isReserveDataDisk, yes, async *bool + var password, keyPairId string + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewReinstallUHostInstanceRequest() + cmd := &cobra.Command{ + Use: "reinstall-os", + Short: "Reinstall the operating system of the UHost instance", + Long: "Reinstall the operating system of the UHost instance. we will detach all udisk disks if the uhost attached some, and then stop the uhost if it's running", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + if *isReserveDataDisk { + req.ReserveDisk = sdk.String("Yes") + } else { + req.ReserveDisk = sdk.String("No") + } + req.UHostId = sdk.String(ctx.PickResourceID(*req.UHostId)) + if len(password) > 0 { + req.LoginMode = sdk.String("Password") + req.KeyPairId = nil + req.Password = sdk.String(password) + } else if len(keyPairId) > 0 { + req.LoginMode = sdk.String("KeyPair") + req.KeyPairId = sdk.String(keyPairId) + req.Password = nil + } else { + ctx.HandleError(fmt.Errorf("password or key-pair-id is required")) + return + } + + any, err := describeUHostByID(ctx, *req.ProjectId, *req.Region, *req.Zone)(*req.UHostId, nil) + if err != nil { + ctx.HandleError(err) + return + } + uhostIns, ok := any.(*uhostsdk.UHostInstanceSet) + if ok { + for _, disk := range uhostIns.DiskSet { + if disk.Type == "Udisk" { + sure := false + if !*yes { + text := fmt.Sprintf("udisk[%s/%s] will be detached, can we do this?", disk.DiskId, disk.Name) + var cErr error + sure, cErr = ctx.Confirm(false, text) + if cErr != nil { + ctx.HandleError(cErr) + return + } + if !sure { + fmt.Fprintf(w, "you don't agree to detach udisk\n") + return + } + } + if *yes || sure { + err := detachUdisk(ctx, false, disk.DiskId, w) + if err != nil { + ctx.HandleError(err) + return + } + } + } + } + } else { + fmt.Fprintf(w, "Something wrong, uhost[%s] may not exist\n", *req.UHostId) + return + } + + err = checkAndCloseUhost(ctx, client, *yes, *async, *req.UHostId, *req.ProjectId, *req.Region, *req.Zone) + if err != nil { + if errors.Is(err, errStopDeclined) { + return + } + ctx.HandleError(err) + return + } + resp, err := client.ReinstallUHostInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("uhost[%s] is reinstalling OS", *req.UHostId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUHostByID(ctx, *req.ProjectId, *req.Region, *req.Zone)).Spoll(resp.UHostId, text, []string{HOST_RUNNING, HOST_FAIL}) + } + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.UHostId = flags.String("uhost-id", "", "Required. Resource ID of the uhost to reinstall operating system") + flags.StringVar(&password, "password", "", "Optional. Password of the uhost user(root/ubuntu)") + flags.StringVar(&keyPairId, "key-pair-id", "", "Optional. Resource ID of ssh key pair. See 'ucloud api --Action DescribeUHostKeyPairs' Where both password and key-pair-id are set, the key-pair-id is ignored") + req.ImageId = flags.String("image-id", "", "Optional. Resource ID the image to install. See 'ucloud image list'. Default is original image of the uhost") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + isReserveDataDisk = flags.Bool("keep-data-disk", false, "Keep data disk or not. If you keep data disk, you can't change OS type(Linux->Window,e.g.)") + yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + async = flags.BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) + }) + cmd.MarkFlagRequired("uhost-id") + return cmd +} + +// detachUdisk detaches a udisk from its uhost, narrating progress to out. +// Copied self-contained from cmd/disk_compat.go (base.BizClient → +// cli.NewServiceClient), used by reinstall-os before reinstalling the OS. +func detachUdisk(ctx *cli.Context, async bool, udiskID string, out io.Writer) error { + any, err := describeUdiskByID(ctx)(udiskID, nil) + if err != nil { + return err + } + if any == nil { + return fmt.Errorf("udisk[%v] is not exist", any) + } + ins, ok := any.(*udisksdk.UDiskDataSet) + if !ok { + return fmt.Errorf("%#v convert to udisk failed", any) + } + client := cli.NewServiceClient(ctx, udisksdk.NewClient) + req := client.NewDetachUDiskRequest() + req.UHostId = sdk.String(ins.UHostId) + req.UDiskId = sdk.String(udiskID) + resp, err := client.DetachUDisk(req) + if err != nil { + return err + } + text := fmt.Sprintf("udisk[%s] is detaching from uhost[%s]", resp.UDiskId, resp.UHostId) + if async { + fmt.Fprintln(out, text) + } else { + ctx.PollerTo(out, describeUdiskByID(ctx)).Spoll(udiskID, text, []string{DISK_AVAILABLE, DISK_FAILED}) + } + return nil +} diff --git a/products/uhost/internal/uhost/reset_password.go b/products/uhost/internal/uhost/reset_password.go new file mode 100644 index 0000000000..4ef3eb7e4d --- /dev/null +++ b/products/uhost/internal/uhost/reset_password.go @@ -0,0 +1,71 @@ +package uhost + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResetPassword ucloud uhost reset-password +func newResetPassword(ctx *cli.Context) *cobra.Command { + var yes *bool + var uhostIDs *[]string + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewResetUHostInstancePasswordRequest() + cmd := &cobra.Command{ + Use: "reset-password", + Short: "Reset the administrator password for the UHost instances.", + Long: "Reset the administrator password for the UHost instances.", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + for _, id := range *uhostIDs { + id = ctx.PickResourceID(id) + req.UHostId = &id + err := checkAndCloseUhost(ctx, client, *yes, false, id, *req.ProjectId, *req.Region, *req.Zone) + if err != nil { + if errors.Is(err, errStopDeclined) { + continue + } + ctx.HandleError(err) + continue + } + host, err := describeUHostByID(ctx, *req.ProjectId, *req.Region, *req.Zone)(id, nil) + inst, ok := host.(*uhostsdk.UHostInstanceSet) + if !ok { + return + } + if inst.BootDiskState == "Initializing" { + fmt.Fprintf(w, "uhost[%s] boot disk in initializing, wait 10 minutes\n", id) + return + } + resp, err := client.ResetUHostInstancePassword(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(w, "uhost[%s] reset password\n", resp.UHostId) + } + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + uhostIDs = flags.StringSlice("uhost-id", nil, "Required. Resource IDs of the uhosts to reset the administrator's password") + req.Password = flags.String("password", "", "Required. New Password") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) + }) + cmd.MarkFlagRequired("uhost-id") + cmd.MarkFlagRequired("password") + return cmd +} diff --git a/products/uhost/internal/uhost/resize.go b/products/uhost/internal/uhost/resize.go new file mode 100644 index 0000000000..136ac2cd05 --- /dev/null +++ b/products/uhost/internal/uhost/resize.go @@ -0,0 +1,229 @@ +package uhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResize ucloud uhost resize +func newResize(ctx *cli.Context) *cobra.Command { + var yes, async *bool + var bootDiskSize, dataDiskSize int + var dataDiskID string + var uhostIDs *[]string + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewResizeUHostInstanceRequest() + cmd := &cobra.Command{ + Use: "resize", + Short: "Resize uhost instance,such as cpu core count, memory size and disk size", + Long: "Resize uhost instance,such as cpu core count, memory size and disk size", + Example: "ucloud uhost resize --uhost-id uhost-xxx1,uhost-xxx2 --cpu 4 --memory-gb 8", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + if *req.CPU == 0 { + req.CPU = nil + } + if *req.Memory == 0 { + req.Memory = nil + } else { + *req.Memory *= 1024 + } + for _, id := range *uhostIDs { + id = ctx.PickResourceID(id) + req.UHostId = &id + host, err := describeUHostByID(ctx, *req.ProjectId, *req.Region, *req.Zone)(id, nil) + if err != nil { + ctx.HandleError(err) + return + } + inst := host.(*uhostsdk.UHostInstanceSet) + stopReq := client.NewStopUHostInstanceRequest() + stopReq.ProjectId = req.ProjectId + stopReq.Region = req.Region + stopReq.Zone = req.Zone + stopReq.UHostId = &id + confirmText := "Resize uhost must be done after the uhost is stopped. Do you want to stop this uhost?" + if req.CPU != nil || req.Memory != nil || *req.NetCapValue != 0 { + if inst.State == HOST_RUNNING { + stop, err := promptStopUhostIns(ctx, client, stopReq, *yes, confirmText) + if err != nil { + ctx.HandleError(err) + return + } + if !stop.proceed { + continue + } + if stop.stopped { + inst.State = HOST_STOPPED + } + } + resp, err := client.ResizeUHostInstance(req) + if err != nil { + ctx.HandleError(err) + } else { + text := fmt.Sprintf("uhost [%v] cpu, memory resize", resp.UHostId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUHostByID(ctx, *req.ProjectId, *req.Region, *req.Zone)).Spoll(resp.UHostId, text, []string{HOST_RUNNING, HOST_STOPPED, HOST_FAIL}) + } + } + } + + if dataDiskSize != 0 || bootDiskSize != 0 { + _req := client.NewResizeAttachedDiskRequest() + var bootDisk uhostsdk.UHostDiskSet + var dataDisks = map[string]uhostsdk.UHostDiskSet{} + for _, disk := range inst.DiskSet { + if disk.IsBoot == "True" { + bootDisk = disk + } else if disk.IsBoot == "False" { + dataDisks[disk.DiskId] = disk + } + } + if bootDiskSize != 0 { + if bootDiskSize <= bootDisk.Size { + ctx.LogError(fmt.Sprintf("Error, disk does not support shrinkage. current system-disk-size %dg", bootDisk.Size)) + continue + } else { + _req.DiskSpace = &bootDiskSize + _req.DiskId = &bootDisk.DiskId + } + err := resizeAttachedDisk(ctx, client, _req, inst, *yes, *async, confirmText) + if err != nil { + ctx.HandleError(err) + } + } + + if dataDiskSize != 0 { + var dataDisk uhostsdk.UHostDiskSet + if len(dataDisks) > 1 { + if dataDiskID == "" { + ctx.LogError(fmt.Sprintf("Error, the uhost %s have %d data disks. data-disk-id should be assigned", id, len(dataDisks))) + continue + } + var ok bool + dataDisk, ok = dataDisks[dataDiskID] + if !ok { + ctx.LogError(fmt.Sprintf("Error, the disk %s does not exist", dataDiskID)) + continue + } + } else if len(dataDisks) == 1 { + for _, disk := range dataDisks { + dataDisk = disk + } + } else if len(dataDisks) == 0 { + ctx.LogError(fmt.Sprintf("Error, the uhost %s have no data disk. data-disk-id should be assigned", id)) + continue + } + if dataDiskSize <= dataDisk.Size { + ctx.LogError(fmt.Sprintf("Error, disk does not support shrinkage. current data-disk-size %dg", dataDisk.Size)) + continue + } + _req.DiskSpace = &dataDiskSize + _req.DiskId = &dataDisk.DiskId + err := resizeAttachedDisk(ctx, client, _req, inst, *yes, *async, confirmText) + if err != nil { + ctx.HandleError(err) + } + } + } + } + }, + } + flags := cmd.Flags() + flags.SortFlags = false + uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "Required. ResourceIDs(or UhostIDs) of the uhost instances") + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + req.CPU = cmd.Flags().Int("cpu", 0, "Optional. The number of virtual CPU cores. Series1 {1, 2, 4, 8, 12, 16, 24, 32}. Series2 {1,2,4,8,16}") + req.Memory = cmd.Flags().Int("memory-gb", 0, "Optional. memory size. Unit: GB. Range: [1, 128], multiple of 2") + cmd.Flags().IntVar(&bootDiskSize, "system-disk-size-gb", 0, "Optional. System disk size, unit GB. Range[20,100]. Step 10. System disk does not support shrinkage") + cmd.Flags().IntVar(&dataDiskSize, "data-disk-size-gb", 0, "Optional. Data disk size,unit GB. Step 10. disk does not support shrinkage") + cmd.Flags().StringVar(&dataDiskID, "data-disk-id", "", "Optional. If the uhost specified has two or more data disks, this parameter should be assigned") + req.NetCapValue = cmd.Flags().Int("net-cap", 0, "Optional. NIC scale. 1,upgrade; 2,downgrade; 0,unchanged") + yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + async = cmd.Flags().BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_RUNNING, HOST_STOPPED, HOST_FAIL}, *req.ProjectId, *req.Region, *req.Zone) + }) + cmd.MarkFlagRequired("uhost-id") + return cmd +} + +// resizeAttachedDisk resizes a uhost's attached disk, stopping the uhost first +// if it is running. Mirrors cmd/uhost.go resizeAttachedDisk. +func resizeAttachedDisk(ctx *cli.Context, client *uhostsdk.UHostClient, req *uhostsdk.ResizeAttachedDiskRequest, host *uhostsdk.UHostInstanceSet, yes, async bool, promptText string) error { + w := ctx.ProgressWriter() + req.UHostId = &host.UHostId + if host.State == HOST_RUNNING { + proceed, err := tryStopUhost(ctx, client, req, host.UHostId, promptText, yes) + if err != nil { + return fmt.Errorf("try to stop uhost error :%w", err) + } + if !proceed { + return nil + } + } + req.DryRun = sdk.Bool(false) + _, err := client.ResizeAttachedDisk(req) + if err != nil { + return err + } + text := fmt.Sprintf("uhost [%s] disk [%s] resize", host.UHostId, *req.DiskId) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUHostByID(ctx, *req.ProjectId, *req.Region, *req.Zone)).Spoll(host.UHostId, text, []string{HOST_RUNNING, HOST_STOPPED, HOST_FAIL}) + } + return nil +} + +func tryStopUhost(ctx *cli.Context, client *uhostsdk.UHostClient, req *uhostsdk.ResizeAttachedDiskRequest, uhostID, promptText string, yes bool) (bool, error) { + req.DryRun = sdk.Bool(true) + resp, err := client.ResizeAttachedDisk(req) + if err != nil { + return false, err + } + if resp.NeedRestart { + stopReq := client.NewStopUHostInstanceRequest() + stopReq.UHostId = &uhostID + stopReq.ProjectId = req.ProjectId + stopReq.Region = req.Region + stopReq.Zone = req.Zone + stop, err := promptStopUhostIns(ctx, client, stopReq, yes, promptText) + if err != nil { + return false, err + } + return stop.proceed, nil + } + return true, nil +} + +type stopPromptResult struct { + proceed bool + stopped bool +} + +// promptStopUhostIns prompts (unless yes) then stops the uhost. proceed is false +// only when the user declined or StopUHostInstance failed. Resize prerequisites +// always wait for the stop; --async only controls the resize operation itself. +func promptStopUhostIns(ctx *cli.Context, client *uhostsdk.UHostClient, req *uhostsdk.StopUHostInstanceRequest, yes bool, promptText string) (stopPromptResult, error) { + ok, err := ctx.Confirm(yes, promptText) + if err != nil { + return stopPromptResult{}, err + } + if !ok { + return stopPromptResult{}, nil + } + stop := stopUhostIns(ctx, client, req, false) + return stopPromptResult{proceed: stop.requested, stopped: stop.stopped}, nil +} diff --git a/products/uhost/internal/uhost/restart.go b/products/uhost/internal/uhost/restart.go new file mode 100644 index 0000000000..0167b54f0d --- /dev/null +++ b/products/uhost/internal/uhost/restart.go @@ -0,0 +1,56 @@ +package uhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newReboot ucloud uhost restart +func newReboot(ctx *cli.Context) *cobra.Command { + var uhostIDs *[]string + var async *bool + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewRebootUHostInstanceRequest() + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart uhost instance", + Long: "Restart uhost instance", + Example: "ucloud uhost restart --uhost-id uhost-xxx1,uhost-xxx2", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + for _, id := range *uhostIDs { + id = ctx.PickResourceID(id) + req.UHostId = &id + resp, err := client.RebootUHostInstance(req) + if err != nil { + ctx.HandleError(err) + } else { + text := fmt.Sprintf("uhost[%v] is restarting", resp.UHostId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUHostByID(ctx, *req.ProjectId, *req.Region, *req.Zone)).Spoll(resp.UHostId, text, []string{HOST_RUNNING, HOST_FAIL}) + } + } + } + }, + } + cmd.Flags().SortFlags = false + uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "Required. ResourceIDs(UHostIds) of the uhost instance") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") + req.DiskPassword = cmd.Flags().String("disk-password", "", "Optional. Encrypted disk password") + async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_FAIL, HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) + }) + cmd.MarkFlagRequired("uhost-id") + return cmd +} diff --git a/products/uhost/internal/uhost/rows.go b/products/uhost/internal/uhost/rows.go new file mode 100644 index 0000000000..e615b0aac8 --- /dev/null +++ b/products/uhost/internal/uhost/rows.go @@ -0,0 +1,70 @@ +package uhost + +// rows.go holds the table-row structs for uhost list output. The platform +// printer (ctx.PrintList) derives table columns from a struct's exported fields +// in declaration order, so the original cmd/uhost.go listUhost column selection +// (which passed an explicit []string column list to base.PrintTable per output +// mode) is reproduced here as three per-mode structs with byte-identical field +// names+order. JSON output uses the full uhostRow (matching the original, which +// always marshalled the full UHostRow slice in --json mode). + +// uhostRow is the full row (wide mode + json). Field set+order is byte-identical +// to cmd/uhost.go UHostRow. +type uhostRow struct { + UHostName string + Remark string + ResourceID string + Group string + PrivateIP string + PublicIP string + Config string + DiskSet string + Zone string + Image string + VPC string + Subnet string + Type string + State string + CreationTime string +} + +// uhostRowDefault is the default (non-wide, non-all-region) column set: +// UHostName, ResourceID, Group, PrivateIP, PublicIP, Config, Image, Type, State, +// CreationTime — matching cmd/uhost.go listUhost's default cols verbatim. +type uhostRowDefault struct { + UHostName string + ResourceID string + Group string + PrivateIP string + PublicIP string + Config string + Image string + Type string + State string + CreationTime string +} + +// uhostRowAllRegion is the default column set plus a trailing Zone column, +// matching cmd/uhost.go listUhost when listAllRegion is true (cols = +// default cols + "Zone"). +type uhostRowAllRegion struct { + UHostName string + ResourceID string + Group string + PrivateIP string + PublicIP string + Config string + Image string + Type string + State string + CreationTime string + Zone string +} + +// isolationGroupRow mirrors cmd/uhost.go isolationGroupRow byte-for-byte. +type isolationGroupRow struct { + ResourceID string + Name string + Remark string + UHostCount string +} diff --git a/products/uhost/internal/uhost/start.go b/products/uhost/internal/uhost/start.go new file mode 100644 index 0000000000..cceff5bc3b --- /dev/null +++ b/products/uhost/internal/uhost/start.go @@ -0,0 +1,55 @@ +package uhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStart ucloud uhost start +func newStart(ctx *cli.Context) *cobra.Command { + var async *bool + var uhostIDs *[]string + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewStartUHostInstanceRequest() + cmd := &cobra.Command{ + Use: "start", + Short: "Start Uhost instance", + Long: "Start Uhost instance", + Example: "ucloud uhost start --uhost-id uhost-xxx1,uhost-xxx2", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + for _, id := range *uhostIDs { + id := ctx.PickResourceID(id) + req.UHostId = &id + resp, err := client.StartUHostInstance(req) + if err != nil { + ctx.HandleError(err) + } else { + text := fmt.Sprintf("uhost[%v] is starting", resp.UHostId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUHostByID(ctx, *req.ProjectId, *req.Region, *req.Zone)).Spoll(resp.UHostId, text, []string{HOST_RUNNING, HOST_FAIL}) + } + } + } + }, + } + cmd.Flags().SortFlags = false + uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "Requried. ResourceIDs(UHostIds) of the uhost instance") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") + async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_STOPPED}, *req.ProjectId, *req.Region, *req.Zone) + }) + cmd.MarkFlagRequired("uhost-id") + return cmd +} diff --git a/products/uhost/internal/uhost/status.go b/products/uhost/internal/uhost/status.go new file mode 100644 index 0000000000..1e115ad09f --- /dev/null +++ b/products/uhost/internal/uhost/status.go @@ -0,0 +1,23 @@ +package uhost + +// UHost-domain state/type constants plus constants this product depends on, +// product-owned copies (formerly model/status + model/cli; domain constants +// live with the product). REGEXP_NAME is the resource-name validation pattern +// (formerly model/cli). +const ( + HOST_RUNNING = "Running" + HOST_STOPPED = "Stopped" + HOST_FAIL = "Install Fail" + + IMAGE_AVAILABLE = "Available" + IMAGE_UNAVAILABLE = "Unavailable" + + DISK_AVAILABLE = "Available" + DISK_FAILED = "Failed" + + EIP_FREE = "free" + + IMAGE_BASE = "Base" + IMAGE_ALL = "*" + REGEXP_NAME = "^[A-Za-z0-9-_.一-龥]{1,63}$" +) diff --git a/products/uhost/internal/uhost/stop.go b/products/uhost/internal/uhost/stop.go new file mode 100644 index 0000000000..df7257ba75 --- /dev/null +++ b/products/uhost/internal/uhost/stop.go @@ -0,0 +1,43 @@ +package uhost + +import ( + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStop ucloud uhost stop +func newStop(ctx *cli.Context) *cobra.Command { + var uhostIDs *[]string + var async *bool + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewStopUHostInstanceRequest() + cmd := &cobra.Command{ + Use: "stop", + Short: "Shut down uhost instance", + Long: "Shut down uhost instance", + Example: "ucloud uhost stop --uhost-id uhost-xxx1,uhost-xxx2", + Run: func(cmd *cobra.Command, args []string) { + for _, id := range *uhostIDs { + id = ctx.PickResourceID(id) + req.UHostId = &id + stopUhostIns(ctx, client, req, *async) + } + }, + } + cmd.Flags().SortFlags = false + uhostIDs = cmd.Flags().StringSlice("uhost-id", nil, "Required. ResourceIDs(UHostIds) of the uhost instances") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") + async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + command.SetCompletion(cmd, "uhost-id", func() []string { + return getUhostList(ctx, []string{HOST_RUNNING}, *req.ProjectId, *req.Region, *req.Zone) + }) + cmd.MarkFlagRequired("uhost-id") + + return cmd +} diff --git a/products/uhost/product.go b/products/uhost/product.go new file mode 100644 index 0000000000..2e074b41cd --- /dev/null +++ b/products/uhost/product.go @@ -0,0 +1,20 @@ +package uhost + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internaluhost "github.com/ucloud/ucloud-cli/products/uhost/internal/uhost" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "uhost", Commands: []string{"uhost"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internaluhost.NewCommand(ctx)} +} diff --git a/products/uhost/product.yaml b/products/uhost/product.yaml new file mode 100644 index 0000000000..011194f096 --- /dev/null +++ b/products/uhost/product.yaml @@ -0,0 +1,6 @@ +name: uhost +owners: + - calmxkk +commands: + - uhost +enabled: true diff --git a/products/uhost/testdata/cmdtree.golden b/products/uhost/testdata/cmdtree.golden new file mode 100644 index 0000000000..463f9f4117 --- /dev/null +++ b/products/uhost/testdata/cmdtree.golden @@ -0,0 +1,161 @@ +ucloud uhost use=uhost short=List,create,delete,stop,restart,poweroff or resize UHost instance +ucloud uhost clone use=clone short=Create an uhost with the same configuration as another uhost, excluding bound eip and udisk + flag=async short= default=false required= + flag=key-pair-id short= default= required= + flag=name short= default= required= + flag=password short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=uhost-id short= default= required=true + flag=zone short= default= required= +ucloud uhost create use=create short=Create UHost instance + flag=async short= default=false required= + flag=bind-eip short= default=[] required= + flag=charge-type short= default=Month required= + flag=concurrent short= default=20 required= + flag=count short= default=1 required= + flag=cpu short= default=4 required=true + flag=create-eip-bandwidth-mb short= default=0 required= + flag=create-eip-line short= default= required= + flag=create-eip-name short= default= required= + flag=create-eip-remark short= default= required= + flag=create-eip-traffic-mode short= default=Bandwidth required= + flag=data-disk-backup-type short= default=NONE required= + flag=data-disk-size-gb short= default=20 required= + flag=data-disk-type short= default=CLOUD_SSD required= + flag=firewall-id short= default= required= + flag=gpu short= default=0 required= + flag=gpu-type short= default= required= + flag=group short= default=Default required= + flag=hot-plug short= default=true required= + flag=image-id short= default= required=true + flag=isolation-group short= default= required= + flag=key-pair-id short= default= required= + flag=machine-type short= default=O required= + flag=memory-gb short= default=8 required=true + flag=minimal-cpu-platform short= default= required= + flag=name short= default=UHost required= + flag=net-capability short= default=Normal required= + flag=os-disk-backup-type short= default=NONE required= + flag=os-disk-size-gb short= default=20 required= + flag=os-disk-type short= default=CLOUD_SSD required= + flag=password short= default= required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=security-group-id short= default=[] required= + flag=shared-bw-id short= default= required= + flag=subnet-id short= default= required= + flag=type short= default= required= + flag=user-data short= default= required= + flag=user-data-base64 short= default= required= + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud uhost create-image use=create-image short=Create image from an uhost instance + flag=async short=a default=false required= + flag=image-desc short= default= required= + flag=image-name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=uhost-id short= default= required=true + flag=zone short= default= required= +ucloud uhost delete use=delete short=Delete Uhost instance + flag=delete-cloud-disk short= default=true required= + flag=destroy short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=release-eip short= default=true required= + flag=uhost-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud uhost isolation-group use=isolation-group short=List and manipulate isolation group of uhost +ucloud uhost isolation-group create use=create short=Create isolation group instance + flag=group-name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= +ucloud uhost isolation-group delete use=delete short=Delete isolation group instances + flag=group-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uhost isolation-group list use=list short=List isolation group of uhost + flag=group-id short= default= required= + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uhost leave-isolation-group use=leave-isolation-group short=Detach uhost from its isolation group + flag=project-id short= default= required= + flag=region short= default= required= + flag=uhost-id short= default=[] required=true + flag=zone short= default= required= +ucloud uhost list use=list short=List all UHost Instances + flag=all-region short= default=false required= + flag=group short= default= required= + flag=isolation-group short= default= required= + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=page-off short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=subnet-id short= default= required= + flag=uhost-id short= default=[] required= + flag=uhost-id-only short= default=false required= + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud uhost poweroff use=poweroff short=Analog power off Uhost instnace + flag=project-id short= default= required= + flag=region short= default= required= + flag=uhost-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud uhost reinstall-os use=reinstall-os short=Reinstall the operating system of the UHost instance + flag=async short=a default=false required= + flag=image-id short= default= required= + flag=keep-data-disk short= default=false required= + flag=key-pair-id short= default= required= + flag=password short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=uhost-id short= default= required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud uhost reset-password use=reset-password short=Reset the administrator password for the UHost instances. + flag=password short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=uhost-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud uhost resize use=resize short=Resize uhost instance,such as cpu core count, memory size and disk size + flag=async short=a default=false required= + flag=cpu short= default=0 required= + flag=data-disk-id short= default= required= + flag=data-disk-size-gb short= default=0 required= + flag=memory-gb short= default=0 required= + flag=net-cap short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=system-disk-size-gb short= default=0 required= + flag=uhost-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud uhost restart use=restart short=Restart uhost instance + flag=async short= default=false required= + flag=disk-password short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=uhost-id short= default=[] required=true + flag=zone short= default= required= +ucloud uhost start use=start short=Start Uhost instance + flag=async short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=uhost-id short= default=[] required=true + flag=zone short= default= required= +ucloud uhost stop use=stop short=Shut down uhost instance + flag=async short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=uhost-id short= default=[] required=true + flag=zone short= default= required= diff --git a/products/uhost/testdata/completion.golden b/products/uhost/testdata/completion.golden new file mode 100644 index 0000000000..a1094bf87d --- /dev/null +++ b/products/uhost/testdata/completion.golden @@ -0,0 +1,62 @@ +ucloud uhost clone uhost-id dynamic +ucloud uhost create bind-eip dynamic +ucloud uhost create charge-type static Dynamic,Month,Trial,Year +ucloud uhost create cpu static 1,12,16,2,24,32,4,64,8 +ucloud uhost create create-eip-line static BGP,International +ucloud uhost create create-eip-traffic-mode static Bandwidth,ShareBandwidth,Traffic +ucloud uhost create data-disk-backup-type static DATAARK,NONE +ucloud uhost create data-disk-type static CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD,NONE +ucloud uhost create firewall-id dynamic +ucloud uhost create gpu-type static 1080Ti,2080,2080Ti,2080Ti-4C,2080TiPro,2080TiS,3080Ti,3090,4090,4090LD,4090Pro,4090_48G,5090,5090D,5090Pro,A100,A800,H100,H20,H200,H800,K80,MI100,MR-V100,MetaX-C500,P40,T4,T4A,T4S,V100,V100S +ucloud uhost create hot-plug static false,true +ucloud uhost create image-id dynamic +ucloud uhost create isolation-group dynamic +ucloud uhost create machine-type static C,G,N,O,OS +ucloud uhost create minimal-cpu-platform static Intel/Auto,Intel/Broadwell,Intel/Cascadelake,Intel/Haswell,Intel/IvyBridge,Intel/Skylake +ucloud uhost create net-capability static Normal,Super,Ultra +ucloud uhost create os-disk-backup-type static DATAARK,NONE +ucloud uhost create os-disk-type static CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD +ucloud uhost create project-id dynamic +ucloud uhost create region dynamic +ucloud uhost create subnet-id dynamic +ucloud uhost create type static C1,G1,G2,G3,I1,I2,N1,N2,N3 +ucloud uhost create vpc-id dynamic +ucloud uhost create zone dynamic +ucloud uhost create-image uhost-id dynamic +ucloud uhost delete delete-cloud-disk static false,true +ucloud uhost delete destroy static false,true +ucloud uhost delete project-id dynamic +ucloud uhost delete region dynamic +ucloud uhost delete release-eip static false,true +ucloud uhost delete uhost-id dynamic +ucloud uhost isolation-group create project-id dynamic +ucloud uhost isolation-group create region dynamic +ucloud uhost isolation-group delete group-id dynamic +ucloud uhost isolation-group delete project-id dynamic +ucloud uhost isolation-group delete region dynamic +ucloud uhost isolation-group list group-id dynamic +ucloud uhost isolation-group list project-id dynamic +ucloud uhost isolation-group list region dynamic +ucloud uhost leave-isolation-group project-id dynamic +ucloud uhost leave-isolation-group region dynamic +ucloud uhost leave-isolation-group uhost-id dynamic +ucloud uhost leave-isolation-group zone dynamic +ucloud uhost list isolation-group dynamic +ucloud uhost list page-off static false,true +ucloud uhost list project-id dynamic +ucloud uhost list region dynamic +ucloud uhost list subnet-id dynamic +ucloud uhost list uhost-id dynamic +ucloud uhost list uhost-id-only static false,true +ucloud uhost list vpc-id dynamic +ucloud uhost list zone dynamic +ucloud uhost poweroff uhost-id dynamic +ucloud uhost reinstall-os uhost-id dynamic +ucloud uhost reset-password uhost-id dynamic +ucloud uhost resize project-id dynamic +ucloud uhost resize region dynamic +ucloud uhost resize uhost-id dynamic +ucloud uhost resize zone dynamic +ucloud uhost restart uhost-id dynamic +ucloud uhost start uhost-id dynamic +ucloud uhost stop uhost-id dynamic diff --git a/products/uk8s/internal/uk8s/cmd.go b/products/uk8s/internal/uk8s/cmd.go new file mode 100644 index 0000000000..f8ba485cb2 --- /dev/null +++ b/products/uk8s/internal/uk8s/cmd.go @@ -0,0 +1,28 @@ +package uk8s + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `uk8s` root command. Per the platform spec (§2.2 +// aggregator role), this file only constructs the top-level command and wires +// the verb constructors via AddCommand — no business logic, no helpers. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "uk8s", + Short: "Read and manipulate UK8S (UCloud Kubernetes Service) clusters", + Long: "Read and manipulate UK8S (UCloud Kubernetes Service) clusters", + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newGetConfig(ctx)) + cmd.AddCommand(newNodeGroup(ctx)) + cmd.AddCommand(newNode(ctx)) + cmd.AddCommand(newImage(ctx)) + cmd.AddCommand(newVersion(ctx)) + return cmd +} diff --git a/products/uk8s/internal/uk8s/commands_test.go b/products/uk8s/internal/uk8s/commands_test.go new file mode 100644 index 0000000000..e080532e91 --- /dev/null +++ b/products/uk8s/internal/uk8s/commands_test.go @@ -0,0 +1,400 @@ +package uk8s + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/spf13/cobra" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func setupCommandGateway(t *testing.T) (*cli.Context, *[]url.Values) { + t.Helper() + + requests := &[]url.Values{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse request form: %v", err) + } + *requests = append(*requests, r.PostForm) + action := r.PostForm.Get("Action") + response := map[string]interface{}{ + "RetCode": 0, + "Action": action + "Response", + } + switch action { + case "DescribeUK8SCluster": + response["ClusterId"] = "uk8s-a" + response["ClusterName"] = "test-cluster" + response["KubeProxy"] = map[string]string{"Mode": "iptables"} + response["MasterList"] = []map[string]interface{}{{ + "NodeId": "uhost-master", "Name": "master-0", "IPSet": []map[string]interface{}{{"IP": "10.0.0.1"}}, + }} + response["NodeList"] = []map[string]interface{}{{ + "NodeId": "uhost-node", "Name": "node-0", "DiskSet": []map[string]interface{}{{"DiskId": "bsi-node"}}, + }} + case "GetClusterConfig": + response["KubeConfig"] = "apiVersion: v1\nclusters: []\n" + response["ExternalKubeConfig"] = "apiVersion: v1\nclusters: []\n" + case "GetUK8SVersions": + response["Data"] = []map[string]string{{ + "K8sVersion": "1.34.5", + "ContainerdVersion": "1.7.27", + }} + case "AddUK8SNodeGroup": + response["NodeGroupId"] = "uk8sng-test" + case "DescribeUK8SImage": + response["CustomImageSet"] = []map[string]interface{}{{ + "ImageId": "uimage-custom", "ImageName": "custom-ubuntu", "Features": []string{"CloudInit"}, + }} + case "ListUK8SClusterNodeV2": + response["NodeSet"] = []map[string]interface{}{{ + "NodeId": "uk8s-node", "UsedCPU": 87, "UsedMemory": 1052389376, "VKCpu": 0, "VKMem": 0, + }} + case "AddUK8SUHostNode": + response["NodeIds"] = []string{"uk8snode-test"} + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + + t.Cleanup(func() { + server.Close() + }) + + var out, errOut bytes.Buffer + ctx := cli.NewContext(cli.Deps{ + In: strings.NewReader(""), Out: &out, Err: &errOut, + Format: cli.OutputTable, + DefaultsProvider: func() command.Defaults { + return command.Defaults{Region: "cn-sh2", ProjectID: "org-test"} + }, + ClientConfig: func() *sdk.Config { + return &sdk.Config{Region: "cn-sh2", ProjectId: "org-test", BaseUrl: server.URL} + }, + BuildCredential: func() *auth.Credential { + return &auth.Credential{PublicKey: "public", PrivateKey: "private"} + }, + AttachHandlers: func(sdk.ServiceClient) {}, + }) + return ctx, requests +} + +func runUK8SCommand(t *testing.T, cmd *cobra.Command, args ...string) { + t.Helper() + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute %s %v: %v", cmd.Use, args, err) + } +} + +func lastRequest(t *testing.T, requests *[]url.Values) url.Values { + t.Helper() + if len(*requests) == 0 { + t.Fatal("command did not call the API") + } + return (*requests)[len(*requests)-1] +} + +func assertRequest(t *testing.T, got url.Values, want map[string]string) { + t.Helper() + for key, expected := range want { + if actual := got.Get(key); actual != expected { + t.Errorf("request %s = %q, want %q", key, actual, expected) + } + } +} + +func TestClusterCommandsDispatch(t *testing.T) { + tests := []struct { + name string + build func(*cli.Context) *cobra.Command + args []string + action string + want map[string]string + }{ + { + name: "delete", build: newDelete, + args: []string{"--cluster-id", "uk8s-a/name,uk8s-b/name", "--release-udisk", "--release-eip", "--yes"}, + action: "DelUK8SCluster", want: map[string]string{"ClusterId": "uk8s-b", "ReleaseUDisk": "true", "ReleaseEIP": "true"}, + }, + { + name: "list", build: newList, + args: []string{"--cluster-id", "uk8s-a/name", "--limit", "25", "--offset", "5"}, + action: "ListUK8SClusterV2", want: map[string]string{"ClusterId": "uk8s-a", "Limit": "25", "Offset": "5"}, + }, + { + name: "describe", build: newDescribe, + args: []string{"--cluster-id", "uk8s-a/name"}, + action: "DescribeUK8SCluster", want: map[string]string{"ClusterId": "uk8s-a"}, + }, + { + name: "get-config", build: newGetConfig, + args: []string{"--cluster-id", "uk8s-a/name", "--external"}, + action: "GetClusterConfig", want: map[string]string{"ClusterId": "uk8s-a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, requests := setupCommandGateway(t) + runUK8SCommand(t, tt.build(ctx), tt.args...) + if tt.name == "delete" && len(*requests) != 2 { + t.Fatalf("delete sent %d requests, want 2", len(*requests)) + } + got := lastRequest(t, requests) + tt.want["Action"] = tt.action + assertRequest(t, got, tt.want) + }) + } +} + +func TestDescribeJSONUsesStructuredResponse(t *testing.T) { + ctx, _ := setupCommandGateway(t) + ctx.SetFormat(cli.OutputJSON) + runUK8SCommand(t, newDescribe(ctx), "--cluster-id", "uk8s-a") + + var got map[string]json.RawMessage + if err := json.Unmarshal(ctx.Out().(*bytes.Buffer).Bytes(), &got); err != nil { + t.Fatalf("decode JSON output: %v", err) + } + if _, ok := got["Attribute"]; ok { + t.Fatal("describe JSON must be a structured response object, not describe rows") + } + if string(got["ClusterId"]) != `"uk8s-a"` { + t.Fatalf("ClusterId = %s, want uk8s-a", got["ClusterId"]) + } + var kubeProxy struct{ Mode string } + if err := json.Unmarshal(got["KubeProxy"], &kubeProxy); err != nil || kubeProxy.Mode != "iptables" { + t.Fatalf("KubeProxy = %s, want structured object with iptables mode", got["KubeProxy"]) + } + var masterList []struct{ NodeId string } + if err := json.Unmarshal(got["MasterList"], &masterList); err != nil || len(masterList) != 1 || masterList[0].NodeId != "uhost-master" { + t.Fatalf("MasterList = %s, want structured node array", got["MasterList"]) + } +} + +func TestNodeGroupCommandsDispatch(t *testing.T) { + tests := []struct { + name string + args []string + action string + want map[string]string + }{ + { + name: "add", + args: []string{ + "add", "--cluster-id", "uk8s-a/name", "--name", "workers", "--machine-type", "G", + "--cpu", "4", "--memory-mb", "8192", "--gpu", "1", "--gpu-type", "V100", + "--image-id", "uimage-a/name", "--subnet-id", "subnet-a/name", "--boot-disk-type", "CLOUD_RSSD", "--boot-disk-size-gb", "40", + "--charge-type", "Month", "--cpu-platform", "Intel/Cascadelake", + }, + action: "AddUK8SNodeGroup", want: map[string]string{ + "ClusterId": "uk8s-a", "NodeGroupName": "workers", "MachineType": "G", "CPU": "4", "Mem": "8192", + "GPU": "1", "GpuType": "V100", "ImageId": "uimage-a", "SubnetId": "subnet-a", + "ChargeType": "Month", "MinimalCpuPlatform": "Intel/Cascadelake", + }, + }, + { + name: "delete", + args: []string{"delete", "--cluster-id", "uk8s-a/name", "--nodegroup-id", "uk8sng-a/name", "--yes"}, + action: "RemoveUK8SNodeGroup", want: map[string]string{"ClusterId": "uk8s-a", "NodeGroupId": "uk8sng-a"}, + }, + { + name: "list", + args: []string{"list", "--cluster-id", "uk8s-a/name"}, + action: "ListUK8SNodeGroup", want: map[string]string{"ClusterId": "uk8s-a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, requests := setupCommandGateway(t) + runUK8SCommand(t, newNodeGroup(ctx), tt.args...) + got := lastRequest(t, requests) + tt.want["Action"] = tt.action + assertRequest(t, got, tt.want) + }) + } +} + +func TestNodeGroupAddOmitsUnspecifiedResourceDefaults(t *testing.T) { + ctx, requests := setupCommandGateway(t) + runUK8SCommand(t, newNodeGroup(ctx), "add", + "--cluster-id", "uk8s-a/name", "--name", "workers", + "--machine-type", "N", "--cpu", "2", "--memory-mb", "4096", + "--image-id", "uimage-a/name", "--subnet-id", "subnet-a/name", "--boot-disk-type", "CLOUD_RSSD", "--boot-disk-size-gb", "40", + "--charge-type", "Month", "--cpu-platform", "Intel/Auto") + got := lastRequest(t, requests) + assertRequest(t, got, map[string]string{ + "Action": "AddUK8SNodeGroup", "ClusterId": "uk8s-a", "NodeGroupName": "workers", + "MachineType": "N", "CPU": "2", "Mem": "4096", "ImageId": "uimage-a", "SubnetId": "subnet-a", + "BootDiskType": "CLOUD_RSSD", "BootDiskSize": "40", "ChargeType": "Month", + "MinimalCpuPlatform": "Intel/Auto", + }) + for _, key := range []string{"DataDiskType", "DataDiskSize", "GPU", "GpuType"} { + if _, ok := got[key]; ok { + t.Errorf("unspecified node-group field %s must be omitted", key) + } + } +} + +func TestNodeCommandsDispatch(t *testing.T) { + tests := []struct { + name string + args []string + action string + want map[string]string + }{ + { + name: "add", + args: []string{ + "add", "--cluster-id", "uk8s-a/name", "--cpu", "2", "--memory-mb", "4096", "--count", "2", + "--charge-type", "Dynamic", "--password", "Password1", "--image-id", "uimage-a/name", + "--isolation-group-id", "ig-a/name", "--group", "Default", "--user-data", "cloud-init", "--init-script", "echo ready", + }, + action: "AddUK8SUHostNode", want: map[string]string{ + "ClusterId": "uk8s-a", "CPU": "2", "Mem": "4096", "Count": "2", "ChargeType": "Dynamic", + "Password": base64.StdEncoding.EncodeToString([]byte("Password1")), "ImageId": "uimage-a", + "IsolationGroup": "ig-a", "Tag": "Default", + "UserData": base64.StdEncoding.EncodeToString([]byte("cloud-init")), + "InitScript": base64.StdEncoding.EncodeToString([]byte("echo ready")), + }, + }, + { + name: "delete", + args: []string{"delete", "--cluster-id", "uk8s-a/name", "--node-id", "node-a/name,node-b/name", "--release-data-udisk=false", "--yes"}, + action: "DelUK8SClusterNodeV2", want: map[string]string{"ClusterId": "uk8s-a", "NodeId": "node-b", "ReleaseDataUDisk": "false"}, + }, + { + name: "list", + args: []string{"list", "--cluster-id", "uk8s-a/name"}, + action: "ListUK8SClusterNodeV2", want: map[string]string{"ClusterId": "uk8s-a"}, + }, + { + name: "describe", + args: []string{"describe", "--cluster-id", "uk8s-a/name", "--node-id", "10.0.0.8"}, + action: "DescribeUK8SNode", want: map[string]string{"ClusterId": "uk8s-a", "Name": "10.0.0.8"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, requests := setupCommandGateway(t) + runUK8SCommand(t, newNode(ctx), tt.args...) + if tt.name == "delete" && len(*requests) != 2 { + t.Fatalf("delete sent %d requests, want 2", len(*requests)) + } + got := lastRequest(t, requests) + tt.want["Action"] = tt.action + assertRequest(t, got, tt.want) + }) + } +} + +func TestImageListDispatch(t *testing.T) { + ctx, requests := setupCommandGateway(t) + runUK8SCommand(t, newImage(ctx), "list", "--zone", "cn-sh2-01") + assertRequest(t, lastRequest(t, requests), map[string]string{ + "Action": "DescribeUK8SImage", "Region": "cn-sh2", "ProjectId": "org-test", "Zone": "cn-sh2-01", + }) +} + +func TestCompatibilityResponsesKeepUpdatedUK8SFields(t *testing.T) { + tests := []struct { + name string + build func(*cli.Context) *cobra.Command + args []string + field string + }{ + { + name: "cluster describe", build: newDescribe, + args: []string{"--cluster-id", "uk8s-a"}, field: "ClusterId", + }, + { + name: "image list", build: func(ctx *cli.Context) *cobra.Command { return newImage(ctx) }, + args: []string{"list"}, field: "CustomImageSet", + }, + { + name: "node list", build: func(ctx *cli.Context) *cobra.Command { return newNode(ctx) }, + args: []string{"list", "--cluster-id", "uk8s-a"}, field: "NodeSet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, _ := setupCommandGateway(t) + ctx.SetFormat(cli.OutputJSON) + runUK8SCommand(t, tt.build(ctx), tt.args...) + + var output map[string]json.RawMessage + if err := json.Unmarshal(ctx.Out().(*bytes.Buffer).Bytes(), &output); err != nil { + t.Fatalf("decode JSON output: %v", err) + } + if _, ok := output[tt.field]; !ok { + t.Fatalf("output does not contain %q: %s", tt.field, ctx.Out().(*bytes.Buffer).String()) + } + }) + } +} + +func TestVersionListDispatch(t *testing.T) { + ctx, requests := setupCommandGateway(t) + runUK8SCommand(t, newVersion(ctx), "list") + assertRequest(t, lastRequest(t, requests), map[string]string{ + "Action": "GetUK8SVersions", "Region": "cn-sh2", "ProjectId": "org-test", "Kind": defaultUK8SKind, + }) +} + +func TestMutationValidationStopsBeforeAPI(t *testing.T) { + tests := []struct { + name string + cmd func(*cli.Context) *cobra.Command + args []string + want string + }{ + { + name: "node count", + cmd: func(ctx *cli.Context) *cobra.Command { return newNode(ctx) }, + args: []string{"add", "--cluster-id", "uk8s-a", "--cpu", "2", "--memory-mb", "4096", "--count", "0", "--charge-type", "Dynamic", "--password", "Password1"}, + want: "--count must be between 1 and 50", + }, + { + name: "nodegroup gpu", + cmd: func(ctx *cli.Context) *cobra.Command { return newNodeGroup(ctx) }, + args: []string{"add", "--cluster-id", "uk8s-a", "--name", "gpu", "--machine-type", "G", "--cpu", "2", "--memory-mb", "4096", "--image-id", "uimage-a", "--subnet-id", "subnet-a", "--boot-disk-type", "CLOUD_RSSD", "--boot-disk-size-gb", "40"}, + want: "--gpu and --gpu-type are required", + }, + { + name: "nodegroup boot disk type", + cmd: func(ctx *cli.Context) *cobra.Command { return newNodeGroup(ctx) }, + args: []string{"add", "--cluster-id", "uk8s-a", "--name", "workers", "--machine-type", "N", "--cpu", "2", "--memory-mb", "4096", "--image-id", "uimage-a", "--subnet-id", "subnet-a", "--boot-disk-size-gb", "40"}, + want: "--boot-disk-type is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, requests := setupCommandGateway(t) + cmd := tt.cmd(ctx) + cmd.SetArgs(tt.args) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want containing %q", err, tt.want) + } + if len(*requests) != 0 { + t.Fatal("invalid mutation must not reach the API") + } + }) + } +} diff --git a/products/uk8s/internal/uk8s/completion.go b/products/uk8s/internal/uk8s/completion.go new file mode 100644 index 0000000000..620cbeb0fc --- /dev/null +++ b/products/uk8s/internal/uk8s/completion.go @@ -0,0 +1,179 @@ +package uk8s + +import ( + "slices" + "strings" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func derefStr(value *string) string { + if value == nil { + return "" + } + return *value +} + +// listClusterIDs returns "ClusterId/Name" completion candidates for any +// cluster-id flag. Pass states=nil to include all clusters; otherwise filter +// against ClusterSet[].Status (CLUSTER_RUNNING etc. from status.go). +// +// Completion providers must never fail the shell: any error here is swallowed +// and the provider returns nil (no candidates). +func listClusterIDs(ctx *cli.Context, states []string, region, projectID string) []string { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewListUK8SClusterV2Request() + req.Region = sdk.String(region) + req.ProjectId = sdk.String(projectID) + resp, err := client.ListUK8SClusterV2(req) + if err != nil { + return nil + } + out := make([]string, 0, len(resp.ClusterSet)) + for _, c := range resp.ClusterSet { + if states != nil && !slices.Contains(states, c.Status) { + continue + } + out = append(out, c.ClusterId+"/"+strings.ReplaceAll(c.ClusterName, " ", "-")) + } + return out +} + +// listVPCIDs returns "VPCId/Name" candidates. Cross-product resource lookup +// uses the SDK service package directly (per §8 of the platform spec) — never +// import another products// tree. +func listVPCIDs(ctx *cli.Context, projectID, region string) []string { + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + resp, err := client.DescribeVPC(req) + if err != nil { + return nil + } + out := make([]string, 0, len(resp.DataSet)) + for _, v := range resp.DataSet { + out = append(out, v.VPCId+"/"+strings.ReplaceAll(v.Name, " ", "-")) + } + return out +} + +// listSubnetIDs returns "SubnetId/Name" candidates for the chosen VPC. +func listSubnetIDs(ctx *cli.Context, vpcID, projectID, region string) []string { + if vpcID == "" { + return nil + } + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDescribeSubnetRequest() + req.VPCId = sdk.String(vpcID) + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + resp, err := client.DescribeSubnet(req) + if err != nil { + return nil + } + out := make([]string, 0, len(resp.DataSet)) + for _, s := range resp.DataSet { + out = append(out, s.SubnetId+"/"+strings.ReplaceAll(s.SubnetName, " ", "-")) + } + return out +} + +func listUK8SImageIDs(ctx *cli.Context, projectID, region, zone string) []string { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewDescribeUK8SImageRequest() + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + resp, err := client.DescribeUK8SImage(req) + if err != nil { + return nil + } + out := make([]string, 0, len(resp.ImageSet)) + for _, image := range resp.ImageSet { + out = append(out, image.ImageId+"/"+strings.ReplaceAll(image.ImageName, " ", "-")) + } + return out +} + +func listUK8SVersions(ctx *cli.Context, projectID, region string) []string { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewGetUK8SVersionsRequest() + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + req.Kind = sdk.String(defaultUK8SKind) + resp, err := client.GetUK8SVersions(req) + if err != nil { + return nil + } + out := make([]string, 0, len(resp.Data)) + for _, version := range resp.Data { + out = append(out, version.K8sVersion) + } + return out +} + +func listIsolationGroupIDs(ctx *cli.Context, projectID, region string) []string { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeIsolationGroupRequest() + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + req.Limit = sdk.Int(100) + resp, err := client.DescribeIsolationGroup(req) + if err != nil { + return nil + } + out := make([]string, 0, len(resp.IsolationGroupSet)) + for _, group := range resp.IsolationGroupSet { + out = append(out, group.GroupId+"/"+strings.ReplaceAll(group.GroupName, " ", "-")) + } + return out +} + +func listNodeGroupIDs(ctx *cli.Context, clusterID, projectID, region string) []string { + if clusterID == "" { + return nil + } + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewListUK8SNodeGroupRequest() + req.ClusterId = sdk.String(ctx.PickResourceID(clusterID)) + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + resp, err := client.ListUK8SNodeGroup(req) + if err != nil { + return nil + } + out := make([]string, 0, len(resp.NodeGroupList)) + for _, group := range resp.NodeGroupList { + out = append(out, group.NodeGroupId+"/"+strings.ReplaceAll(group.NodeGroupName, " ", "-")) + } + return out +} + +func listNodeIDs(ctx *cli.Context, clusterID, projectID, region string) []string { + if clusterID == "" { + return nil + } + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewListUK8SClusterNodeV2Request() + req.ClusterId = sdk.String(ctx.PickResourceID(clusterID)) + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + resp, err := client.ListUK8SClusterNodeV2(req) + if err != nil { + return nil + } + out := make([]string, 0, len(resp.NodeSet)) + for _, node := range resp.NodeSet { + if strings.EqualFold(node.NodeRole, "master") { + continue + } + out = append(out, node.NodeId+"/"+strings.ReplaceAll(node.InstanceName, " ", "-")) + } + return out +} diff --git a/products/uk8s/internal/uk8s/create.go b/products/uk8s/internal/uk8s/create.go new file mode 100644 index 0000000000..585ebe4610 --- /dev/null +++ b/products/uk8s/internal/uk8s/create.go @@ -0,0 +1,480 @@ +package uk8s + +import ( + "encoding/base64" + "fmt" + "net" + "regexp" + "strings" + + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// masterCount is fixed at 3 by the platform contract (CreateUK8SClusterV2 +// builds a 3-master HA control plane; the SDK requires 3 Master entries). +const masterCount = 3 + +// newCreate implements `ucloud uk8s create`. +// +// Platform APIs exercised: cli.NewServiceClient, ctx.BindCommonParams, +// ctx.PollerTo(...).Spoll (wait path), ctx.ProgressWriter, ctx.EmitResult, +// ctx.HandleError, command.SetFlagValues, command.SetCompletion, +// MarkFlagRequired with "Required." descriptions, the --async pattern. +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewCreateUK8SClusterV2Request() + + var ( + async bool + masterZones []string + nodeZone string + kubeProxyMode string + chargeType string + quantity int + userData string + userDataB64 string + initScript string + initScriptB64 string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a UK8S cluster", + Long: "Create a UK8S (UCloud Kubernetes Service) cluster and, unless --async is set, wait for it to become RUNNING.", + SilenceUsage: true, + SilenceErrors: false, + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := validateCreateCommon(req.Region, req.ProjectId, *req.ServiceCIDR); err != nil { + return err + } + if err := validateCreateShape("master", *req.MasterCPU, *req.MasterMem); err != nil { + return err + } + if err := validateCreateShape("node", *req.Nodes[0].CPU, *req.Nodes[0].Mem); err != nil { + return err + } + if *req.Nodes[0].Count < 1 || *req.Nodes[0].Count > 10 { + return fmt.Errorf("--node-count must be between 1 and 10") + } + if req.Nodes[0].IsolationGroup != nil && *req.Nodes[0].IsolationGroup != "" && *req.Nodes[0].Count > 8 { + return fmt.Errorf("--node-count cannot exceed 8 when --node-isolation-group-id is set") + } + if err := validateCreateOptionalFields(cmd, req); err != nil { + return err + } + if err := validatePassword(*req.Password); err != nil { + return err + } + if cmd.Flags().Changed("charge-type") && !oneOf(chargeType, "Dynamic", "Month", "Year") { + return fmt.Errorf("--charge-type must be one of Dynamic, Month, or Year") + } + if !oneOf(*req.MasterMachineType, "N", "C", "O", "OS") { + return fmt.Errorf("--master-machine-type must be one of N, C, O, or OS") + } + if !oneOf(*req.Nodes[0].MachineType, "N", "C", "G", "O", "OS") { + return fmt.Errorf("--node-machine-type must be one of N, C, G, O, or OS") + } + if *req.Nodes[0].MachineType == "G" { + if !cmd.Flags().Changed("node-gpu") || !cmd.Flags().Changed("node-gpu-type") { + return fmt.Errorf("--node-gpu and --node-gpu-type are required when --node-machine-type is G") + } + } else if cmd.Flags().Changed("node-gpu") || cmd.Flags().Changed("node-gpu-type") { + return fmt.Errorf("--node-gpu and --node-gpu-type require --node-machine-type G") + } + if err := bindEncodedValue(cmd, "user-data", userData, "user-data-base64", userDataB64, &req.UserData); err != nil { + return err + } + if err := bindEncodedValue(cmd, "init-script", initScript, "init-script-base64", initScriptB64, &req.InitScript); err != nil { + return err + } + // Pad masterZones to masterCount so the user can supply 1 or 3 + // zones (single-AZ dev clusters vs. multi-AZ HA). + switch len(masterZones) { + case 1: + masterZones = []string{masterZones[0], masterZones[0], masterZones[0]} + case masterCount: + // already a triple + default: + return fmt.Errorf("--master-zone requires exactly 1 or %d entries (got %d)", masterCount, len(masterZones)) + } + if nodeZone == "" { + return fmt.Errorf("--node-zone is required") + } + if cmd.Flags().Changed("charge-type") { + req.ChargeType = sdk.String(chargeType) + } + if cmd.Flags().Changed("quantity") { + if chargeType == "Dynamic" { + return fmt.Errorf("--quantity must not be set when --charge-type is Dynamic") + } + req.Quantity = sdk.Int(quantity) + } + optional := map[string]func(){ + "external-api-server": func() { req.ExternalApiServer = nil }, + "cluster-domain": func() { req.ClusterDomain = nil }, + "master-boot-disk-type": func() { req.MasterBootDiskType = nil }, + "master-boot-disk-size-gb": func() { req.MasterBootDiskSize = nil }, + "master-data-disk-type": func() { req.MasterDataDiskType = nil }, + "master-data-disk-size-gb": func() { req.MasterDataDiskSize = nil }, + "master-cpu-platform": func() { req.MasterMinimalCpuPlatform = nil }, + "node-boot-disk-type": func() { req.Nodes[0].BootDiskType = nil }, + "node-boot-disk-size-gb": func() { req.Nodes[0].BootDiskSIze = nil }, + "node-data-disk-type": func() { req.Nodes[0].DataDiskType = nil }, + "node-data-disk-size-gb": func() { req.Nodes[0].DataDiskSize = nil }, + "node-cpu-platform": func() { req.Nodes[0].MinimalCpuPlatform = nil }, + "node-max-pods": func() { req.Nodes[0].MaxPods = nil }, + "node-isolation-group-id": func() { req.Nodes[0].IsolationGroup = nil }, + "node-labels": func() { req.Nodes[0].Labels = nil }, + "node-taints": func() { req.Nodes[0].Taints = nil }, + "node-gpu": func() { req.Nodes[0].GPU = nil }, + "node-gpu-type": func() { req.Nodes[0].GpuType = nil }, + "group": func() { req.Tag = nil }, + } + for name, clear := range optional { + if !cmd.Flags().Changed(name) { + clear() + } + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + // Base64-encode the password: the SDK docstring requires the + // caller to base64-encode (echo -n Password1 | base64). + if req.Password != nil && *req.Password != "" { + encoded := base64.StdEncoding.EncodeToString([]byte(*req.Password)) + req.Password = sdk.String(encoded) + } + + // Build Master slice from --master-zone (pre-padded in PreRunE). + masters := make([]uk8ssdk.CreateUK8SClusterV2ParamMaster, 0, masterCount) + for _, z := range masterZones { + masters = append(masters, uk8ssdk.CreateUK8SClusterV2ParamMaster{Zone: sdk.String(z)}) + } + req.Master = masters + + // Build a single Nodes group; Node.* fields are bound to the + // first entry of the slice (count=1 group, multiple VMs). + node := uk8ssdk.CreateUK8SClusterV2ParamNodes{ + Zone: sdk.String(nodeZone), + CPU: req.Nodes[0].CPU, + Count: req.Nodes[0].Count, + Mem: req.Nodes[0].Mem, + MachineType: req.Nodes[0].MachineType, + BootDiskType: req.Nodes[0].BootDiskType, + BootDiskSIze: req.Nodes[0].BootDiskSIze, + DataDiskType: req.Nodes[0].DataDiskType, + DataDiskSize: req.Nodes[0].DataDiskSize, + MinimalCpuPlatform: req.Nodes[0].MinimalCpuPlatform, + MaxPods: req.Nodes[0].MaxPods, + IsolationGroup: req.Nodes[0].IsolationGroup, + Labels: req.Nodes[0].Labels, + Taints: req.Nodes[0].Taints, + GPU: req.Nodes[0].GPU, + GpuType: req.Nodes[0].GpuType, + } + req.Nodes = []uk8ssdk.CreateUK8SClusterV2ParamNodes{node} + + // Resolve id/name forms for VPC/Subnet/Image (PickResourceID + // strips the "/Name" tail that completion hands back). + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId)) + if req.ImageId != nil && *req.ImageId != "" { + id := ctx.PickResourceID(*req.ImageId) + req.ImageId = sdk.String(id) + } + + // Wire kube-proxy from the bound flag. + if kubeProxyMode != "" { + req.KubeProxy = &uk8ssdk.CreateUK8SClusterV2ParamKubeProxy{Mode: sdk.String(kubeProxyMode)} + } + + w := ctx.ProgressWriter() + resp, err := client.CreateUK8SClusterV2(req) + if err != nil { + ctx.HandleError(err) + return nil + } + + text := fmt.Sprintf("uk8s[%s] is creating", resp.ClusterId) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx)).Spoll(resp.ClusterId, text, []string{ + CLUSTER_RUNNING, CLUSTER_CREATEFAILED, CLUSTER_ERROR, CLUSTER_ABNORMAL, + }) + } + + // json/yaml: structured row on stdout; table: no-op (text above + // is the result). + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ClusterId, Action: "create", Status: "Creating"}) + return nil + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + // Pre-allocate the single Nodes group so flag bindings can write into + // its fields (the SDK request constructor returns an empty slice). + req.Nodes = []uk8ssdk.CreateUK8SClusterV2ParamNodes{{}} + + // --- Required: cluster identification --- + req.ClusterName = flags.String("name", "", "Required. Cluster name.") + req.K8sVersion = flags.String("k8s-version", "", "Required. Kubernetes version. Use 'ucloud uk8s version list' to find a supported version.") + req.Password = flags.String("password", "", "Required. Password for cluster nodes (Master + Node). 8-30 chars from A-Z, a-z, 0-9 and ()~!@#$%^&*-+=_|{}[]:;'\\<>,.?/; must include at least 2 of {uppercase, lowercase, digit, special symbol}. Base64-encoded automatically before submission.") + + // --- Required: network --- + req.VPCId = flags.String("vpc-id", "", "Required. VPC ID. See 'ucloud vpc list'.") + req.SubnetId = flags.String("subnet-id", "", "Required. Subnet ID where nodes and pods live. See 'ucloud subnet list'.") + req.ServiceCIDR = flags.String("service-cidr", "", "Required. Service CIDR for ClusterIP allocation (e.g. 172.17.0.0/16). Must not overlap with the VPC CIDR.") + + // --- Required: master --- + req.MasterCPU = flags.Int("master-cpu", 0, "Required. vCPU cores per Master node. Range [2, 64].") + req.MasterMem = flags.Int("master-memory-mb", 0, "Required. Memory per Master node. Unit: MB. Range [4096, 262144], multiple of 1024.") + req.MasterMachineType = flags.String("master-machine-type", "", "Required. Master machine type. One of N, C, O, OS.") + flags.StringSliceVar(&masterZones, "master-zone", nil, "Required. Availability zone(s) for the 3 Master nodes. Pass 1 zone (replicated 3x) or 3 zones for multi-AZ HA.") + + // --- Required: nodes (first/only group) --- + req.Nodes[0].CPU = flags.Int("node-cpu", 0, "Required. vCPU cores per Node. Range [2, 64].") + req.Nodes[0].Count = flags.Int("node-count", 0, "Required. Node count per group. Range [1, 10].") + req.Nodes[0].Mem = flags.Int("node-memory-mb", 0, "Required. Memory per Node. Unit: MB. Range [4096, 262144], multiple of 1024.") + req.Nodes[0].MachineType = flags.String("node-machine-type", "", "Required. Node machine type. One of N, C, G, O, OS.") + flags.StringVar(&nodeZone, "node-zone", "", "Required. Availability zone for the node group.") + + // --- Required: image --- + req.ImageId = flags.String("image-id", "", "Required. Compatible UK8S UHost image ID for Master and Node. See 'ucloud uk8s image list'.") + + // --- Optional: extras --- + req.ExternalApiServer = flags.String("external-api-server", "", "Optional. Expose the API server on the public internet. Accept values: Yes, No.") + req.ClusterDomain = flags.String("cluster-domain", "", "Optional. Custom cluster domain.") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for the cluster to become RUNNING.") + + // Master disk / platform (optional) + req.MasterBootDiskType = flags.String("master-boot-disk-type", "", "Optional. Master system disk type. See uhost disk types.") + req.MasterBootDiskSize = flags.Int("master-boot-disk-size-gb", 0, "Optional. Master system disk size in GB. Range [40, 500].") + req.MasterDataDiskType = flags.String("master-data-disk-type", "", "Optional. Master data disk type.") + req.MasterDataDiskSize = flags.Int("master-data-disk-size-gb", 0, "Optional. Master data disk size in GB. Range [20, 1000].") + req.MasterMinimalCpuPlatform = flags.String("master-cpu-platform", "", "Optional. Minimum CPU platform. E.g. Intel/Cascadelake.") + + // Node disk / platform (optional) + req.Nodes[0].BootDiskType = flags.String("node-boot-disk-type", "", "Optional. Node system disk type.") + req.Nodes[0].BootDiskSIze = flags.Int("node-boot-disk-size-gb", 0, "Optional. Node system disk size in GB. Range [40, 500].") + req.Nodes[0].DataDiskType = flags.String("node-data-disk-type", "", "Optional. Node data disk type.") + req.Nodes[0].DataDiskSize = flags.Int("node-data-disk-size-gb", 0, "Optional. Node data disk size in GB. Range [20, 1000].") + req.Nodes[0].MinimalCpuPlatform = flags.String("node-cpu-platform", "", "Optional. Minimum CPU platform.") + req.Nodes[0].MaxPods = flags.Int("node-max-pods", 0, "Optional. Maximum pods per node.") + req.Nodes[0].IsolationGroup = flags.String("node-isolation-group-id", "", "Optional. Isolation group ID for Node instances; one group supports at most 8 nodes.") + req.Nodes[0].Labels = flags.String("node-labels", "", "Optional. Comma-separated node labels in key=value form, at most 5.") + req.Nodes[0].Taints = flags.String("node-taints", "", "Optional. Comma-separated node taints in key=value:effect form, at most 5.") + req.Nodes[0].GPU = flags.Int("node-gpu", 0, "Optional. GPU core count; supported only by GPU-capable machine types.") + req.Nodes[0].GpuType = flags.String("node-gpu-type", "", "Optional. GPU type: K80, P40, or V100.") + + // Cluster customization. Plain values are base64-encoded by the CLI; the + // *-base64 variants accept already encoded data and conflict with plain input. + flags.StringVar(&userData, "user-data", "", "Optional. User data; base64-encoded automatically. Maximum decoded size: 16 KiB.") + flags.StringVar(&userDataB64, "user-data-base64", "", "Optional. Pre-encoded user data. Conflicts with --user-data.") + flags.StringVar(&initScript, "init-script", "", "Optional. Post-install script; base64-encoded automatically. Maximum decoded size: 16 KiB.") + flags.StringVar(&initScriptB64, "init-script-base64", "", "Optional. Pre-encoded post-install script. Conflicts with --init-script.") + req.Tag = flags.String("group", "", "Optional. Business group.") + + // kube-proxy + flags.StringVar(&kubeProxyMode, "kube-proxy-mode", "", "Optional. kube-proxy mode. Accept values: iptables, ipvs.") + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + flags.StringVar(&chargeType, "charge-type", "", "Optional. Billing mode: Dynamic, Month, or Year.") + flags.IntVar(&quantity, "quantity", 0, "Optional. Purchase duration.") + + // Static enum candidates. + command.SetFlagValues(cmd, "master-machine-type", "N", "C", "O", "OS") + command.SetFlagValues(cmd, "node-machine-type", "N", "C", "G", "O", "OS") + command.SetFlagValues(cmd, "external-api-server", "Yes", "No") + command.SetFlagValues(cmd, "kube-proxy-mode", "iptables", "ipvs") + command.SetFlagValues(cmd, "master-boot-disk-type", "CLOUD_SSD", "CLOUD_NORMAL", "LOCAL_SSD", "LOCAL_NORMAL", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK") + command.SetFlagValues(cmd, "master-data-disk-type", "CLOUD_SSD", "CLOUD_NORMAL", "LOCAL_SSD", "LOCAL_NORMAL", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK", "") + command.SetFlagValues(cmd, "node-boot-disk-type", "CLOUD_SSD", "CLOUD_NORMAL", "LOCAL_SSD", "LOCAL_NORMAL", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK") + command.SetFlagValues(cmd, "node-data-disk-type", "CLOUD_SSD", "CLOUD_NORMAL", "LOCAL_SSD", "LOCAL_NORMAL", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK", "") + command.SetFlagValues(cmd, "master-cpu-platform", "Intel/Auto", "Intel/IvyBridge", "Intel/Haswell", "Intel/Broadwell", "Intel/Skylake", "Intel/Cascadelake") + command.SetFlagValues(cmd, "node-cpu-platform", "Intel/Auto", "Intel/IvyBridge", "Intel/Haswell", "Intel/Broadwell", "Intel/Skylake", "Intel/Cascadelake") + command.SetFlagValues(cmd, "charge-type", "Dynamic", "Month", "Year") + command.SetFlagValues(cmd, "node-gpu-type", "K80", "P40", "V100") + + // Dynamic completion for resource ids. + command.SetCompletion(cmd, "vpc-id", func() []string { + return listVPCIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return listSubnetIDs(ctx, ctx.PickResourceID(*req.VPCId), derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "k8s-version", func() []string { + return listUK8SVersions(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "image-id", func() []string { + return listUK8SImageIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region), nodeZone) + }) + command.SetCompletion(cmd, "node-isolation-group-id", func() []string { + return listIsolationGroupIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("subnet-id") + cmd.MarkFlagRequired("service-cidr") + cmd.MarkFlagRequired("master-cpu") + cmd.MarkFlagRequired("master-memory-mb") + cmd.MarkFlagRequired("master-machine-type") + cmd.MarkFlagRequired("master-zone") + cmd.MarkFlagRequired("node-cpu") + cmd.MarkFlagRequired("node-count") + cmd.MarkFlagRequired("node-memory-mb") + cmd.MarkFlagRequired("node-machine-type") + cmd.MarkFlagRequired("node-zone") + cmd.MarkFlagRequired("image-id") + cmd.MarkFlagRequired("k8s-version") + + return cmd +} + +func validateCreateCommon(region, projectID *string, serviceCIDR string) error { + if region == nil || strings.TrimSpace(*region) == "" { + return fmt.Errorf("region is required; set --region or configure it in the active profile") + } + if projectID == nil || strings.TrimSpace(*projectID) == "" { + return fmt.Errorf("project ID is required; set --project-id or configure it in the active profile") + } + if _, _, err := net.ParseCIDR(serviceCIDR); err != nil { + return fmt.Errorf("--service-cidr must be a valid CIDR: %w", err) + } + return nil +} + +func validateCreateShape(prefix string, cpu, memory int) error { + if cpu < 2 || cpu > 64 { + return fmt.Errorf("--%s-cpu must be between 2 and 64", prefix) + } + if memory < 4096 || memory > 262144 || memory%1024 != 0 { + return fmt.Errorf("--%s-memory-mb must be between 4096 and 262144 and a multiple of 1024", prefix) + } + return nil +} + +func validateCreateOptionalFields(cmd *cobra.Command, req *uk8ssdk.CreateUK8SClusterV2Request) error { + if cmd.Flags().Changed("external-api-server") && !oneOf(*req.ExternalApiServer, "Yes", "No") { + return fmt.Errorf("--external-api-server must be Yes or No") + } + if mode, err := cmd.Flags().GetString("kube-proxy-mode"); err == nil && cmd.Flags().Changed("kube-proxy-mode") && !oneOf(mode, "iptables", "ipvs") { + return fmt.Errorf("--kube-proxy-mode must be iptables or ipvs") + } + if cmd.Flags().Changed("node-gpu-type") && !oneOf(*req.Nodes[0].GpuType, "K80", "P40", "V100") { + return fmt.Errorf("--node-gpu-type must be one of K80, P40, or V100") + } + if cmd.Flags().Changed("node-gpu") && *req.Nodes[0].GPU < 1 { + return fmt.Errorf("--node-gpu must be greater than 0") + } + if cmd.Flags().Changed("node-max-pods") && *req.Nodes[0].MaxPods < 1 { + return fmt.Errorf("--node-max-pods must be greater than 0") + } + for _, item := range []struct { + name string + value *int + min, max int + }{ + {"master-boot-disk-size-gb", req.MasterBootDiskSize, 40, 500}, + {"node-boot-disk-size-gb", req.Nodes[0].BootDiskSIze, 40, 500}, + {"master-data-disk-size-gb", req.MasterDataDiskSize, 20, 1000}, + {"node-data-disk-size-gb", req.Nodes[0].DataDiskSize, 20, 1000}, + } { + if cmd.Flags().Changed(item.name) && (*item.value < item.min || *item.value > item.max) { + return fmt.Errorf("--%s must be between %d and %d", item.name, item.min, item.max) + } + } + for _, item := range []struct { + name string + value *string + }{ + {"node-labels", req.Nodes[0].Labels}, + {"node-taints", req.Nodes[0].Taints}, + } { + if item.value != nil && *item.value != "" && len(strings.Split(*item.value, ",")) > 5 { + return fmt.Errorf("--%s accepts at most 5 comma-separated entries", item.name) + } + } + return nil +} + +func bindEncodedValue(cmd *cobra.Command, plainName, plain, encodedName, encoded string, target **string) error { + if plain != "" && encoded != "" { + return fmt.Errorf("--%s conflicts with --%s", plainName, encodedName) + } + if plain != "" { + if len([]byte(plain)) > 16*1024 { + return fmt.Errorf("--%s must not exceed 16 KiB", plainName) + } + *target = sdk.String(base64.StdEncoding.EncodeToString([]byte(plain))) + return nil + } + if encoded != "" { + if !common.IsBase64Encoded([]byte(encoded)) { + return fmt.Errorf("--%s must be base64-encoded", encodedName) + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || len(decoded) > 16*1024 { + return fmt.Errorf("--%s decoded value must not exceed 16 KiB", encodedName) + } + *target = sdk.String(encoded) + return nil + } + if !cmd.Flags().Changed(plainName) && !cmd.Flags().Changed(encodedName) { + *target = nil + } + return nil +} + +func oneOf(value string, allowed ...string) bool { + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +// UK8S password policy (server-side UHost/CreateUK8SClusterV2 rule): +// 8-30 chars from [A-Za-z0-9()~!@#$%^&*-+=_|{}[]:;'\<>,.?/], must include at +// least 2 of {uppercase, lowercase, digit, special symbol}. Mirrors the +// check used by UHost/UASGTemplateBuilder so we fail fast at the CLI. +var ( + uk8sPwdAllowed = regexp.MustCompile(`^[A-Za-z0-9()~!@#$%^&*\-_+=|{}[\]:;'\\<>,.?/]+$`) + uk8sPwdUpper = regexp.MustCompile(`[A-Z]`) + uk8sPwdLower = regexp.MustCompile(`[a-z]`) + uk8sPwdDigit = regexp.MustCompile(`[0-9]`) + uk8sPwdSpecial = regexp.MustCompile(`[^A-Za-z0-9]`) +) + +func validatePassword(raw string) error { + if l := len(raw); l < 8 || l > 30 { + return fmt.Errorf("--password must be 8-30 characters long (got %d)", l) + } + if !uk8sPwdAllowed.MatchString(raw) { + return fmt.Errorf("--password contains illegal characters; allowed: A-Z, a-z, 0-9 and ()~!@#$%%^&*-_+=|{}[]:;'\\<>,.?/") + } + classes := 0 + for _, re := range []*regexp.Regexp{uk8sPwdUpper, uk8sPwdLower, uk8sPwdDigit, uk8sPwdSpecial} { + if re.MatchString(raw) { + classes++ + } + } + if classes < 2 { + return fmt.Errorf("--password must contain at least 2 of: uppercase, lowercase, digit, special symbol") + } + return nil +} diff --git a/products/uk8s/internal/uk8s/create_test.go b/products/uk8s/internal/uk8s/create_test.go new file mode 100644 index 0000000000..1d1cc258b3 --- /dev/null +++ b/products/uk8s/internal/uk8s/create_test.go @@ -0,0 +1,271 @@ +package uk8s + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func setupCreateMock(t *testing.T) (*cli.Context, *url.Values, func()) { + t.Helper() + + values := &url.Values{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse request form: %v", err) + } + *values = r.PostForm + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "RetCode": 0, + "Action": "CreateUK8SClusterV2Response", + "ClusterId": "uk8s-test", + }) + })) + + var out, errOut bytes.Buffer + ctx := cli.NewContext(cli.Deps{ + In: strings.NewReader(""), + Out: &out, + Err: &errOut, + Format: cli.OutputTable, + DefaultsProvider: func() command.Defaults { + return command.Defaults{Region: "cn-sh2", ProjectID: "org-test"} + }, + ClientConfig: func() *sdk.Config { + return &sdk.Config{Region: "cn-sh2", ProjectId: "org-test", BaseUrl: server.URL} + }, + BuildCredential: func() *auth.Credential { + return &auth.Credential{PublicKey: "public", PrivateKey: "private"} + }, + AttachHandlers: func(sdk.ServiceClient) {}, + }) + cleanup := func() { + server.Close() + } + return ctx, values, cleanup +} + +func requiredCreateArgs() []string { + return []string{ + "--name", "demo-uk8s", + "--password", "Password1", + "--vpc-id", "uvnet-test/vpc-name", + "--subnet-id", "subnet-test/subnet-name", + "--service-cidr", "172.17.0.0/16", + "--master-cpu", "2", + "--master-memory-mb", "4096", + "--master-machine-type", "N", + "--master-zone", "cn-sh2-01", + "--node-cpu", "2", + "--node-count", "3", + "--node-memory-mb", "4096", + "--node-machine-type", "N", + "--node-zone", "cn-sh2-01", + "--image-id", "uimage-test/image-name", + "--k8s-version", "1.34.5", + "--async", + } +} + +func TestCreateRequestMatchesDocument(t *testing.T) { + ctx, form, cleanup := setupCreateMock(t) + defer cleanup() + + cmd := newCreate(ctx) + args := append(requiredCreateArgs(), + "--charge-type", "Month", + "--quantity", "1", + "--node-isolation-group-id", "ig-test", + "--node-machine-type", "G", + "--node-gpu", "1", + "--node-gpu-type", "V100", + "--node-labels", "env=test,team=cli", + "--node-taints", "dedicated=test:NoSchedule", + "--node-max-pods", "110", + "--group", "Default", + "--user-data", "cloud-init", + "--init-script", "echo ready", + ) + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute create: %v", err) + } + + want := map[string]string{ + "Action": "CreateUK8SClusterV2", + "Region": "cn-sh2", + "ProjectId": "org-test", + "ClusterName": "demo-uk8s", + "Password": base64.StdEncoding.EncodeToString([]byte("Password1")), + "VPCId": "uvnet-test", + "SubnetId": "subnet-test", + "ServiceCIDR": "172.17.0.0/16", + "K8sVersion": "1.34.5", + "ImageId": "uimage-test", + "MasterCPU": "2", + "MasterMem": "4096", + "MasterMachineType": "N", + "Master.0.Zone": "cn-sh2-01", + "Master.1.Zone": "cn-sh2-01", + "Master.2.Zone": "cn-sh2-01", + "Nodes.0.Zone": "cn-sh2-01", + "Nodes.0.CPU": "2", + "Nodes.0.Count": "3", + "Nodes.0.Mem": "4096", + "Nodes.0.MachineType": "G", + "Nodes.0.GPU": "1", + "Nodes.0.GpuType": "V100", + "Nodes.0.IsolationGroup": "ig-test", + "Nodes.0.Labels": "env=test,team=cli", + "Nodes.0.Taints": "dedicated=test:NoSchedule", + "Nodes.0.MaxPods": "110", + "ChargeType": "Month", + "Quantity": "1", + "Tag": "Default", + "UserData": base64.StdEncoding.EncodeToString([]byte("cloud-init")), + "InitScript": base64.StdEncoding.EncodeToString([]byte("echo ready")), + } + for key, expected := range want { + if got := form.Get(key); got != expected { + t.Errorf("request %s = %q, want %q", key, got, expected) + } + } +} + +func TestCreateOmitsDocumentedOptionalFields(t *testing.T) { + ctx, form, cleanup := setupCreateMock(t) + defer cleanup() + + cmd := newCreate(ctx) + cmd.SetArgs(requiredCreateArgs()) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute create: %v", err) + } + + for _, key := range []string{ + "ChargeType", "Quantity", "UserData", "InitScript", + "Nodes.0.MaxPods", "Nodes.0.IsolationGroup", "Nodes.0.Labels", "Nodes.0.Taints", + } { + if _, ok := (*form)[key]; ok { + t.Errorf("optional request field %s must be omitted", key) + } + } +} + +func TestCreateRejectsInvalidShapeBeforeRequest(t *testing.T) { + ctx, form, cleanup := setupCreateMock(t) + defer cleanup() + + cmd := newCreate(ctx) + args := requiredCreateArgs() + for i := range args { + if args[i] == "--node-memory-mb" { + args[i+1] = "5000" + } + } + cmd.SetArgs(args) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "multiple of 1024") { + t.Fatalf("expected memory validation error, got %v", err) + } + if len(*form) != 0 { + t.Fatal("invalid command must not reach the API") + } +} + +func TestValidatePassword(t *testing.T) { + cases := []struct { + name string + pwd string + wantErr string // substring; "" means accept + }{ + // Valid + {name: "9 chars upper+lower+digit", pwd: "Password1", wantErr: ""}, + {name: "8 chars lower+digit (boundary)", pwd: "abc12345", wantErr: ""}, + {name: "30 chars all four classes (boundary)", pwd: strings.Repeat("Aa1!", 7) + "Aa", wantErr: ""}, + {name: "9 chars lower+digit+special no upper", pwd: "password1!", wantErr: ""}, + {name: "all four classes", pwd: "Abc123!@#", wantErr: ""}, + {name: "backslash is allowed", pwd: `Pa\ssw0rd`, wantErr: ""}, + + // Length + {name: "7 chars too short", pwd: "Abc1234", wantErr: "8-30"}, + {name: "31 chars too long", pwd: strings.Repeat("Aa1!", 7) + "Aa1", wantErr: "8-30"}, + {name: "empty", pwd: "", wantErr: "8-30"}, + + // Illegal chars (not in allowed set) + {name: "contains space", pwd: "Pass word1", wantErr: "illegal characters"}, + {name: "contains tab", pwd: "Pass\tword1", wantErr: "illegal characters"}, + {name: "contains chinese char", pwd: "Password密1", wantErr: "illegal characters"}, + {name: "contains double-quote", pwd: `Pass"word1`, wantErr: "illegal characters"}, + {name: "contains backtick", pwd: "Pass`word1", wantErr: "illegal characters"}, + + // Single class is not enough + {name: "only digits", pwd: "12345678", wantErr: "at least 2"}, + {name: "only lowercase", pwd: "abcdefgh", wantErr: "at least 2"}, + {name: "only uppercase", pwd: "ABCDEFGH", wantErr: "at least 2"}, + {name: "only specials", pwd: "!@#$%^&*", wantErr: "at least 2"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validatePassword(tc.pwd) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("validatePassword(%q) unexpected error: %v", tc.pwd, err) + } + return + } + if err == nil { + t.Fatalf("validatePassword(%q) returned nil, want error containing %q", tc.pwd, tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("validatePassword(%q) = %v, want substring %q", tc.pwd, err, tc.wantErr) + } + }) + } +} + +func TestCreateRejectsBadPasswordBeforeRequest(t *testing.T) { + cases := []struct { + name string + pwd string + wantErr string + }{ + {name: "too short", pwd: "Ab1!", wantErr: "8-30"}, + {name: "illegal char", pwd: "Password 1", wantErr: "illegal characters"}, + {name: "single class", pwd: "abcdefgh", wantErr: "at least 2"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, form, cleanup := setupCreateMock(t) + defer cleanup() + + cmd := newCreate(ctx) + args := requiredCreateArgs() + for i := range args { + if args[i] == "--password" { + args[i+1] = tc.pwd + break + } + } + cmd.SetArgs(args) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got %v", tc.wantErr, err) + } + if len(*form) != 0 { + t.Fatal("invalid password must not reach the API") + } + }) + } +} diff --git a/products/uk8s/internal/uk8s/delete.go b/products/uk8s/internal/uk8s/delete.go new file mode 100644 index 0000000000..72b209ff64 --- /dev/null +++ b/products/uk8s/internal/uk8s/delete.go @@ -0,0 +1,101 @@ +package uk8s + +import ( + "fmt" + + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewDelUK8SClusterRequest() + + var clusterIDs []string + var releaseUDisk bool + var releaseEIP bool + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete UK8S clusters", + Long: "Delete one or more UK8S clusters by cluster ID.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the UK8S cluster(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + + w := ctx.ProgressWriter() + results := make([]cli.OpResultRow, 0, len(clusterIDs)) + for _, idName := range clusterIDs { + id := ctx.PickResourceID(idName) + req.ClusterId = sdk.String(id) + req.ReleaseUDisk = sdk.Bool(releaseUDisk) + var err error + if releaseEIP { + // DelUK8SClusterRequest in older SDK schemas does not expose + // ReleaseEIP, but the UK8S API accepts it. Use a local request + // shape only when the user explicitly opts in. Preserve CommonBase + // so the bound region and project ID are sent with this request. + eipReq := &deleteClusterRequest{ + CommonBase: req.CommonBase, + ClusterId: req.ClusterId, + ReleaseUDisk: req.ReleaseUDisk, + ReleaseEIP: sdk.Bool(true), + } + client.SetupRequest(eipReq) + var resp uk8ssdk.DelUK8SClusterResponse + err = client.InvokeAction("DelUK8SCluster", eipReq, &resp) + } else { + _, err = client.DelUK8SCluster(req) + } + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "uk8s[%s] deletion requested\n", id) + results = append(results, cli.OpResultRow{ + ResourceID: id, + Action: "delete", + Status: "Deleting", + }) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&clusterIDs, "cluster-id", nil, "Required. Cluster ID(s) to delete.") + flags.BoolVar(&releaseUDisk, "release-udisk", false, "Optional. Release data disks attached to cluster nodes.") + flags.BoolVar(&releaseEIP, "release-eip", false, "Optional. Release EIP resources attached to the cluster.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("cluster-id") + command.SetCompletion(cmd, "cluster-id", func() []string { + return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId)) + }) + return cmd +} + +// deleteClusterRequest carries the optional ReleaseEIP field that is not yet +// present in the generated UK8S SDK request type. +type deleteClusterRequest struct { + request.CommonBase + ClusterId *string `required:"true"` + ReleaseUDisk *bool `required:"false"` + ReleaseEIP *bool `required:"false"` +} diff --git a/products/uk8s/internal/uk8s/describe.go b/products/uk8s/internal/uk8s/describe.go new file mode 100644 index 0000000000..6afde75cc9 --- /dev/null +++ b/products/uk8s/internal/uk8s/describe.go @@ -0,0 +1,53 @@ +package uk8s + +import ( + "fmt" + + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newDescribe(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewDescribeUK8SClusterRequest() + + cmd := &cobra.Command{ + Use: "describe", + Short: "Show details of a UK8S cluster", + Long: "Show the attributes of one UK8S cluster.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + *req.ClusterId = ctx.PickResourceID(*req.ClusterId) + cluster, err := client.DescribeUK8SCluster(req) + if err != nil { + ctx.HandleError(err) + return + } + if cluster.ClusterId == "" { + ctx.HandleError(fmt.Errorf("cluster %q not found", *req.ClusterId)) + return + } + if ctx.Format() != cli.OutputTable { + ctx.PrintList(cluster) + return + } + + ctx.PrintList(clusterDescribeRows(cluster)) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID to describe.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("cluster-id") + command.SetCompletion(cmd, "cluster-id", func() []string { + return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId)) + }) + return cmd +} diff --git a/products/uk8s/internal/uk8s/get_config.go b/products/uk8s/internal/uk8s/get_config.go new file mode 100644 index 0000000000..c04a208910 --- /dev/null +++ b/products/uk8s/internal/uk8s/get_config.go @@ -0,0 +1,66 @@ +package uk8s + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newGetConfig(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewGetClusterConfigRequest() + var external bool + + cmd := &cobra.Command{ + Use: "get-config", + Short: "Print a UK8S cluster kubeconfig", + Long: "Print the internal kubeconfig, or the external kubeconfig with --external.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + *req.ClusterId = ctx.PickResourceID(*req.ClusterId) + resp, err := client.GetClusterConfig(req) + if err != nil { + ctx.HandleError(err) + return + } + config := resp.KubeConfig + if external { + config = resp.ExternalKubeConfig + } + if strings.TrimSpace(config) == "" { + kind := "internal" + if external { + kind = "external" + } + ctx.HandleError(fmt.Errorf("%s kubeconfig is not available for cluster %q", kind, *req.ClusterId)) + return + } + if ctx.Format() != cli.OutputTable { + ctx.PrintList(resp) + return + } + fmt.Fprint(ctx.Out(), config) + if !strings.HasSuffix(config, "\n") { + fmt.Fprintln(ctx.Out()) + } + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID whose kubeconfig will be printed.") + flags.BoolVar(&external, "external", false, "Optional. Print the external kubeconfig.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("cluster-id") + command.SetCompletion(cmd, "cluster-id", func() []string { + return listClusterIDs(ctx, []string{CLUSTER_RUNNING}, derefStr(req.Region), derefStr(req.ProjectId)) + }) + return cmd +} diff --git a/products/uk8s/internal/uk8s/image.go b/products/uk8s/internal/uk8s/image.go new file mode 100644 index 0000000000..4744f97166 --- /dev/null +++ b/products/uk8s/internal/uk8s/image.go @@ -0,0 +1,13 @@ +package uk8s + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newImage(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{Use: "image", Short: "Inspect UK8S images"} + cmd.AddCommand(newImageList(ctx)) + return cmd +} diff --git a/products/uk8s/internal/uk8s/image_list.go b/products/uk8s/internal/uk8s/image_list.go new file mode 100644 index 0000000000..46352b0e43 --- /dev/null +++ b/products/uk8s/internal/uk8s/image_list.go @@ -0,0 +1,38 @@ +package uk8s + +import ( + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newImageList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewDescribeUK8SImageRequest() + + cmd := &cobra.Command{ + Use: "list", + Short: "List images supported by UK8S", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.DescribeUK8SImage(req) + if err != nil { + ctx.HandleError(err) + return + } + if ctx.Format() != cli.OutputTable { + ctx.PrintList(resp) + return + } + ctx.PrintList(imageRows(resp)) + }, + } + + cmd.Flags().SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + return cmd +} diff --git a/products/uk8s/internal/uk8s/list.go b/products/uk8s/internal/uk8s/list.go new file mode 100644 index 0000000000..cbe6907436 --- /dev/null +++ b/products/uk8s/internal/uk8s/list.go @@ -0,0 +1,49 @@ +package uk8s + +import ( + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewListUK8SClusterV2Request() + + cmd := &cobra.Command{ + Use: "list", + Short: "List UK8S clusters", + Long: "List UK8S clusters in the active region and project.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + if req.ClusterId != nil && *req.ClusterId != "" { + *req.ClusterId = ctx.PickResourceID(*req.ClusterId) + } + resp, err := client.ListUK8SClusterV2(req) + if err != nil { + ctx.HandleError(err) + return + } + if ctx.Format() != cli.OutputTable { + ctx.PrintList(resp) + return + } + ctx.PrintList(clusterRows(resp.ClusterSet)) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterId = flags.String("cluster-id", "", "Optional. List only the specified cluster.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + ctx.BindLimit(cmd, req) + ctx.BindOffset(cmd, req) + command.SetCompletion(cmd, "cluster-id", func() []string { + return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId)) + }) + return cmd +} diff --git a/products/uk8s/internal/uk8s/node.go b/products/uk8s/internal/uk8s/node.go new file mode 100644 index 0000000000..ab75afd285 --- /dev/null +++ b/products/uk8s/internal/uk8s/node.go @@ -0,0 +1,16 @@ +package uk8s + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newNode(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{Use: "node", Short: "Manage UK8S nodes"} + cmd.AddCommand(newNodeAdd(ctx)) + cmd.AddCommand(newNodeDelete(ctx)) + cmd.AddCommand(newNodeList(ctx)) + cmd.AddCommand(newNodeDescribe(ctx)) + return cmd +} diff --git a/products/uk8s/internal/uk8s/node_add.go b/products/uk8s/internal/uk8s/node_add.go new file mode 100644 index 0000000000..4523dbfa8e --- /dev/null +++ b/products/uk8s/internal/uk8s/node_add.go @@ -0,0 +1,175 @@ +package uk8s + +import ( + "encoding/base64" + "fmt" + "strings" + + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newNodeAdd(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewAddUK8SUHostNodeRequest() + var userData, userDataB64, initScript, initScriptB64 string + + cmd := &cobra.Command{ + Use: "add", + Short: "Add UHost nodes to a UK8S cluster", + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + if *req.CPU < 2 || *req.CPU > 64 { + return fmt.Errorf("--cpu must be between 2 and 64") + } + if *req.Mem < 4096 || *req.Mem > 262144 || *req.Mem%1024 != 0 { + return fmt.Errorf("--memory-mb must be between 4096 and 262144 and a multiple of 1024") + } + if *req.Count < 1 || *req.Count > 50 { + return fmt.Errorf("--count must be between 1 and 50") + } + for _, item := range []struct { + name string + value *int + min, max int + }{ + {"boot-disk-size-gb", req.BootDiskSize, 40, 500}, + {"data-disk-size-gb", req.DataDiskSize, 20, 1000}, + } { + if cmd.Flags().Changed(item.name) && (*item.value < item.min || *item.value > item.max) { + return fmt.Errorf("--%s must be between %d and %d", item.name, item.min, item.max) + } + } + if cmd.Flags().Changed("quantity") && *req.Quantity < 0 { + return fmt.Errorf("--quantity must not be negative") + } + if !oneOf(*req.ChargeType, "Dynamic", "Month", "Year", "Postpay") { + return fmt.Errorf("--charge-type must be one of Dynamic, Month, Year, or Postpay") + } + if cmd.Flags().Changed("machine-type") && !oneOf(*req.MachineType, "N", "C", "G", "O", "OS") { + return fmt.Errorf("--machine-type must be one of N, C, G, O, or OS") + } + if req.MachineType != nil && *req.MachineType == "G" { + if !cmd.Flags().Changed("gpu") || !cmd.Flags().Changed("gpu-type") { + return fmt.Errorf("--gpu and --gpu-type are required when --machine-type is G") + } + } else if cmd.Flags().Changed("gpu") || cmd.Flags().Changed("gpu-type") { + return fmt.Errorf("--gpu and --gpu-type require --machine-type G") + } + if cmd.Flags().Changed("gpu") && *req.GPU < 1 { + return fmt.Errorf("--gpu must be greater than 0") + } + if cmd.Flags().Changed("gpu-type") && !oneOf(*req.GpuType, "K80", "P40", "V100") { + return fmt.Errorf("--gpu-type must be one of K80, P40, or V100") + } + if req.IsolationGroup != nil && *req.IsolationGroup != "" && *req.Count > 8 { + return fmt.Errorf("--count cannot exceed 8 when --isolation-group-id is set") + } + if cmd.Flags().Changed("max-pods") && *req.MaxPods < 1 { + return fmt.Errorf("--max-pods must be greater than 0") + } + for name, value := range map[string]*string{"labels": req.Labels, "taints": req.Taints} { + if value != nil && *value != "" && len(strings.Split(*value, ",")) > 5 { + return fmt.Errorf("--%s accepts at most 5 comma-separated entries", name) + } + } + if *req.ChargeType == "Dynamic" && cmd.Flags().Changed("quantity") { + return fmt.Errorf("--quantity must not be set when --charge-type is Dynamic") + } + if err := validatePassword(*req.Password); err != nil { + return err + } + if err := bindEncodedValue(cmd, "user-data", userData, "user-data-base64", userDataB64, &req.UserData); err != nil { + return err + } + return bindEncodedValue(cmd, "init-script", initScript, "init-script-base64", initScriptB64, &req.InitScript) + }, + Run: func(cmd *cobra.Command, args []string) { + *req.ClusterId = ctx.PickResourceID(*req.ClusterId) + if req.NodeGroupId != nil && *req.NodeGroupId != "" { + *req.NodeGroupId = ctx.PickResourceID(*req.NodeGroupId) + } + for _, value := range []*string{req.SubnetId, req.ImageId, req.IsolationGroup} { + if value != nil && *value != "" { + *value = ctx.PickResourceID(*value) + } + } + req.Password = sdk.String(base64.StdEncoding.EncodeToString([]byte(*req.Password))) + resp, err := client.AddUK8SUHostNode(req) + if err != nil { + ctx.HandleError(err) + return + } + if ctx.Format() != cli.OutputTable { + ctx.PrintList(resp) + return + } + for _, id := range resp.NodeIds { + fmt.Fprintf(ctx.ProgressWriter(), "uk8s node[%s] is being added\n", id) + } + rows := make([]cli.OpResultRow, 0, len(resp.NodeIds)) + for _, id := range resp.NodeIds { + rows = append(rows, cli.OpResultRow{ResourceID: id, Action: "add", Status: "Adding"}) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.") + req.CPU = flags.Int("cpu", 0, "Required. vCPU cores per node.") + req.Mem = flags.Int("memory-mb", 0, "Required. Memory in MB per node.") + req.Count = flags.Int("count", 0, "Required. Number of nodes, 1-50.") + req.ChargeType = flags.String("charge-type", "", "Required. Billing mode.") + req.Password = flags.String("password", "", "Required. Plaintext node password. 8-30 chars from A-Z, a-z, 0-9 and ()~!@#$%^&*-+=_|{}[]:;'\\<>,.?/; must include at least 2 of {uppercase, lowercase, digit, special symbol}. Base64-encoded automatically before submission.") + req.MachineType = flags.String("machine-type", "", "Optional. Node machine type.") + req.NodeGroupId = flags.String("nodegroup-id", "", "Optional. Node group ID.") + req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID.") + req.ImageId = flags.String("image-id", "", "Optional. Image ID.") + req.BootDiskType = flags.String("boot-disk-type", "", "Optional. Boot disk type.") + req.BootDiskSize = flags.Int("boot-disk-size-gb", 0, "Optional. Boot disk size in GB.") + req.DataDiskType = flags.String("data-disk-type", "", "Optional. Data disk type.") + req.DataDiskSize = flags.Int("data-disk-size-gb", 0, "Optional. Data disk size in GB.") + req.MinimalCpuPlatform = flags.String("cpu-platform", "", "Optional. Minimum CPU platform.") + req.MaxPods = flags.Int("max-pods", 0, "Optional. Maximum pods per node.") + req.Quantity = flags.Int("quantity", 0, "Optional. Purchase duration.") + req.GPU = flags.Int("gpu", 0, "Optional. GPU count.") + req.GpuType = flags.String("gpu-type", "", "Optional. GPU type.") + req.IsolationGroup = flags.String("isolation-group-id", "", "Optional. Isolation group ID.") + req.Labels = flags.String("labels", "", "Optional. Comma-separated node labels.") + req.Taints = flags.String("taints", "", "Optional. Comma-separated node taints.") + req.DisableSchedule = flags.Bool("disable-schedule", false, "Optional. Disable scheduling on the new nodes.") + flags.StringVar(&userData, "user-data", "", "Optional. User data; base64-encoded automatically. Maximum decoded size: 16 KiB.") + flags.StringVar(&userDataB64, "user-data-base64", "", "Optional. Pre-encoded user data. Conflicts with --user-data.") + flags.StringVar(&initScript, "init-script", "", "Optional. Post-install script; base64-encoded automatically. Maximum decoded size: 16 KiB.") + flags.StringVar(&initScriptB64, "init-script-base64", "", "Optional. Pre-encoded post-install script. Conflicts with --init-script.") + req.Tag = flags.String("group", "", "Optional. Business group.") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + for _, name := range []string{"cluster-id", "cpu", "memory-mb", "count", "charge-type", "password"} { + cmd.MarkFlagRequired(name) + } + command.SetCompletion(cmd, "cluster-id", func() []string { + return listClusterIDs(ctx, []string{CLUSTER_RUNNING}, derefStr(req.Region), derefStr(req.ProjectId)) + }) + command.SetCompletion(cmd, "nodegroup-id", func() []string { + return listNodeGroupIDs(ctx, derefStr(req.ClusterId), derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "image-id", func() []string { + return listUK8SImageIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region), derefStr(req.Zone)) + }) + command.SetCompletion(cmd, "isolation-group-id", func() []string { + return listIsolationGroupIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetFlagValues(cmd, "machine-type", "N", "C", "G", "O", "OS") + command.SetFlagValues(cmd, "charge-type", "Dynamic", "Month", "Year", "Postpay") + command.SetFlagValues(cmd, "gpu-type", "K80", "P40", "V100") + return cmd +} diff --git a/products/uk8s/internal/uk8s/node_add_test.go b/products/uk8s/internal/uk8s/node_add_test.go new file mode 100644 index 0000000000..cd5f6b5ec6 --- /dev/null +++ b/products/uk8s/internal/uk8s/node_add_test.go @@ -0,0 +1,232 @@ +package uk8s + +import ( + "encoding/base64" + "strings" + "testing" +) + +// TestNodeAddRejectsBadPasswordBeforeRequest guards against reverting +// node_add.go's PreRunE back to a single-class uppercase check. The rule +// must match uk8s_create's validatePassword (8-30 chars, allowed set, +// at least 2 of {uppercase, lowercase, digit, special}). +func TestNodeAddRejectsBadPasswordBeforeRequest(t *testing.T) { + cases := []struct { + name string + pwd string + wantErr string + }{ + {name: "too short", pwd: "Ab1!", wantErr: "8-30"}, + {name: "illegal char", pwd: "Password 1", wantErr: "illegal characters"}, + {name: "single class", pwd: "abcdefgh", wantErr: "at least 2"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, requests := setupCommandGateway(t) + cmd := newNode(ctx) + cmd.SetArgs([]string{ + "add", + "--cluster-id", "uk8s-a", + "--cpu", "2", + "--memory-mb", "4096", + "--count", "1", + "--charge-type", "Dynamic", + "--password", tc.pwd, + }) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got %v", tc.wantErr, err) + } + if len(*requests) != 0 { + t.Fatal("invalid password must not reach the API") + } + }) + } +} + +// requiredNodeAddArgs returns the minimum flag set required for the +// uk8s node add command to pass cobra's MarkFlagRequired checks and +// PreRunE validation. IDs use the "id/name" form so PickResourceID +// is exercised (matching the create_test contract). +func requiredNodeAddArgs() []string { + return []string{ + "add", + "--cluster-id", "uk8s-a/name", + "--cpu", "2", + "--memory-mb", "4096", + "--count", "1", + "--charge-type", "Dynamic", + "--password", "Password1", + } +} + +// TestNodeAddRequestMatchesDocument mirrors TestCreateRequestMatchesDocument: +// with all optional flags set, the SDK request must match the documented +// field names, base64-encoded password/userdata/initscript, and stripped +// "id/name" suffixes. +func TestNodeAddRequestMatchesDocument(t *testing.T) { + ctx, requests := setupCommandGateway(t) + cmd := newNode(ctx) + args := append(requiredNodeAddArgs(), + "--machine-type", "G", + "--gpu", "1", + "--gpu-type", "V100", + "--image-id", "uimage-a/name", + "--subnet-id", "subnet-a/name", + "--nodegroup-id", "uk8sng-a/name", + "--isolation-group-id", "ig-a/name", + "--group", "Default", + "--max-pods", "110", + "--labels", "env=test,team=cli", + "--taints", "dedicated=test:NoSchedule", + "--user-data", "cloud-init", + "--init-script", "echo ready", + "--cpu-platform", "Intel/Cascadelake", + "--boot-disk-type", "CLOUD_SSD", + "--boot-disk-size-gb", "40", + "--data-disk-type", "CLOUD_SSD", + "--data-disk-size-gb", "100", + ) + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute node add: %v", err) + } + + want := map[string]string{ + "Action": "AddUK8SUHostNode", + "Region": "cn-sh2", + "ProjectId": "org-test", + "ClusterId": "uk8s-a", + "CPU": "2", + "Mem": "4096", + "Count": "1", + "ChargeType": "Dynamic", + "Password": base64.StdEncoding.EncodeToString([]byte("Password1")), + "MachineType": "G", + "GPU": "1", + "GpuType": "V100", + "ImageId": "uimage-a", + "SubnetId": "subnet-a", + "NodeGroupId": "uk8sng-a", + "IsolationGroup": "ig-a", + "Tag": "Default", + "MaxPods": "110", + "Labels": "env=test,team=cli", + "Taints": "dedicated=test:NoSchedule", + "UserData": base64.StdEncoding.EncodeToString([]byte("cloud-init")), + "InitScript": base64.StdEncoding.EncodeToString([]byte("echo ready")), + "MinimalCpuPlatform": "Intel/Cascadelake", + "BootDiskType": "CLOUD_SSD", + "BootDiskSize": "40", + "DataDiskType": "CLOUD_SSD", + "DataDiskSize": "100", + } + got := lastRequest(t, requests) + assertRequest(t, got, want) +} + +// TestNodeAddOmitsDocumentedOptionalFields mirrors +// TestCreateOmitsDocumentedOptionalFields: with only required flags, +// optional string fields must be absent from the request form so the SDK +// does not send empty-string values that confuse the backend. Int fields +// (GPU, MaxPods, BootDiskSize, DataDiskSize, Quantity) and the DisableSchedule +// bool all default to 0/false and the SDK marshals them as such — create +// avoids this with an explicit "optional" map that nils unset values, but +// node_add does not. Asserting their omission here would force the same +// pattern; left as a follow-up since it would change the SDK wire format +// of an existing command. +func TestNodeAddOmitsDocumentedOptionalFields(t *testing.T) { + ctx, requests := setupCommandGateway(t) + cmd := newNode(ctx) + cmd.SetArgs(requiredNodeAddArgs()) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute node add: %v", err) + } + got := lastRequest(t, requests) + + for _, key := range []string{ + "MachineType", "GpuType", "ImageId", "SubnetId", "NodeGroupId", + "IsolationGroup", "Tag", "Labels", "Taints", "UserData", + "InitScript", "MinimalCpuPlatform", "BootDiskType", + "DataDiskType", + } { + if _, ok := got[key]; ok { + t.Errorf("optional request field %s must be omitted", key) + } + } +} + +// TestNodeAddRejectsInvalidShapeBeforeRequest mirrors +// TestCreateRejectsInvalidShapeBeforeRequest: mutate one flag at a time +// and assert PreRunE rejects the mutation before any API call lands. +// Mutators operate through *[]string so they can append flags absent from +// requiredNodeAddArgs (e.g. machine-type, boot-disk-size-gb). +func TestNodeAddRejectsInvalidShapeBeforeRequest(t *testing.T) { + cases := []struct { + name string + mutate func(*[]string) + wantErr string + }{ + { + name: "cpu below range", + mutate: func(a *[]string) { setArg(a, "--cpu", "1") }, + wantErr: "--cpu must be between 2 and 64", + }, + { + name: "memory not multiple of 1024", + mutate: func(a *[]string) { setArg(a, "--memory-mb", "5000") }, + wantErr: "multiple of 1024", + }, + { + name: "count above range", + mutate: func(a *[]string) { setArg(a, "--count", "100") }, + wantErr: "--count must be between 1 and 50", + }, + { + name: "charge-type unknown", + mutate: func(a *[]string) { setArg(a, "--charge-type", "PayAsYouGo") }, + wantErr: "--charge-type must be one of", + }, + { + name: "machine-type G without gpu", + mutate: func(a *[]string) { setArg(a, "--machine-type", "G") }, + wantErr: "--gpu and --gpu-type are required", + }, + { + name: "boot disk size below range", + mutate: func(a *[]string) { setArg(a, "--boot-disk-size-gb", "10") }, + wantErr: "--boot-disk-size-gb must be between 40 and 500", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx, requests := setupCommandGateway(t) + cmd := newNode(ctx) + args := requiredNodeAddArgs() + tc.mutate(&args) + cmd.SetArgs(args) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want containing %q", err, tc.wantErr) + } + if len(*requests) != 0 { + t.Fatal("invalid mutation must not reach the API") + } + }) + } +} + +// setArg replaces the value of --flag in args if present, or appends +// --flag value if absent. Operates on *[]string so appends (which may +// reallocate the backing array) propagate to the caller. +func setArg(argsPtr *[]string, flag, value string) { + args := *argsPtr + for i := range args { + if args[i] == flag { + args[i+1] = value + return + } + } + *argsPtr = append(args, flag, value) +} diff --git a/products/uk8s/internal/uk8s/node_delete.go b/products/uk8s/internal/uk8s/node_delete.go new file mode 100644 index 0000000000..f671e1b63f --- /dev/null +++ b/products/uk8s/internal/uk8s/node_delete.go @@ -0,0 +1,69 @@ +package uk8s + +import ( + "fmt" + + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newNodeDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewDelUK8SClusterNodeV2Request() + var nodeIDs []string + var releaseDataUDisk bool + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete nodes from a UK8S cluster", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the UK8S node(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + *req.ClusterId = ctx.PickResourceID(*req.ClusterId) + results := make([]cli.OpResultRow, 0, len(nodeIDs)) + for _, idName := range nodeIDs { + id := ctx.PickResourceID(idName) + req.NodeId = sdk.String(id) + req.ReleaseDataUDisk = sdk.Bool(releaseDataUDisk) + if _, err := client.DelUK8SClusterNodeV2(req); err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "uk8s node[%s] deletion requested\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.") + flags.StringSliceVar(&nodeIDs, "node-id", nil, "Required. Node ID(s).") + flags.BoolVar(&releaseDataUDisk, "release-data-udisk", true, "Optional. Release data disks.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("cluster-id") + cmd.MarkFlagRequired("node-id") + command.SetCompletion(cmd, "cluster-id", func() []string { + return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId)) + }) + command.SetCompletion(cmd, "node-id", func() []string { + return listNodeIDs(ctx, derefStr(req.ClusterId), derefStr(req.ProjectId), derefStr(req.Region)) + }) + return cmd +} diff --git a/products/uk8s/internal/uk8s/node_describe.go b/products/uk8s/internal/uk8s/node_describe.go new file mode 100644 index 0000000000..62f57d2430 --- /dev/null +++ b/products/uk8s/internal/uk8s/node_describe.go @@ -0,0 +1,74 @@ +package uk8s + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newNodeDescribe(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewDescribeUK8SNodeRequest() + + cmd := &cobra.Command{ + Use: "describe", + Short: "Show details of a UK8S node", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + *req.ClusterId = ctx.PickResourceID(*req.ClusterId) + *req.Name = ctx.PickResourceID(*req.Name) + node, err := client.DescribeUK8SNode(req) + if err != nil { + ctx.HandleError(err) + return + } + if ctx.Format() != cli.OutputTable { + ctx.PrintList(node) + return + } + rows := []cli.DescribeRow{ + {Attribute: "Name", Content: node.Name}, + {Attribute: "Hostname", Content: node.Hostname}, + {Attribute: "InternalIP", Content: node.InternalIP}, + {Attribute: "ProviderID", Content: node.ProviderID}, + {Attribute: "CPUCapacity", Content: node.CPUCapacity}, + {Attribute: "MemoryCapacity", Content: node.MemoryCapacity}, + {Attribute: "PodCapacity", Content: fmt.Sprintf("%d", node.PodCapacity)}, + {Attribute: "AllocatedPods", Content: fmt.Sprintf("%d", node.AllocatedPodCount)}, + {Attribute: "Unschedulable", Content: fmt.Sprintf("%t", node.Unschedulable)}, + {Attribute: "KubeletVersion", Content: node.KubeletVersion}, + {Attribute: "KubeProxyVersion", Content: node.KubeProxyVersion}, + {Attribute: "ContainerRuntime", Content: node.ContainerRuntimeVersion}, + {Attribute: "OSImage", Content: node.OSImage}, + {Attribute: "KernelVersion", Content: node.KernelVersion}, + {Attribute: "Labels", Content: strings.Join(node.Labels, ",")}, + {Attribute: "Taints", Content: strings.Join(node.Taints, ",")}, + {Attribute: "Created", Content: time.Unix(int64(node.CreationTimestamp), 0).Format(time.RFC3339)}, + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.") + req.Name = flags.String("node-id", "", "Required. Node ID or IP.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("cluster-id") + cmd.MarkFlagRequired("node-id") + command.SetCompletion(cmd, "cluster-id", func() []string { + return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId)) + }) + command.SetCompletion(cmd, "node-id", func() []string { + return listNodeIDs(ctx, derefStr(req.ClusterId), derefStr(req.ProjectId), derefStr(req.Region)) + }) + return cmd +} diff --git a/products/uk8s/internal/uk8s/node_list.go b/products/uk8s/internal/uk8s/node_list.go new file mode 100644 index 0000000000..debac78081 --- /dev/null +++ b/products/uk8s/internal/uk8s/node_list.go @@ -0,0 +1,45 @@ +package uk8s + +import ( + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newNodeList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewListUK8SClusterNodeV2Request() + + cmd := &cobra.Command{ + Use: "list", + Short: "List nodes in a UK8S cluster", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + *req.ClusterId = ctx.PickResourceID(*req.ClusterId) + resp, err := client.ListUK8SClusterNodeV2(req) + if err != nil { + ctx.HandleError(err) + return + } + if ctx.Format() != cli.OutputTable { + ctx.PrintList(resp) + return + } + ctx.PrintList(nodeRows(resp.NodeSet)) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("cluster-id") + command.SetCompletion(cmd, "cluster-id", func() []string { + return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId)) + }) + return cmd +} diff --git a/products/uk8s/internal/uk8s/nodegroup.go b/products/uk8s/internal/uk8s/nodegroup.go new file mode 100644 index 0000000000..a863c5b0a8 --- /dev/null +++ b/products/uk8s/internal/uk8s/nodegroup.go @@ -0,0 +1,15 @@ +package uk8s + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newNodeGroup(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{Use: "nodegroup", Short: "Manage UK8S node groups"} + cmd.AddCommand(newNodeGroupAdd(ctx)) + cmd.AddCommand(newNodeGroupDelete(ctx)) + cmd.AddCommand(newNodeGroupList(ctx)) + return cmd +} diff --git a/products/uk8s/internal/uk8s/nodegroup_add.go b/products/uk8s/internal/uk8s/nodegroup_add.go new file mode 100644 index 0000000000..885eeff2d0 --- /dev/null +++ b/products/uk8s/internal/uk8s/nodegroup_add.go @@ -0,0 +1,173 @@ +package uk8s + +import ( + "fmt" + + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newNodeGroupAdd(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewAddUK8SNodeGroupRequest() + + cmd := &cobra.Command{ + Use: "add", + Short: "Add a UK8S node group", + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + for _, name := range []string{"cluster-id", "name", "machine-type", "cpu", "memory-mb", "image-id", "subnet-id", "boot-disk-type", "boot-disk-size-gb"} { + if !cmd.Flags().Changed(name) { + return fmt.Errorf("--%s is required", name) + } + } + if req.MachineType == nil || *req.MachineType == "" { + return fmt.Errorf("--machine-type is required") + } + if req.CPU == nil { + return fmt.Errorf("--cpu is required") + } + if req.Mem == nil { + return fmt.Errorf("--memory-mb is required") + } + if req.SubnetId == nil || *req.SubnetId == "" { + return fmt.Errorf("--subnet-id is required") + } + if req.ImageId == nil || *req.ImageId == "" { + return fmt.Errorf("--image-id is required") + } + if req.BootDiskType == nil || *req.BootDiskType == "" { + return fmt.Errorf("--boot-disk-type is required") + } + if req.BootDiskSize == nil { + return fmt.Errorf("--boot-disk-size-gb is required") + } + if !oneOf(*req.MachineType, "N", "C", "G", "O", "OS") { + return fmt.Errorf("--machine-type must be one of N, C, G, O, or OS") + } + if *req.CPU < 2 || *req.CPU > 64 { + return fmt.Errorf("--cpu must be between 2 and 64") + } + if *req.Mem < 4096 || *req.Mem > 262144 || *req.Mem%1024 != 0 { + return fmt.Errorf("--memory-mb must be between 4096 and 262144 and a multiple of 1024") + } + for _, item := range []struct { + name string + value *int + min, max int + }{ + {"boot-disk-size-gb", req.BootDiskSize, 40, 500}, + {"data-disk-size-gb", req.DataDiskSize, 20, 1000}, + } { + if *item.value != 0 && (*item.value < item.min || *item.value > item.max) { + return fmt.Errorf("--%s must be between %d and %d", item.name, item.min, item.max) + } + } + if *req.BootDiskSize < 40 || *req.BootDiskSize > 500 { + return fmt.Errorf("--boot-disk-size-gb must be between 40 and 500") + } + if *req.BootDiskType != "CLOUD_RSSD" { + return fmt.Errorf("--boot-disk-type must be CLOUD_RSSD") + } + if !oneOf(*req.ChargeType, "Dynamic", "Month", "Year") { + return fmt.Errorf("--charge-type must be one of Dynamic, Month, or Year") + } + if !oneOf(*req.MinimalCpuPlatform, "Intel/Auto", "Intel/IvyBridge", "Intel/Haswell", "Intel/Broadwell", "Intel/Skylake", "Intel/Cascadelake", "Intel/CascadelakeR", "Amd/Epyc2", "Amd/Auto") { + return fmt.Errorf("--cpu-platform must be one of Intel/Auto, Intel/IvyBridge, Intel/Haswell, Intel/Broadwell, Intel/Skylake, Intel/Cascadelake, Intel/CascadelakeR, Amd/Epyc2, Amd/Auto") + } + if cmd.Flags().Changed("machine-type") && *req.MachineType == "G" { + if !cmd.Flags().Changed("gpu") || !cmd.Flags().Changed("gpu-type") { + return fmt.Errorf("--gpu and --gpu-type are required when --machine-type is G") + } + } else if cmd.Flags().Changed("gpu") || cmd.Flags().Changed("gpu-type") { + return fmt.Errorf("--gpu and --gpu-type require --machine-type G") + } + if cmd.Flags().Changed("gpu") && *req.GPU < 1 { + return fmt.Errorf("--gpu must be greater than 0") + } + if cmd.Flags().Changed("gpu-type") && !oneOf(*req.GpuType, "K80", "P40", "V100") { + return fmt.Errorf("--gpu-type must be one of K80, P40, or V100") + } + // Keep the boot disk type explicit because the backend rejects a + // node-group request with an empty Disks.0.Type. Every other + // product field remains nil unless the user supplied its flag. + for name, clear := range map[string]func(){ + "data-disk-type": func() { req.DataDiskType = nil }, + "data-disk-size-gb": func() { req.DataDiskSize = nil }, + "group": func() { req.Tag = nil }, + "gpu": func() { req.GPU = nil }, + "gpu-type": func() { req.GpuType = nil }, + } { + if !cmd.Flags().Changed(name) { + clear() + } + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + *req.ClusterId = ctx.PickResourceID(*req.ClusterId) + for _, value := range []*string{req.ImageId, req.SubnetId} { + if value != nil && *value != "" { + *value = ctx.PickResourceID(*value) + } + } + resp, err := client.AddUK8SNodeGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "uk8s nodegroup[%s] added\n", resp.NodeGroupId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.NodeGroupId, Action: "add", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.") + req.NodeGroupName = flags.String("name", "", "Required. Node group name.") + req.MachineType = flags.String("machine-type", "", "Required. Node machine type. One of N, C, G, O, OS. G requires --gpu and --gpu-type.") + req.CPU = flags.Int("cpu", 0, "Required. vCPU cores per node. Range 2-64.") + req.Mem = flags.Int("memory-mb", 0, "Required. Memory in MB per node. Range 4096-262144, multiple of 1024.") + req.ImageId = flags.String("image-id", "", "Required. Compatible UK8S node image ID. Choose one with 'ucloud uk8s image list'.") + req.SubnetId = flags.String("subnet-id", "", "Required. Subnet ID; must belong to the cluster's VPC.") + req.BootDiskType = flags.String("boot-disk-type", "", "Required. System disk type. Only CLOUD_RSSD is supported for UK8S node pools.") + req.BootDiskSize = flags.Int("boot-disk-size-gb", 0, "Required. Boot disk size in GB. Range 40-500.") + req.DataDiskType = flags.String("data-disk-type", "", "Optional. Data disk type.") + req.DataDiskSize = flags.Int("data-disk-size-gb", 0, "Optional. Data disk size in GB.") + req.MinimalCpuPlatform = flags.String("cpu-platform", "Intel/Auto", "Required. Minimum CPU platform. Defaults to Intel/Auto. One of Intel/Auto, Intel/IvyBridge, Intel/Haswell, Intel/Broadwell, Intel/Skylake, Intel/Cascadelake, Intel/CascadelakeR, Amd/Epyc2, Amd/Auto.") + req.ChargeType = flags.String("charge-type", "Month", "Required. Billing mode. Defaults to Month.") + req.Tag = flags.String("group", "", "Optional. Business group.") + req.GPU = flags.Int("gpu", 0, "Optional. GPU count; requires machine type G.") + req.GpuType = flags.String("gpu-type", "", "Optional. GPU type: K80, P40, or V100.") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("cluster-id") + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("machine-type") + cmd.MarkFlagRequired("cpu") + cmd.MarkFlagRequired("memory-mb") + cmd.MarkFlagRequired("image-id") + cmd.MarkFlagRequired("subnet-id") + cmd.MarkFlagRequired("boot-disk-type") + cmd.MarkFlagRequired("boot-disk-size-gb") + cmd.MarkFlagRequired("charge-type") + cmd.MarkFlagRequired("cpu-platform") + command.SetCompletion(cmd, "cluster-id", func() []string { + return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId)) + }) + command.SetFlagValues(cmd, "machine-type", "N", "C", "G", "O", "OS") + command.SetFlagValues(cmd, "charge-type", "Dynamic", "Month", "Year") + command.SetFlagValues(cmd, "gpu-type", "K80", "P40", "V100") + command.SetFlagValues(cmd, "boot-disk-type", "CLOUD_RSSD") + command.SetFlagValues(cmd, "cpu-platform", "Intel/Auto", "Intel/IvyBridge", "Intel/Haswell", "Intel/Broadwell", "Intel/Skylake", "Intel/Cascadelake", "Intel/CascadelakeR", "Amd/Epyc2", "Amd/Auto") + command.SetFlagValues(cmd, "data-disk-type", "CLOUD_SSD", "CLOUD_NORMAL", "LOCAL_SSD", "LOCAL_NORMAL", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK") + command.SetCompletion(cmd, "image-id", func() []string { + return listUK8SImageIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region), derefStr(req.Zone)) + }) + return cmd +} diff --git a/products/uk8s/internal/uk8s/nodegroup_delete.go b/products/uk8s/internal/uk8s/nodegroup_delete.go new file mode 100644 index 0000000000..9ed66e6f7d --- /dev/null +++ b/products/uk8s/internal/uk8s/nodegroup_delete.go @@ -0,0 +1,59 @@ +package uk8s + +import ( + "fmt" + + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newNodeGroupDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewRemoveUK8SNodeGroupRequest() + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a UK8S node group", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the UK8S node group?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + *req.ClusterId = ctx.PickResourceID(*req.ClusterId) + *req.NodeGroupId = ctx.PickResourceID(*req.NodeGroupId) + if _, err := client.RemoveUK8SNodeGroup(req); err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "uk8s nodegroup[%s] deletion requested\n", *req.NodeGroupId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.NodeGroupId, Action: "delete", Status: "Deleting"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.") + req.NodeGroupId = flags.String("nodegroup-id", "", "Required. Node group ID.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("cluster-id") + cmd.MarkFlagRequired("nodegroup-id") + command.SetCompletion(cmd, "cluster-id", func() []string { + return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId)) + }) + command.SetCompletion(cmd, "nodegroup-id", func() []string { + return listNodeGroupIDs(ctx, derefStr(req.ClusterId), derefStr(req.ProjectId), derefStr(req.Region)) + }) + return cmd +} diff --git a/products/uk8s/internal/uk8s/nodegroup_list.go b/products/uk8s/internal/uk8s/nodegroup_list.go new file mode 100644 index 0000000000..ead04ab218 --- /dev/null +++ b/products/uk8s/internal/uk8s/nodegroup_list.go @@ -0,0 +1,45 @@ +package uk8s + +import ( + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func newNodeGroupList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewListUK8SNodeGroupRequest() + + cmd := &cobra.Command{ + Use: "list", + Short: "List UK8S node groups", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + *req.ClusterId = ctx.PickResourceID(*req.ClusterId) + resp, err := client.ListUK8SNodeGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + if ctx.Format() != cli.OutputTable { + ctx.PrintList(resp) + return + } + ctx.PrintList(nodeGroupRows(resp.NodeGroupList)) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + cmd.MarkFlagRequired("cluster-id") + command.SetCompletion(cmd, "cluster-id", func() []string { + return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId)) + }) + return cmd +} diff --git a/products/uk8s/internal/uk8s/poll.go b/products/uk8s/internal/uk8s/poll.go new file mode 100644 index 0000000000..d22f44f4b8 --- /dev/null +++ b/products/uk8s/internal/uk8s/poll.go @@ -0,0 +1,35 @@ +package uk8s + +import ( + "fmt" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// describeByID is the Poller's describe closure. The signature +// func(string, *request.CommonBase) (interface{}, error) is exactly what +// ctx.PollerTo expects; the returned interface{} is asserted back to +// *uk8ssdk.DescribeUK8SClusterResponse in the polling logic so the Poller can +// read resp.Status against the target states declared by the create verb. +func describeByID(ctx *cli.Context) func(string, *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + return func(clusterID string, common *request.CommonBase) (interface{}, error) { + req := client.NewDescribeUK8SClusterRequest() + if common != nil { + req.CommonBase = *common + } + req.ClusterId = sdk.String(clusterID) + resp, err := client.DescribeUK8SCluster(req) + if err != nil { + return nil, err + } + if resp.ClusterId == "" { + return nil, fmt.Errorf("cluster %q not found", clusterID) + } + return resp, nil + } +} diff --git a/products/uk8s/internal/uk8s/rows.go b/products/uk8s/internal/uk8s/rows.go new file mode 100644 index 0000000000..c939f2e865 --- /dev/null +++ b/products/uk8s/internal/uk8s/rows.go @@ -0,0 +1,198 @@ +package uk8s + +import ( + "fmt" + "strconv" + "strings" + "time" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// Table rows are deliberately explicit, matching the UHost commands: table +// output is a stable, compact view, while JSON/YAML keep the full SDK response. +type clusterRow struct { + ResourceID string + Name string + Version string + Type string + Status string + MasterCount int + NodeCount int + VPCID string + SubnetID string + APIServer string + CNIMode string + Runtime string + CreationTime string +} + +type nodeGroupRow struct { + ResourceID string + Name string + NodeCount int + Zone string + SubnetID string + Config string + Image string + ChargeType string + Tag string +} + +type imageRow struct { + ResourceID string + Name string + Kind string + Product string + OS string + SizeGB int + ZoneID int + Features string +} + +type nodeRow struct { + ResourceID string + Name string + Role string + Status string + NodeGroup string + PrivateIP string + Config string + Image string + MachineType string + Zone string + InstanceID string + Unschedulable bool + CreationTime string +} + +type versionRow struct { + K8sVersion string + ContainerdVersion string +} + +func clusterRows(clusters []uk8ssdk.ClusterSet) []clusterRow { + rows := make([]clusterRow, 0, len(clusters)) + for _, cluster := range clusters { + rows = append(rows, clusterRow{ + ResourceID: cluster.ClusterId, Name: cluster.ClusterName, + Version: cluster.K8sVersion, Type: cluster.ClusterType, Status: cluster.Status, + MasterCount: cluster.MasterCount, NodeCount: cluster.NodeCount, + VPCID: cluster.VPCId, SubnetID: cluster.SubnetId, APIServer: cluster.ApiServer, + CNIMode: cluster.CNIMode, Runtime: formatRuntime(cluster.RuntimeName, cluster.RuntimeVersion), + CreationTime: formatTimestamp(cluster.CreateTime), + }) + } + return rows +} + +func nodeGroupRows(groups []uk8ssdk.NodeGroupSet) []nodeGroupRow { + rows := make([]nodeGroupRow, 0, len(groups)) + for _, group := range groups { + rows = append(rows, nodeGroupRow{ + ResourceID: group.NodeGroupId, Name: group.NodeGroupName, NodeCount: len(group.NodeList), + Zone: group.Zone, SubnetID: group.SubnetId, + Config: fmt.Sprintf("cpu:%d memory:%dMB boot:%s:%dG", group.CPU, group.Mem, group.BootDiskType, group.BootDiskSize), + Image: fmt.Sprintf("%s|%s", group.ImageId, group.ImageName), ChargeType: group.ChargeType, Tag: group.Tag, + }) + } + return rows +} + +func imageRows(response *uk8ssdk.DescribeUK8SImageResponse) []imageRow { + rows := make([]imageRow, 0) + appendImages := func(images []uk8ssdk.ImageInfo, kind, product string) { + for _, image := range images { + rows = append(rows, imageRow{ + ResourceID: image.ImageId, Name: image.ImageName, Kind: kind, Product: product, + OS: image.OsName, SizeGB: image.ImageSize, ZoneID: image.ZoneId, + Features: strings.Join(image.Features, ","), + }) + } + } + appendImages(response.ImageSet, "Base", "UHost") + appendImages(response.CustomImageSet, "Custom", "UHost") + appendImages(response.PHostImageSet, "Base", "PHost") + appendImages(response.CustomPHostImageSet, "Custom", "PHost") + return rows +} + +func nodeRows(nodes []uk8ssdk.NodeInfoV2) []nodeRow { + rows := make([]nodeRow, 0, len(nodes)) + for _, node := range nodes { + rows = append(rows, nodeRow{ + ResourceID: node.NodeId, Name: node.InstanceName, Role: node.NodeRole, Status: node.NodeStatus, + NodeGroup: node.NodeGroupName, PrivateIP: nodePrivateIP(node), + Config: fmt.Sprintf("cpu:%d memory:%dMB", node.CPU, node.Memory), Image: node.OsName, + MachineType: node.MachineType, Zone: node.Zone, InstanceID: node.InstanceId, + Unschedulable: node.Unschedulable, CreationTime: formatTimestamp(node.CreateTime), + }) + } + return rows +} + +func clusterDescribeRows(cluster *uk8ssdk.DescribeUK8SClusterResponse) []cli.DescribeRow { + return []cli.DescribeRow{ + {Attribute: "ResourceID", Content: cluster.ClusterId}, + {Attribute: "Name", Content: cluster.ClusterName}, + {Attribute: "Version", Content: cluster.Version}, + {Attribute: "Status", Content: cluster.Status}, + {Attribute: "Type", Content: cluster.ClusterType}, + {Attribute: "APIServer", Content: cluster.ApiServer}, + {Attribute: "ExternalAPIServer", Content: cluster.ExternalApiServer}, + {Attribute: "VPCID", Content: cluster.VPCId}, + {Attribute: "SubnetID", Content: cluster.SubnetId}, + {Attribute: "ServiceCIDR", Content: cluster.ServiceCIDR}, + {Attribute: "PodCIDR", Content: cluster.PodCIDR}, + {Attribute: "NodeCIDR", Content: cluster.NodeCIDR}, + {Attribute: "ClusterDomain", Content: cluster.ClusterDomain}, + {Attribute: "CNIMode", Content: cluster.CNIMode}, + {Attribute: "Runtime", Content: formatRuntime(cluster.RuntimeName, cluster.RuntimeVersion)}, + {Attribute: "MonitorType", Content: cluster.MonitorType}, + {Attribute: "MasterCount", Content: strconv.Itoa(cluster.MasterCount)}, + {Attribute: "NodeCount", Content: strconv.Itoa(cluster.NodeCount)}, + {Attribute: "MasterResourceStatus", Content: cluster.MasterResourceStatus}, + {Attribute: "ExternalUlb", Content: cluster.ExternalUlb}, + {Attribute: "InternalUlb", Content: cluster.InternalUlb}, + {Attribute: "LbClass", Content: cluster.LbClass}, + {Attribute: "DeleteProtection", Content: strconv.Itoa(cluster.DeleteProtection)}, + {Attribute: "EnableUserAuth", Content: strconv.FormatBool(cluster.EnableUserAuth)}, + {Attribute: "DedicatedPodSubnet", Content: strconv.FormatBool(cluster.DedicatedPodSubnet)}, + {Attribute: "CreationTime", Content: formatTimestamp(cluster.CreateTime)}, + {Attribute: "UpdateTime", Content: formatTimestamp(cluster.UpdateTime)}, + {Attribute: "KubeProxyMode", Content: cluster.KubeProxy.Mode}, + {Attribute: "PodSubnetIds", Content: strings.Join(cluster.PodSubnetIds, ",")}, + {Attribute: "PodSubnetSecGroups", Content: strings.Join(cluster.PodSubnetSecGroups, ",")}, + {Attribute: "CACert", Content: cluster.CACert}, + {Attribute: "EtcdCert", Content: cluster.EtcdCert}, + {Attribute: "EtcdKey", Content: cluster.EtcdKey}, + } +} + +func nodePrivateIP(node uk8ssdk.NodeInfoV2) string { + for _, ip := range node.IPSet { + if ip.Type == "Private" { + return ip.IP + } + } + return "" +} + +func formatTimestamp(value int) string { + if value <= 0 { + return "" + } + return time.Unix(int64(value), 0).Format(time.RFC3339) +} + +func formatRuntime(name, version string) string { + if name == "" { + return version + } + if version == "" { + return name + } + return name + ":" + version +} diff --git a/products/uk8s/internal/uk8s/status.go b/products/uk8s/internal/uk8s/status.go new file mode 100644 index 0000000000..efb061ffa6 --- /dev/null +++ b/products/uk8s/internal/uk8s/status.go @@ -0,0 +1,19 @@ +package uk8s + +// UK8S cluster-domain state constants. Sourced from +// ucloud-sdk-go/services/uk8s/models.go UK8SClusterSet.Status docstring. +// Product-owned (formerly model/status); see §2.5 of the platform spec. +const ( + // Cluster is being initialized after creation. + CLUSTER_INITIALIZING = "INITIALIZING" + // Cluster is starting up. + CLUSTER_STARTING = "STARTING" + // Cluster creation failed. + CLUSTER_CREATEFAILED = "CREATEFAILED" + // Cluster is running normally. + CLUSTER_RUNNING = "RUNNING" + // Cluster has an error. + CLUSTER_ERROR = "ERROR" + // Cluster is in an abnormal state. + CLUSTER_ABNORMAL = "ABNORMAL" +) diff --git a/products/uk8s/internal/uk8s/version.go b/products/uk8s/internal/uk8s/version.go new file mode 100644 index 0000000000..4d66e10f83 --- /dev/null +++ b/products/uk8s/internal/uk8s/version.go @@ -0,0 +1,13 @@ +package uk8s + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newVersion(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{Use: "version", Short: "Inspect UK8S versions"} + cmd.AddCommand(newVersionList(ctx)) + return cmd +} diff --git a/products/uk8s/internal/uk8s/version_list.go b/products/uk8s/internal/uk8s/version_list.go new file mode 100644 index 0000000000..e20bcd1098 --- /dev/null +++ b/products/uk8s/internal/uk8s/version_list.go @@ -0,0 +1,45 @@ +package uk8s + +import ( + "github.com/spf13/cobra" + + uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +const defaultUK8SKind = "Dedicated" + +func newVersionList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uk8ssdk.NewClient) + req := client.NewGetUK8SVersionsRequest() + + cmd := &cobra.Command{ + Use: "list", + Short: "List versions supported by UK8S", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.GetUK8SVersions(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]versionRow, 0, len(resp.Data)) + for _, version := range resp.Data { + rows = append(rows, versionRow{ + K8sVersion: version.K8sVersion, + ContainerdVersion: version.ContainerdVersion, + }) + } + ctx.PrintList(rows) + }, + } + + cmd.Flags().SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.Kind = cmd.Flags().String("kind", defaultUK8SKind, "Optional. Cluster kind.") + command.SetFlagValues(cmd, "kind", defaultUK8SKind) + return cmd +} diff --git a/products/uk8s/product.go b/products/uk8s/product.go new file mode 100644 index 0000000000..af45fadc82 --- /dev/null +++ b/products/uk8s/product.go @@ -0,0 +1,21 @@ +package uk8s + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internaluk8s "github.com/ucloud/ucloud-cli/products/uk8s/internal/uk8s" +) + +type product struct{} + +// New returns the uk8s product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "uk8s", Commands: []string{"uk8s"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internaluk8s.NewCommand(ctx)} +} \ No newline at end of file diff --git a/products/uk8s/product.yaml b/products/uk8s/product.yaml new file mode 100644 index 0000000000..20fbfa8a0d --- /dev/null +++ b/products/uk8s/product.yaml @@ -0,0 +1,7 @@ +# products/uk8s/product.yaml — uk8s 产品元数据(归属真源,owner 自治维护) +name: uk8s +owners: + - Episkey-G +commands: + - uk8s +enabled: true \ No newline at end of file diff --git a/products/uk8s/testdata/cmdtree.golden b/products/uk8s/testdata/cmdtree.golden new file mode 100644 index 0000000000..18e1ccb897 --- /dev/null +++ b/products/uk8s/testdata/cmdtree.golden @@ -0,0 +1,159 @@ +ucloud uk8s use=uk8s short=Read and manipulate UK8S (UCloud Kubernetes Service) clusters +ucloud uk8s create use=create short=Create a UK8S cluster + flag=async short= default=false required= + flag=charge-type short= default= required= + flag=cluster-domain short= default= required= + flag=external-api-server short= default= required= + flag=group short= default= required= + flag=image-id short= default= required=true + flag=init-script short= default= required= + flag=init-script-base64 short= default= required= + flag=k8s-version short= default= required=true + flag=kube-proxy-mode short= default= required= + flag=master-boot-disk-size-gb short= default=0 required= + flag=master-boot-disk-type short= default= required= + flag=master-cpu short= default=0 required=true + flag=master-cpu-platform short= default= required= + flag=master-data-disk-size-gb short= default=0 required= + flag=master-data-disk-type short= default= required= + flag=master-machine-type short= default= required=true + flag=master-memory-mb short= default=0 required=true + flag=master-zone short= default=[] required=true + flag=name short= default= required=true + flag=node-boot-disk-size-gb short= default=0 required= + flag=node-boot-disk-type short= default= required= + flag=node-count short= default=0 required=true + flag=node-cpu short= default=0 required=true + flag=node-cpu-platform short= default= required= + flag=node-data-disk-size-gb short= default=0 required= + flag=node-data-disk-type short= default= required= + flag=node-gpu short= default=0 required= + flag=node-gpu-type short= default= required= + flag=node-isolation-group-id short= default= required= + flag=node-labels short= default= required= + flag=node-machine-type short= default= required=true + flag=node-max-pods short= default=0 required= + flag=node-memory-mb short= default=0 required=true + flag=node-taints short= default= required= + flag=node-zone short= default= required=true + flag=password short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=0 required= + flag=region short= default= required= + flag=service-cidr short= default= required=true + flag=subnet-id short= default= required=true + flag=user-data short= default= required= + flag=user-data-base64 short= default= required= + flag=vpc-id short= default= required=true +ucloud uk8s delete use=delete short=Delete UK8S clusters + flag=cluster-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=release-eip short= default=false required= + flag=release-udisk short= default=false required= + flag=yes short=y default=false required= +ucloud uk8s describe use=describe short=Show details of a UK8S cluster + flag=cluster-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uk8s get-config use=get-config short=Print a UK8S cluster kubeconfig + flag=cluster-id short= default= required=true + flag=external short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uk8s image use=image short=Inspect UK8S images +ucloud uk8s image list use=list short=List images supported by UK8S + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud uk8s list use=list short=List UK8S clusters + flag=cluster-id short= default= required= + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uk8s node use=node short=Manage UK8S nodes +ucloud uk8s node add use=add short=Add UHost nodes to a UK8S cluster + flag=boot-disk-size-gb short= default=0 required= + flag=boot-disk-type short= default= required= + flag=charge-type short= default= required=true + flag=cluster-id short= default= required=true + flag=count short= default=0 required=true + flag=cpu short= default=0 required=true + flag=cpu-platform short= default= required= + flag=data-disk-size-gb short= default=0 required= + flag=data-disk-type short= default= required= + flag=disable-schedule short= default=false required= + flag=gpu short= default=0 required= + flag=gpu-type short= default= required= + flag=group short= default= required= + flag=image-id short= default= required= + flag=init-script short= default= required= + flag=init-script-base64 short= default= required= + flag=isolation-group-id short= default= required= + flag=labels short= default= required= + flag=machine-type short= default= required= + flag=max-pods short= default=0 required= + flag=memory-mb short= default=0 required=true + flag=nodegroup-id short= default= required= + flag=password short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=0 required= + flag=region short= default= required= + flag=subnet-id short= default= required= + flag=taints short= default= required= + flag=user-data short= default= required= + flag=user-data-base64 short= default= required= + flag=zone short= default= required= +ucloud uk8s node delete use=delete short=Delete nodes from a UK8S cluster + flag=cluster-id short= default= required=true + flag=node-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=release-data-udisk short= default=true required= + flag=yes short=y default=false required= +ucloud uk8s node describe use=describe short=Show details of a UK8S node + flag=cluster-id short= default= required=true + flag=node-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uk8s node list use=list short=List nodes in a UK8S cluster + flag=cluster-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uk8s nodegroup use=nodegroup short=Manage UK8S node groups +ucloud uk8s nodegroup add use=add short=Add a UK8S node group + flag=boot-disk-size-gb short= default=0 required=true + flag=boot-disk-type short= default= required=true + flag=charge-type short= default=Month required=true + flag=cluster-id short= default= required=true + flag=cpu short= default=0 required=true + flag=cpu-platform short= default=Intel/Auto required=true + flag=data-disk-size-gb short= default=0 required= + flag=data-disk-type short= default= required= + flag=gpu short= default=0 required= + flag=gpu-type short= default= required= + flag=group short= default= required= + flag=image-id short= default= required=true + flag=machine-type short= default= required=true + flag=memory-mb short= default=0 required=true + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=subnet-id short= default= required=true + flag=zone short= default= required= +ucloud uk8s nodegroup delete use=delete short=Delete a UK8S node group + flag=cluster-id short= default= required=true + flag=nodegroup-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= +ucloud uk8s nodegroup list use=list short=List UK8S node groups + flag=cluster-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uk8s version use=version short=Inspect UK8S versions +ucloud uk8s version list use=list short=List versions supported by UK8S + flag=kind short= default=Dedicated required= + flag=project-id short= default= required= + flag=region short= default= required= diff --git a/products/uk8s/testdata/completion.golden b/products/uk8s/testdata/completion.golden new file mode 100644 index 0000000000..9490a8d8ef --- /dev/null +++ b/products/uk8s/testdata/completion.golden @@ -0,0 +1,76 @@ +ucloud uk8s create charge-type static Dynamic,Month,Year +ucloud uk8s create external-api-server static No,Yes +ucloud uk8s create image-id dynamic +ucloud uk8s create k8s-version dynamic +ucloud uk8s create kube-proxy-mode static iptables,ipvs +ucloud uk8s create master-boot-disk-type static CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD +ucloud uk8s create master-cpu-platform static Intel/Auto,Intel/Broadwell,Intel/Cascadelake,Intel/Haswell,Intel/IvyBridge,Intel/Skylake +ucloud uk8s create master-data-disk-type static ,CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD +ucloud uk8s create master-machine-type static C,N,O,OS +ucloud uk8s create node-boot-disk-type static CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD +ucloud uk8s create node-cpu-platform static Intel/Auto,Intel/Broadwell,Intel/Cascadelake,Intel/Haswell,Intel/IvyBridge,Intel/Skylake +ucloud uk8s create node-data-disk-type static ,CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD +ucloud uk8s create node-gpu-type static K80,P40,V100 +ucloud uk8s create node-isolation-group-id dynamic +ucloud uk8s create node-machine-type static C,G,N,O,OS +ucloud uk8s create project-id dynamic +ucloud uk8s create region dynamic +ucloud uk8s create subnet-id static +ucloud uk8s create vpc-id dynamic +ucloud uk8s delete cluster-id dynamic +ucloud uk8s delete project-id dynamic +ucloud uk8s delete region dynamic +ucloud uk8s describe cluster-id dynamic +ucloud uk8s describe project-id dynamic +ucloud uk8s describe region dynamic +ucloud uk8s get-config cluster-id dynamic +ucloud uk8s get-config project-id dynamic +ucloud uk8s get-config region dynamic +ucloud uk8s image list project-id dynamic +ucloud uk8s image list region dynamic +ucloud uk8s image list zone dynamic +ucloud uk8s list cluster-id dynamic +ucloud uk8s list project-id dynamic +ucloud uk8s list region dynamic +ucloud uk8s node add charge-type static Dynamic,Month,Postpay,Year +ucloud uk8s node add cluster-id dynamic +ucloud uk8s node add gpu-type static K80,P40,V100 +ucloud uk8s node add image-id dynamic +ucloud uk8s node add isolation-group-id dynamic +ucloud uk8s node add machine-type static C,G,N,O,OS +ucloud uk8s node add nodegroup-id static +ucloud uk8s node add project-id dynamic +ucloud uk8s node add region dynamic +ucloud uk8s node add zone dynamic +ucloud uk8s node delete cluster-id dynamic +ucloud uk8s node delete node-id static +ucloud uk8s node delete project-id dynamic +ucloud uk8s node delete region dynamic +ucloud uk8s node describe cluster-id dynamic +ucloud uk8s node describe node-id static +ucloud uk8s node describe project-id dynamic +ucloud uk8s node describe region dynamic +ucloud uk8s node list cluster-id dynamic +ucloud uk8s node list project-id dynamic +ucloud uk8s node list region dynamic +ucloud uk8s nodegroup add boot-disk-type static CLOUD_RSSD +ucloud uk8s nodegroup add charge-type static Dynamic,Month,Year +ucloud uk8s nodegroup add cluster-id dynamic +ucloud uk8s nodegroup add cpu-platform static Amd/Auto,Amd/Epyc2,Intel/Auto,Intel/Broadwell,Intel/Cascadelake,Intel/CascadelakeR,Intel/Haswell,Intel/IvyBridge,Intel/Skylake +ucloud uk8s nodegroup add data-disk-type static CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD +ucloud uk8s nodegroup add gpu-type static K80,P40,V100 +ucloud uk8s nodegroup add image-id dynamic +ucloud uk8s nodegroup add machine-type static C,G,N,O,OS +ucloud uk8s nodegroup add project-id dynamic +ucloud uk8s nodegroup add region dynamic +ucloud uk8s nodegroup add zone dynamic +ucloud uk8s nodegroup delete cluster-id dynamic +ucloud uk8s nodegroup delete nodegroup-id static +ucloud uk8s nodegroup delete project-id dynamic +ucloud uk8s nodegroup delete region dynamic +ucloud uk8s nodegroup list cluster-id dynamic +ucloud uk8s nodegroup list project-id dynamic +ucloud uk8s nodegroup list region dynamic +ucloud uk8s version list kind static Dedicated +ucloud uk8s version list project-id dynamic +ucloud uk8s version list region dynamic diff --git a/products/ukafka/internal/ukafka/add_node.go b/products/ukafka/internal/ukafka/add_node.go new file mode 100644 index 0000000000..d51062290f --- /dev/null +++ b/products/ukafka/internal/ukafka/add_node.go @@ -0,0 +1,64 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newAddNode ucloud ukafka add-node +func newAddNode(ctx *cli.Context) *cobra.Command { + var async *bool + var nodeCount *int + var nodeType *string + var instanceID *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewAddUKafkaInstanceNodeRequest() + + cmd := &cobra.Command{ + Use: "add-node", + Short: "Add nodes to UKafka instance", + Long: "Add nodes to UKafka instance", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + req.InstanceId = sdk.String(*instanceID) + req.NodeCount = sdk.String(fmt.Sprintf("%d", *nodeCount)) + req.NodeType = sdk.String(*nodeType) + + _, err := client.AddUKafkaInstanceNode(req) + if err != nil { + ctx.HandleError(err) + return + } + + text := fmt.Sprintf("ukafka[%s] adding %d node(s)", *instanceID, *nodeCount) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUKafkaInstanceByID(ctx)).Spoll(*instanceID, text, []string{StateRunning, StateAbnormal}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *instanceID, Action: "add-node", Status: "Running"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + nodeCount = flags.Int("node-count", 1, "Required. Number of nodes to add") + nodeType = flags.String("node-type", "", "Required. Node type") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.Bool("async", false, "Optional. Do not wait for operation to finish") + + cmd.MarkFlagRequired("ukafka-id") + cmd.MarkFlagRequired("node-type") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/appversion.go b/products/ukafka/internal/ukafka/appversion.go new file mode 100644 index 0000000000..d4bd810165 --- /dev/null +++ b/products/ukafka/internal/ukafka/appversion.go @@ -0,0 +1,42 @@ +package ukafka + +import ( + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newAppVersion ucloud ukafka app-version +func newAppVersion(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewListUKafkaFrameworkVersionRequest() + cmd := &cobra.Command{ + Use: "app-version", + Short: "List available Kafka versions", + Long: "List available Kafka versions", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.ListUKafkaFrameworkVersion(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []VersionRow{} + for _, v := range resp.FrameworkVersions { + row := VersionRow{ + Version: v.Version, + Label: v.Label, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + return cmd +} diff --git a/products/ukafka/internal/ukafka/check_topic.go b/products/ukafka/internal/ukafka/check_topic.go new file mode 100644 index 0000000000..8ff559b799 --- /dev/null +++ b/products/ukafka/internal/ukafka/check_topic.go @@ -0,0 +1,95 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// IsUKafkaTopicNameExistResponse 自定义响应结构 +type IsUKafkaTopicNameExistResponse struct { + RetCode int `json:"RetCode"` + Action string `json:"Action"` + IsExist string `json:"IsExist"` + Message string `json:"Message"` +} + +// newCheckTopic ucloud ukafka check-topic +func newCheckTopic(ctx *cli.Context) *cobra.Command { + var instanceID *string + var topicName *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewIsUKafkaTopicNameExistRequest() + + cmd := &cobra.Command{ + Use: "check-topic", + Short: "Check if a topic name exists in UKafka instance", + Long: "Check if a topic name exists in UKafka instance", + Run: func(cmd *cobra.Command, args []string) { + // 使用 GenericInvoke 调用 API + genReq := client.Client.NewGenericRequest() + genReq.SetAction("IsUKafkaTopicNameExist") + genReq.SetRegion(*req.Region) + genReq.SetZone(*req.Zone) + if req.ProjectId != nil && *req.ProjectId != "" { + genReq.SetProjectId(*req.ProjectId) + } + + payload := map[string]interface{}{ + "ClusterInstanceId": *instanceID, + "TopicName": *topicName, + } + genReq.SetPayload(payload) + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + ctx.HandleError(err) + return + } + + var resp IsUKafkaTopicNameExistResponse + if err := genResp.Unmarshal(&resp); err != nil { + ctx.HandleError(fmt.Errorf("parse response: %w", err)) + return + } + + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("API error: RetCode=%d, Message=%s", resp.RetCode, resp.Message)) + return + } + + // 输出结构化结果 + rows := []cli.DescribeRow{ + {Attribute: "InstanceID", Content: *instanceID}, + {Attribute: "TopicName", Content: *topicName}, + {Attribute: "Exists", Content: resp.IsExist}, + } + ctx.PrintList(rows) + + // 同时输出事件结果 + ctx.EmitResult(cli.OpResultRow{ + ResourceID: *instanceID, + Action: "check-topic", + Status: resp.IsExist, + }) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + topicName = flags.String("topic-name", "", "Required. Topic name to check") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + cmd.MarkFlagRequired("ukafka-id") + cmd.MarkFlagRequired("topic-name") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/cmd.go b/products/ukafka/internal/ukafka/cmd.go new file mode 100644 index 0000000000..a5ed0bc893 --- /dev/null +++ b/products/ukafka/internal/ukafka/cmd.go @@ -0,0 +1,30 @@ +package ukafka + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `ukafka` root command and mounts the subcommands. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ukafka", + Short: "Manage UKafka instances", + Long: "Manage UKafka instances", + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newNodeConf(ctx)) + cmd.AddCommand(newAppVersion(ctx)) + cmd.AddCommand(newAddNode(ctx)) + cmd.AddCommand(newDescribeConsumer(ctx)) + cmd.AddCommand(newCheckTopic(ctx)) + cmd.AddCommand(newListConsumers(ctx)) + cmd.AddCommand(newListTopics(ctx)) + cmd.AddCommand(newModifyType(ctx)) + cmd.AddCommand(newResizeDisk(ctx)) + return cmd +} diff --git a/products/ukafka/internal/ukafka/create.go b/products/ukafka/internal/ukafka/create.go new file mode 100644 index 0000000000..387d2ce4f1 --- /dev/null +++ b/products/ukafka/internal/ukafka/create.go @@ -0,0 +1,72 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud ukafka create +func newCreate(ctx *cli.Context) *cobra.Command { + var async *bool + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewCreateUKafkaInstanceRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create UKafka instance", + Long: "Create UKafka instance", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + resp, err := client.CreateUKafkaInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("ukafka[%s] is creating", resp.InstanceId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUKafkaInstanceByID(ctx)).Spoll(resp.InstanceId, text, []string{StateRunning, StateAbnormal}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.InstanceId, Action: "create", Status: "Creating"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.InstanceName = flags.String("name", "", "Required. Instance name") + req.FrameworkVersion = flags.String("kafka-version", "", "Required. Kafka version, e.g. 2.12-2.4.1") + req.NodeType = flags.String("node-type", "", "Required. Node type") + req.DiskSize = flags.Int("disk-size-gb", 0, "Required. Disk size in GB") + req.NodeCount = flags.Int("node-count", 3, "Optional. Node count, default 3") + req.LogRetentionHours = flags.String("log-retention-hours", "72", "Optional. Log retention hours (1-240), default 72") + req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID") + req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID") + req.BusinessId = flags.String("business-id", "", "Optional. Business group ID") + req.Quantity = flags.String("quantity", "1", "Optional. Instance quantity, default 1") + req.DiskControllerType = flags.String("disk-controller-type", "NONE", "Optional. Disk controller type: NONE or CLEAN") + req.DiskThreshold = flags.String("disk-threshold", "90", "Optional. Disk cleanup threshold (70-90), default 90") + req.IsSecurityEnabled = flags.String("enable-security", "false", "Optional. Enable security group: true or false") + async = flags.Bool("async", false, "Optional. Do not wait for creation to finish") + + // Bind common params with Tab completion + // Note: UKafka SDK uses *string for ChargeType/Quantity (not *int), + // so we cannot use ctx.BindCommonParams which assumes standard types. + // Instead we bind region/zone/project-id individually with completion. + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("kafka-version") + cmd.MarkFlagRequired("node-type") + cmd.MarkFlagRequired("disk-size-gb") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/delete.go b/products/ukafka/internal/ukafka/delete.go new file mode 100644 index 0000000000..d9f1eba566 --- /dev/null +++ b/products/ukafka/internal/ukafka/delete.go @@ -0,0 +1,59 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete ucloud ukafka delete +func newDelete(ctx *cli.Context) *cobra.Command { + var yes *bool + var instanceIDs *[]string + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewDeleteUKafkaInstanceRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete UKafka instances", + Long: "Delete UKafka instances", + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(*yes, "Are you sure to delete UKafka instance(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range *instanceIDs { + id := ctx.PickResourceID(idName) + req.InstanceId = &id + _, err := client.DeleteUKafkaInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "ukafka[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + instanceIDs = flags.StringSlice("ukafka-id", nil, "Required. Instance ID(s) to delete") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + yes = flags.BoolP("yes", "y", false, "Optional. Skip confirmation prompt") + + cmd.MarkFlagRequired("ukafka-id") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/describe.go b/products/ukafka/internal/ukafka/describe.go new file mode 100644 index 0000000000..b0f9d0a9f4 --- /dev/null +++ b/products/ukafka/internal/ukafka/describe.go @@ -0,0 +1,182 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// DescribeUKafkaInstanceResponse 自定义响应结构 +type DescribeUKafkaInstanceResponse struct { + RetCode int `json:"RetCode"` + Action string `json:"Action"` + ClusterSet []ClusterInfo `json:"ClusterSet"` + Message string `json:"Message"` +} + +// ClusterInfo 实例信息 +type ClusterInfo struct { + Zone string `json:"Zone"` + ClusterInstanceId string `json:"ClusterInstanceId"` + ClusterInstanceName string `json:"ClusterInstanceName"` + Remark string `json:"Remark"` + Tag string `json:"Tag"` + Framework string `json:"Framework"` + FrameworkVersion string `json:"FrameworkVersion"` + NetworkId string `json:"NetworkId"` + VPCId string `json:"VPCId"` + SubnetId string `json:"SubnetId"` + BusinessId string `json:"BusinessId"` + UHostSet []Broker `json:"UHostSet"` + IsOpenSecgroup bool `json:"IsOpenSecgroup"` + ChargeType string `json:"ChargeType"` + AutoRenew string `json:"AutoRenew"` + ValidBrokerNum int `json:"ValidBrokerNum"` + UHostCount int `json:"UHostCount"` + ExpireTime int `json:"ExpireTime"` + CreateTime int `json:"CreateTime"` + RunningTime int `json:"RunningTime"` + State string `json:"State"` +} + +// Broker 节点信息 +type Broker struct { + BrokerId string `json:"BrokerId"` + UHostId string `json:"UHostId"` + ResourceId string `json:"ResourceId"` + UHostRole string `json:"UHostRole"` + UHostName string `json:"UHostName"` + DomainName string `json:"DomainName"` + Remark string `json:"Remark"` + CreateTime int `json:"CreateTime"` + ExpireTime int `json:"ExpireTime"` + InstanceGroupType string `json:"InstanceGroupType"` + SecurityGroupId string `json:"SecurityGroupId"` + State string `json:"State"` + ZooKeeper string `json:"ZooKeeper"` + UHostConfig UHostConfig `json:"UHostConfig"` + IPSet []IPInfo `json:"IPSet"` + KafkaPort int `json:"KafkaPort"` + ZooKeeperPort int `json:"ZooKeeperPort"` +} + +// UHostConfig 节点配置 +type UHostConfig struct { + CPU int `json:"CPU"` + Memory int `json:"Memory"` + DataDiskSize int `json:"DataDiskSize"` + DiskType string `json:"DiskType"` +} + +// IPInfo IP信息 +type IPInfo struct { + Type string `json:"Type"` + IP string `json:"IP"` +} + +// newDescribe ucloud ukafka describe +func newDescribe(ctx *cli.Context) *cobra.Command { + var instanceID *string + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewDescribeUKafkaInstanceRequest() + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe UKafka instance details", + Long: "Describe UKafka instance details", + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*instanceID) + + // 使用 GenericInvoke 绕过 SDK 类型问题 + genReq := client.Client.NewGenericRequest() + genReq.SetAction("DescribeUKafkaInstance") + genReq.SetRegion(*req.Region) + genReq.SetZone(*req.Zone) + if req.ProjectId != nil && *req.ProjectId != "" { + genReq.SetProjectId(*req.ProjectId) + } + + payload := map[string]interface{}{ + "ClusterInstanceId": id, + } + genReq.SetPayload(payload) + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + ctx.HandleError(err) + return + } + + var resp DescribeUKafkaInstanceResponse + if err := genResp.Unmarshal(&resp); err != nil { + ctx.HandleError(fmt.Errorf("parse response: %w", err)) + return + } + + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("API error: RetCode=%d, Message=%s", resp.RetCode, resp.Message)) + return + } + + if len(resp.ClusterSet) == 0 { + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "describe", Status: "NotFound"}) + return + } + + cluster := resp.ClusterSet[0] + rows := []cli.DescribeRow{ + {Attribute: "InstanceID", Content: cluster.ClusterInstanceId}, + {Attribute: "InstanceName", Content: cluster.ClusterInstanceName}, + {Attribute: "Region", Content: *req.Region}, + {Attribute: "Zone", Content: cluster.Zone}, + {Attribute: "State", Content: cluster.State}, + {Attribute: "Framework", Content: cluster.Framework}, + {Attribute: "FrameworkVersion", Content: cluster.FrameworkVersion}, + {Attribute: "VPCId", Content: cluster.VPCId}, + {Attribute: "SubnetId", Content: cluster.SubnetId}, + {Attribute: "BusinessId", Content: cluster.BusinessId}, + {Attribute: "ChargeType", Content: cluster.ChargeType}, + {Attribute: "AutoRenew", Content: cluster.AutoRenew}, + {Attribute: "Remark", Content: cluster.Remark}, + } + + // Add node information + if len(cluster.UHostSet) > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "--- Nodes ---", Content: fmt.Sprintf("%d nodes", len(cluster.UHostSet))}) + for i, node := range cluster.UHostSet { + prefix := fmt.Sprintf("Node[%d]", i) + var ip string + if len(node.IPSet) > 0 { + ip = node.IPSet[0].IP + } + rows = append(rows, + cli.DescribeRow{Attribute: prefix + ".NodeID", Content: node.UHostId}, + cli.DescribeRow{Attribute: prefix + ".NodeName", Content: node.UHostName}, + cli.DescribeRow{Attribute: prefix + ".NodeRole", Content: node.UHostRole}, + cli.DescribeRow{Attribute: prefix + ".State", Content: node.State}, + cli.DescribeRow{Attribute: prefix + ".IP", Content: ip}, + cli.DescribeRow{Attribute: prefix + ".CPU", Content: fmt.Sprintf("%d", node.UHostConfig.CPU)}, + cli.DescribeRow{Attribute: prefix + ".Memory", Content: fmt.Sprintf("%dMB", node.UHostConfig.Memory)}, + cli.DescribeRow{Attribute: prefix + ".DiskSize", Content: fmt.Sprintf("%dGB", node.UHostConfig.DataDiskSize)}, + cli.DescribeRow{Attribute: prefix + ".DiskType", Content: node.UHostConfig.DiskType}, + cli.DescribeRow{Attribute: prefix + ".KafkaPort", Content: fmt.Sprintf("%d", node.KafkaPort)}, + ) + } + } + ctx.PrintList(rows) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID to describe") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + cmd.MarkFlagRequired("ukafka-id") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/describe_consumer.go b/products/ukafka/internal/ukafka/describe_consumer.go new file mode 100644 index 0000000000..6f8c1344ab --- /dev/null +++ b/products/ukafka/internal/ukafka/describe_consumer.go @@ -0,0 +1,68 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDescribeConsumer ucloud ukafka describe-consumer +func newDescribeConsumer(ctx *cli.Context) *cobra.Command { + var instanceID *string + var consumerGroup *string + var consumerType *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewDescribeUKafkaConsumerRequest() + + cmd := &cobra.Command{ + Use: "describe-consumer", + Short: "Describe Kafka consumer group details", + Long: "Describe Kafka consumer group details", + Run: func(cmd *cobra.Command, args []string) { + req.ClusterInstanceId = sdk.String(*instanceID) + req.ConsumerGroup = sdk.String(*consumerGroup) + req.Type = sdk.String(*consumerType) + + resp, err := client.DescribeUKafkaConsumer(req) + if err != nil { + ctx.HandleError(err) + return + } + + rows := []cli.DescribeRow{ + {Attribute: "GroupName", Content: resp.GroupName}, + {Attribute: "Type", Content: resp.Type}, + } + if len(resp.Topics) > 0 { + for i, topic := range resp.Topics { + rows = append(rows, cli.DescribeRow{ + Attribute: fmt.Sprintf("Topic[%d]", i), + Content: topic, + }) + } + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + consumerGroup = flags.String("consumer-group", "", "Required. Consumer group name") + consumerType = flags.String("type", "", "Required. Consumer group type (e.g. ZK, KF)") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + cmd.MarkFlagRequired("ukafka-id") + cmd.MarkFlagRequired("consumer-group") + cmd.MarkFlagRequired("type") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/list.go b/products/ukafka/internal/ukafka/list.go new file mode 100644 index 0000000000..e6645bc518 --- /dev/null +++ b/products/ukafka/internal/ukafka/list.go @@ -0,0 +1,133 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// ListUKafkaInstanceResponse 自定义响应结构,修复 SDK 类型问题 +type ListUKafkaInstanceResponse struct { + RetCode int `json:"RetCode"` + Action string `json:"Action"` + TotalCount int `json:"TotalCount"` + ClusterSet []ClusterSetRaw `json:"ClusterSet"` + Message string `json:"Message"` +} + +// ClusterSetRaw 实例信息 +type ClusterSetRaw struct { + Zone string `json:"Zone"` + ClusterInstanceId string `json:"ClusterInstanceId"` + ClusterInstanceName string `json:"ClusterInstanceName"` + Framework string `json:"Framework"` + FrameworkVersion string `json:"FrameworkVersion"` + Remark string `json:"Remark"` + CreateTime int `json:"CreateTime"` + RunningTime int `json:"RunningTime"` + ExpireTime int `json:"ExpireTime"` + AutoRenew string `json:"AutoRenew"` + ChargeType string `json:"ChargeType"` + UHostCount int `json:"UHostCount"` + State string `json:"State"` + Tag string `json:"Tag"` + InstanceGroupType string `json:"InstanceGroupType"` + VPCId string `json:"VPCId"` + SubnetId string `json:"SubnetId"` + BusinessId string `json:"BusinessId"` +} + +// newList ucloud ukafka list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewListUKafkaInstanceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List UKafka instances", + Long: "List UKafka instances", + Run: func(cmd *cobra.Command, args []string) { + // 使用 GenericInvoke 绕过 SDK 类型问题 + genReq := client.Client.NewGenericRequest() + genReq.SetAction("ListUKafkaInstance") + genReq.SetRegion(*req.Region) + genReq.SetZone(*req.Zone) + if req.ProjectId != nil && *req.ProjectId != "" { + genReq.SetProjectId(*req.ProjectId) + } + + // 构建额外参数 + payload := map[string]interface{}{} + if req.Offset != nil && *req.Offset != "0" { + payload["Offset"] = *req.Offset + } + if req.Limit != nil && *req.Limit != "60" { + payload["Limit"] = *req.Limit + } + if req.VPCId != nil && *req.VPCId != "" { + payload["VPCId"] = *req.VPCId + } + if req.SubnetId != nil && *req.SubnetId != "" { + payload["SubnetId"] = *req.SubnetId + } + if req.BusinessId != nil && *req.BusinessId != "" { + payload["BusinessId"] = *req.BusinessId + } + if len(payload) > 0 { + genReq.SetPayload(payload) + } + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + ctx.HandleError(err) + return + } + + var resp ListUKafkaInstanceResponse + if err := genResp.Unmarshal(&resp); err != nil { + ctx.HandleError(fmt.Errorf("parse response: %w", err)) + return + } + + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("API error: RetCode=%d, Message=%s", resp.RetCode, resp.Message)) + return + } + + list := []InstanceRow{} + for _, ins := range resp.ClusterSet { + row := InstanceRow{ + InstanceID: ins.ClusterInstanceId, + InstanceName: ins.ClusterInstanceName, + Framework: ins.Framework, + Version: ins.FrameworkVersion, + Zone: ins.Zone, + State: ins.State, + NodeCount: fmt.Sprintf("%d", ins.UHostCount), + VPCId: ins.VPCId, + SubnetId: ins.SubnetId, + ChargeType: ins.ChargeType, + CreateTime: common.FormatDate(ins.CreateTime), + ExpireTime: common.FormatDate(ins.ExpireTime), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", "", "Optional. Assign availability zone") + req.Offset = flags.String("offset", "0", "Optional. Offset") + req.Limit = flags.String("limit", "60", "Optional. Limit, default 60") + req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID") + req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID") + req.BusinessId = flags.String("business-id", "", "Optional. Business group ID") + return cmd +} diff --git a/products/ukafka/internal/ukafka/list_consumers.go b/products/ukafka/internal/ukafka/list_consumers.go new file mode 100644 index 0000000000..80e1e2cf0a --- /dev/null +++ b/products/ukafka/internal/ukafka/list_consumers.go @@ -0,0 +1,58 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListConsumers ucloud ukafka list-consumers +func newListConsumers(ctx *cli.Context) *cobra.Command { + var instanceID *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewListUKafkaConsumersRequest() + + cmd := &cobra.Command{ + Use: "list-consumers", + Short: "List Kafka consumer groups", + Long: "List Kafka consumer groups in UKafka instance", + Run: func(cmd *cobra.Command, args []string) { + req.ClusterInstanceId = sdk.String(*instanceID) + + resp, err := client.ListUKafkaConsumers(req) + if err != nil { + ctx.HandleError(err) + return + } + + list := []ConsumerGroupRow{} + for _, g := range resp.Groups { + row := ConsumerGroupRow{ + GroupName: g.GroupName, + Type: g.Type, + NumOfTopics: fmt.Sprintf("%d", g.NumOfTopics), + GroupID: g.GroupId, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + cmd.MarkFlagRequired("ukafka-id") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/list_topics.go b/products/ukafka/internal/ukafka/list_topics.go new file mode 100644 index 0000000000..7198afc3f4 --- /dev/null +++ b/products/ukafka/internal/ukafka/list_topics.go @@ -0,0 +1,101 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// ListUKafkaTopicsResponse 自定义响应结构 +type ListUKafkaTopicsResponse struct { + RetCode int `json:"RetCode"` + Action string `json:"Action"` + TopicList []TopicInfo `json:"TopicList"` + Length int `json:"Length"` + Message string `json:"Message"` +} + +// TopicInfo topic信息 +type TopicInfo struct { + Topic string `json:"Topic"` + NumOfPartition int `json:"NumOfPartition"` + NumOfOccupyBroker int `json:"NumOfOccupyBroker"` + NumOfReplica int `json:"NumOfReplica"` + Status string `json:"Status"` + UnderReplicasPer string `json:"UnderReplicasPer"` +} + +// newListTopics ucloud ukafka list-topics +func newListTopics(ctx *cli.Context) *cobra.Command { + var instanceID *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewListUKafkaTopicsRequest() + + cmd := &cobra.Command{ + Use: "list-topics", + Short: "List Kafka topics in UKafka instance", + Long: "List Kafka topics in UKafka instance", + Run: func(cmd *cobra.Command, args []string) { + // 使用 GenericInvoke 绕过 SDK 类型问题 + genReq := client.Client.NewGenericRequest() + genReq.SetAction("ListUKafkaTopics") + genReq.SetRegion(*req.Region) + genReq.SetZone(*req.Zone) + if req.ProjectId != nil && *req.ProjectId != "" { + genReq.SetProjectId(*req.ProjectId) + } + + payload := map[string]interface{}{ + "ClusterInstanceId": *instanceID, + } + genReq.SetPayload(payload) + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + ctx.HandleError(err) + return + } + + var resp ListUKafkaTopicsResponse + if err := genResp.Unmarshal(&resp); err != nil { + ctx.HandleError(fmt.Errorf("parse response: %w", err)) + return + } + + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("API error: RetCode=%d, Message=%s", resp.RetCode, resp.Message)) + return + } + + list := []TopicRow{} + for _, t := range resp.TopicList { + row := TopicRow{ + Topic: t.Topic, + NumOfPartition: fmt.Sprintf("%d", t.NumOfPartition), + NumOfReplica: fmt.Sprintf("%d", t.NumOfReplica), + NumOfOccupyBroker: fmt.Sprintf("%d", t.NumOfOccupyBroker), + UnderReplicasPer: t.UnderReplicasPer, + Status: t.Status, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + cmd.MarkFlagRequired("ukafka-id") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/modify_type.go b/products/ukafka/internal/ukafka/modify_type.go new file mode 100644 index 0000000000..711fe418b4 --- /dev/null +++ b/products/ukafka/internal/ukafka/modify_type.go @@ -0,0 +1,61 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newModifyType ucloud ukafka modify-type +func newModifyType(ctx *cli.Context) *cobra.Command { + var async *bool + var instanceID *string + var nodeType *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewModifyUKafkaInstanceTypeRequest() + + cmd := &cobra.Command{ + Use: "modify-type", + Short: "Modify UKafka instance type (CPU and memory)", + Long: "Modify UKafka instance type, only upgrade CPU and memory", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + req.InstanceId = sdk.String(*instanceID) + req.NodeType = sdk.String(*nodeType) + + _, err := client.ModifyUKafkaInstanceType(req) + if err != nil { + ctx.HandleError(err) + return + } + + text := fmt.Sprintf("ukafka[%s] is modifying type to %s", *instanceID, *nodeType) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUKafkaInstanceByID(ctx)).Spoll(*instanceID, text, []string{StateRunning, StateAbnormal}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *instanceID, Action: "modify-type", Status: "Running"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + nodeType = flags.String("node-type", "", "Required. Target node type") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.Bool("async", false, "Optional. Do not wait for operation to finish") + + cmd.MarkFlagRequired("ukafka-id") + cmd.MarkFlagRequired("node-type") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/nodeconf.go b/products/ukafka/internal/ukafka/nodeconf.go new file mode 100644 index 0000000000..5a32920eac --- /dev/null +++ b/products/ukafka/internal/ukafka/nodeconf.go @@ -0,0 +1,105 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// GetUKafkaNodeTypeResponse 自定义响应结构 +type GetUKafkaNodeTypeResponse struct { + RetCode int `json:"RetCode"` + Action string `json:"Action"` + NodeTypeSet []InstanceType `json:"NodeTypeSet"` + TotalCount int `json:"TotalCount"` + Message string `json:"Message"` +} + +// InstanceType 机型信息 +type InstanceType struct { + NodeTypeName string `json:"NodeTypeName"` + CPU int `json:"CPU"` + Memory int `json:"Memory"` + DiskType string `json:"DiskType"` + DiskSet []DiskSet `json:"DiskSet"` + MaxDiskSize int `json:"MaxDiskSize"` + MinDiskSize int `json:"MinDiskSize"` + IsOpenSecGroup bool `json:"IsOpenSecGroup"` + UHostFamily string `json:"UHostFamily"` +} + +// DiskSet 磁盘配置 +type DiskSet struct { + Type string `json:"Type"` + Size int `json:"Size"` +} + +// newNodeConf ucloud ukafka node-conf +func newNodeConf(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewGetUKafkaNodeTypeRequest() + cmd := &cobra.Command{ + Use: "node-conf", + Short: "List available UKafka node configurations", + Long: "List available UKafka node configurations", + Run: func(cmd *cobra.Command, args []string) { + // 使用 GenericInvoke 绕过 SDK 类型问题 + genReq := client.Client.NewGenericRequest() + genReq.SetAction("GetUKafkaNodeType") + genReq.SetRegion(*req.Region) + genReq.SetZone(*req.Zone) + if req.ProjectId != nil && *req.ProjectId != "" { + genReq.SetProjectId(*req.ProjectId) + } + if req.NodeType != nil && *req.NodeType != "" { + payload := map[string]interface{}{ + "NodeType": *req.NodeType, + } + genReq.SetPayload(payload) + } + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + ctx.HandleError(err) + return + } + + var resp GetUKafkaNodeTypeResponse + if err := genResp.Unmarshal(&resp); err != nil { + ctx.HandleError(fmt.Errorf("parse response: %w", err)) + return + } + + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("API error: RetCode=%d, Message=%s", resp.RetCode, resp.Message)) + return + } + + list := []NodeConfRow{} + for _, t := range resp.NodeTypeSet { + row := NodeConfRow{ + NodeType: t.NodeTypeName, + CPU: fmt.Sprintf("%d", t.CPU), + Memory: fmt.Sprintf("%dMB", t.Memory), + DiskType: t.DiskType, + MinDiskSize: fmt.Sprintf("%d", t.MinDiskSize), + MaxDiskSize: fmt.Sprintf("%d", t.MaxDiskSize), + SecGroup: fmt.Sprintf("%v", t.IsOpenSecGroup), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + req.NodeType = flags.String("node-type", "", "Optional. Specify node type") + return cmd +} diff --git a/products/ukafka/internal/ukafka/poll.go b/products/ukafka/internal/ukafka/poll.go new file mode 100644 index 0000000000..d5dd12e05e --- /dev/null +++ b/products/ukafka/internal/ukafka/poll.go @@ -0,0 +1,59 @@ +package ukafka + +import ( + "fmt" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// describeUKafkaInstanceByID returns the poller's describe func +// It uses commonBase to get region and zone for the request +func describeUKafkaInstanceByID(ctx *cli.Context) func(instanceID string, commonBase *request.CommonBase) (interface{}, error) { + return func(instanceID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + genReq := client.Client.NewGenericRequest() + genReq.SetAction("DescribeUKafkaInstance") + + // Use region/zone from commonBase if available, otherwise use defaults + if commonBase != nil { + if commonBase.Region != nil && *commonBase.Region != "" { + genReq.SetRegion(*commonBase.Region) + } else { + genReq.SetRegion(ctx.DefaultRegion()) + } + if commonBase.Zone != nil && *commonBase.Zone != "" { + genReq.SetZone(*commonBase.Zone) + } else { + genReq.SetZone(ctx.DefaultZone()) + } + if commonBase.ProjectId != nil && *commonBase.ProjectId != "" { + genReq.SetProjectId(*commonBase.ProjectId) + } + } else { + genReq.SetRegion(ctx.DefaultRegion()) + genReq.SetZone(ctx.DefaultZone()) + } + + payload := map[string]interface{}{ + "ClusterInstanceId": instanceID, + } + genReq.SetPayload(payload) + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + return nil, err + } + + var resp DescribeUKafkaInstanceResponse + if err := genResp.Unmarshal(&resp); err != nil { + return nil, err + } + if len(resp.ClusterSet) == 0 { + return nil, fmt.Errorf("instance not found") + } + return &resp.ClusterSet[0], nil + } +} diff --git a/products/ukafka/internal/ukafka/resize_disk.go b/products/ukafka/internal/ukafka/resize_disk.go new file mode 100644 index 0000000000..baa9143323 --- /dev/null +++ b/products/ukafka/internal/ukafka/resize_disk.go @@ -0,0 +1,61 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newResizeDisk ucloud ukafka resize-disk +func newResizeDisk(ctx *cli.Context) *cobra.Command { + var async *bool + var instanceID *string + var diskSize *int + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewResizeUKafkaDiskRequest() + + cmd := &cobra.Command{ + Use: "resize-disk", + Short: "Resize UKafka instance disk", + Long: "Resize UKafka instance disk size", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + req.InstanceId = sdk.String(*instanceID) + req.DiskSize = sdk.Int(*diskSize) + + _, err := client.ResizeUKafkaDisk(req) + if err != nil { + ctx.HandleError(err) + return + } + + text := fmt.Sprintf("ukafka[%s] is resizing disk to %dGB", *instanceID, *diskSize) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUKafkaInstanceByID(ctx)).Spoll(*instanceID, text, []string{StateRunning, StateAbnormal}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *instanceID, Action: "resize-disk", Status: "Running"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + diskSize = flags.Int("disk-size-gb", 0, "Required. Target disk size in GB") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.Bool("async", false, "Optional. Do not wait for operation to finish") + + cmd.MarkFlagRequired("ukafka-id") + cmd.MarkFlagRequired("disk-size-gb") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/rows.go b/products/ukafka/internal/ukafka/rows.go new file mode 100644 index 0000000000..0333924e31 --- /dev/null +++ b/products/ukafka/internal/ukafka/rows.go @@ -0,0 +1,52 @@ +package ukafka + +// InstanceRow represents a UKafka instance in list output +type InstanceRow struct { + InstanceID string + InstanceName string + Framework string + Version string + Zone string + State string + NodeCount string + VPCId string + SubnetId string + ChargeType string + CreateTime string + ExpireTime string +} + +// NodeConfRow represents a node configuration in node-conf output +type NodeConfRow struct { + NodeType string + CPU string + Memory string + DiskType string + MinDiskSize string + MaxDiskSize string + SecGroup string +} + +// VersionRow represents a version in app-version output +type VersionRow struct { + Version string + Label string +} + +// ConsumerGroupRow represents a consumer group in list-consumers output +type ConsumerGroupRow struct { + GroupName string + Type string + NumOfTopics string + GroupID string +} + +// TopicRow represents a topic in list-topics output +type TopicRow struct { + Topic string + NumOfPartition string + NumOfReplica string + NumOfOccupyBroker string + UnderReplicasPer string + Status string +} diff --git a/products/ukafka/internal/ukafka/status.go b/products/ukafka/internal/ukafka/status.go new file mode 100644 index 0000000000..3df0605b4a --- /dev/null +++ b/products/ukafka/internal/ukafka/status.go @@ -0,0 +1,12 @@ +package ukafka + +// Instance state constants +const ( + StateRunning = "Running" + StateAbnormal = "Abnormal" + StateCreating = "Creating" + StateDeleting = "Deleting" + StateDeleted = "Deleted" + StateUpdating = "Updating" + StateDeploying = "Deploying" +) diff --git a/products/ukafka/product.go b/products/ukafka/product.go new file mode 100644 index 0000000000..a3a277b1f5 --- /dev/null +++ b/products/ukafka/product.go @@ -0,0 +1,21 @@ +package ukafka + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalukafka "github.com/ucloud/ucloud-cli/products/ukafka/internal/ukafka" +) + +type product struct{} + +// New returns the ukafka product (registered via hack/gen-products) +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "ukafka", Commands: []string{"ukafka"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalukafka.NewCommand(ctx)} +} diff --git a/products/ukafka/product.yaml b/products/ukafka/product.yaml new file mode 100644 index 0000000000..fd1ae115d3 --- /dev/null +++ b/products/ukafka/product.yaml @@ -0,0 +1,7 @@ +# products/ukafka/product.yaml — ukafka 产品元数据 +name: ukafka +owners: + - zxl-wangwang +commands: + - ukafka +enabled: true diff --git a/products/ukafka/testdata/cmdtree.golden b/products/ukafka/testdata/cmdtree.golden new file mode 100644 index 0000000000..a3be5b9cb6 --- /dev/null +++ b/products/ukafka/testdata/cmdtree.golden @@ -0,0 +1,93 @@ +ucloud ukafka use=ukafka short=Manage UKafka instances +ucloud ukafka add-node use=add-node short=Add nodes to UKafka instance + flag=async short= default=false required= + flag=node-count short= default=1 required= + flag=node-type short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka app-version use=app-version short=List available Kafka versions + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud ukafka check-topic use=check-topic short=Check if a topic name exists in UKafka instance + flag=project-id short= default= required= + flag=region short= default= required= + flag=topic-name short= default= required=true + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka create use=create short=Create UKafka instance + flag=async short= default=false required= + flag=business-id short= default= required= + flag=disk-controller-type short= default=NONE required= + flag=disk-size-gb short= default=0 required=true + flag=disk-threshold short= default=90 required= + flag=enable-security short= default=false required= + flag=kafka-version short= default= required=true + flag=log-retention-hours short= default=72 required= + flag=name short= default= required=true + flag=node-count short= default=3 required= + flag=node-type short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud ukafka delete use=delete short=Delete UKafka instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud ukafka describe use=describe short=Describe UKafka instance details + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka describe-consumer use=describe-consumer short=Describe Kafka consumer group details + flag=consumer-group short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=type short= default= required=true + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka list use=list short=List UKafka instances + flag=business-id short= default= required= + flag=limit short= default=60 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud ukafka list-consumers use=list-consumers short=List Kafka consumer groups + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka list-topics use=list-topics short=List Kafka topics in UKafka instance + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka modify-type use=modify-type short=Modify UKafka instance type (CPU and memory) + flag=async short= default=false required= + flag=node-type short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka node-conf use=node-conf short=List available UKafka node configurations + flag=node-type short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud ukafka resize-disk use=resize-disk short=Resize UKafka instance disk + flag=async short= default=false required= + flag=disk-size-gb short= default=0 required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= diff --git a/products/ukafka/testdata/completion.golden b/products/ukafka/testdata/completion.golden new file mode 100644 index 0000000000..5ebec444a9 --- /dev/null +++ b/products/ukafka/testdata/completion.golden @@ -0,0 +1,3 @@ +ucloud ukafka create project-id dynamic +ucloud ukafka create region dynamic +ucloud ukafka create zone dynamic diff --git a/products/ulb/internal/ulb/cmd.go b/products/ulb/internal/ulb/cmd.go new file mode 100644 index 0000000000..f165967f30 --- /dev/null +++ b/products/ulb/internal/ulb/cmd.go @@ -0,0 +1,23 @@ +package ulb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand returns the ucloud ulb command tree. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ulb", + Short: "List and manipulate ULB instances", + Long: "List and manipulate ULB instances", + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newUpdate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newVServer(ctx)) + cmd.AddCommand(newSSL(ctx)) + return cmd +} diff --git a/products/ulb/internal/ulb/completion.go b/products/ulb/internal/ulb/completion.go new file mode 100644 index 0000000000..b2d391cf0a --- /dev/null +++ b/products/ulb/internal/ulb/completion.go @@ -0,0 +1,156 @@ +package ulb + +import ( + "fmt" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func getAllULB(ctx *cli.Context, project, region string) ([]ulbsdk.ULBSet, error) { + list := []ulbsdk.ULBSet{} + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDescribeULBRequest() + req.ProjectId = &project + req.Region = ®ion + + for offset, limit := 0, 50; ; offset += limit { + req.Offset = sdk.Int(offset) + req.Limit = sdk.Int(limit) + resp, err := client.DescribeULB(req) + if err != nil { + return nil, err + } + list = append(list, resp.DataSet...) + + if resp.TotalCount < offset+limit { + break + } + } + return list, nil +} + +func getAllULBIDNames(ctx *cli.Context, project, region string) []string { + list := []string{} + ulbList, err := getAllULB(ctx, project, region) + if err != nil { + return nil + } + for _, ulb := range ulbList { + list = append(list, fmt.Sprintf("%s/%s", ulb.ULBId, ulb.Name)) + } + return list +} + +func getAllVServers(ctx *cli.Context, ulbID, vserverID, project, region string) ([]ulbsdk.ULBVServerSet, error) { + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDescribeVServerRequest() + req.ULBId = sdk.String(cli.PickResourceID(ulbID)) + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = ®ion + if vserverID != "" { + req.VServerId = sdk.String(cli.PickResourceID(vserverID)) + } + resp, err := client.DescribeVServer(req) + if err != nil { + return nil, err + } + if vserverID != "" { + if len(resp.DataSet) < 1 { + return nil, fmt.Errorf("VServer[%s] may not exist", vserverID) + } else if len(resp.DataSet) > 1 { + return nil, fmt.Errorf("Internal Error, too many vserver:%#v", resp.DataSet) + } + } + return resp.DataSet, nil +} + +func getAllVServerIDNames(ctx *cli.Context, ulbID, project, region string) []string { + vservers, err := getAllVServers(ctx, ulbID, "", project, region) + if err != nil { + return nil + } + idNames := []string{} + for _, vs := range vservers { + idNames = append(idNames, fmt.Sprintf("%s/%s", vs.VServerId, vs.VServerName)) + } + return idNames +} + +func getAllBackendNodes(ctx *cli.Context, ulbID, vserverID, project, region string) ([]ulbsdk.ULBBackendSet, error) { + vsList, err := getAllVServers(ctx, ulbID, vserverID, project, region) + if err != nil { + return nil, err + } + nodeList := []ulbsdk.ULBBackendSet{} + for _, vs := range vsList { + nodeList = append(nodeList, vs.BackendSet...) + } + return nodeList, nil +} + +func getAllBackendNodeIDNames(ctx *cli.Context, ulbID, vserverID, project, region string) []string { + nodeList, err := getAllBackendNodes(ctx, ulbID, vserverID, project, region) + if err != nil { + return nil + } + idNames := []string{} + for _, node := range nodeList { + idNames = append(idNames, fmt.Sprintf("%s/%s", node.BackendId, node.ResourceName)) + } + return idNames +} + +func getAllSSLCertIDNames(ctx *cli.Context, project, region string) []string { + sslcs, err := getAllSSLCerts(ctx, project, region) + if err != nil { + return nil + } + idNames := []string{} + for _, ssl := range sslcs { + idNames = append(idNames, fmt.Sprintf("%s/%s", ssl.SSLId, ssl.SSLName)) + } + return idNames +} + +func getAllSSLCerts(ctx *cli.Context, project, region string) ([]ulbsdk.ULBSSLSet, error) { + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDescribeSSLRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + list := []ulbsdk.ULBSSLSet{} + for offset, limit := 0, 50; ; offset += limit { + req.Offset = sdk.Int(offset) + req.Limit = sdk.Int(limit) + resp, err := client.DescribeSSL(req) + if err != nil { + return nil, err + } + list = append(list, resp.DataSet...) + if resp.TotalCount <= offset+limit { + break + } + } + return list, nil +} + +func getSSLCertByID(ctx *cli.Context, sslID, project, region string) (*ulbsdk.ULBSSLSet, error) { + if sslID == "" { + return nil, fmt.Errorf("ssl certificate resource id can't be empty") + } + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDescribeSSLRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + req.SSLId = sdk.String(cli.PickResourceID(sslID)) + resp, err := client.DescribeSSL(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) <= 0 { + return nil, fmt.Errorf("ssl certificate[%s] is not exists", sslID) + } + return &resp.DataSet[0], nil +} diff --git a/products/ulb/internal/ulb/create.go b/products/ulb/internal/ulb/create.go new file mode 100644 index 0000000000..2e0fae512a --- /dev/null +++ b/products/ulb/internal/ulb/create.go @@ -0,0 +1,122 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate returns ucloud ulb create. +func newCreate(ctx *cli.Context) *cobra.Command { + var bindEipID *string + mode := "outer" + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + unetClient := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewCreateULBRequest() + eipReq := unetClient.NewAllocateEIPRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create ULB instance", + Long: "Create ULB instance", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + if mode == "outer" { + if *bindEipID == "" && *eipReq.Bandwidth == 0 { + fmt.Fprintln(ctx.ProgressWriter(), "Outer mode ULB need a eip to bind, please assign eip by flag 'bind-eip' or create eip by 'create-eip-bandwidth-mb'") + return + } + if *eipReq.OperatorName == "" { + *eipReq.OperatorName = getEIPLine(*req.Region) + } + req.OuterMode = sdk.String("Yes") + } else if mode == "inner" { + req.InnerMode = sdk.String("Yes") + } else { + fmt.Fprintln(ctx.ProgressWriter(), "Error, flag mode should be 'outer' or 'inner'") + return + } + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId)) + resp, err := client.CreateULB(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ulb[%s] created\n", resp.ULBId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ULBId, Action: "create", Status: "Created"}) + if mode == "inner" { + return + } + bindEipID = sdk.String(ctx.PickResourceID(*bindEipID)) + if *bindEipID != "" { + _ = bindEIP(ctx, sdk.String(resp.ULBId), sdk.String("ulb"), bindEipID, req.ProjectId, req.Region) + return + } + if *eipReq.OperatorName != "" && *eipReq.Bandwidth != 0 { + eipReq.ChargeType = req.ChargeType + eipReq.Tag = req.Tag + eipReq.Region = req.Region + eipReq.ProjectId = req.ProjectId + eipResp, err := unetClient.AllocateEIP(eipReq) + + if err != nil { + ctx.HandleError(err) + return + } + + for _, eip := range eipResp.EIPSet { + fmt.Fprintf(ctx.ProgressWriter(), "allocate EIP[%s] ", eip.EIPId) + for _, ip := range eip.EIPAddr { + fmt.Fprintf(ctx.ProgressWriter(), "IP:%s Line:%s \n", ip.IP, ip.OperatorName) + } + _ = bindEIP(ctx, sdk.String(resp.ULBId), sdk.String("ulb"), sdk.String(eip.EIPId), req.ProjectId, req.Region) + } + } + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ULBName = flags.String("name", "", "Required. Name of ULB instance to create") + flags.StringVar(&mode, "mode", "outer", "Required. Network mode of ULB instance, outer or inner.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.VPCId = flags.String("vpc-id", "", "Optional. Resource ID of VPC which the ULB to create belong to. See 'ucloud vpc list'") + req.SubnetId = flags.String("subnet-id", "", "Optional. Resource ID of subnet. This flag will be discarded when you are creating an outter mode ULB. See 'ucloud subnet list'") + req.ChargeType = flags.String("charge-type", "Month", "Optional.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") + req.Tag = flags.String("group", "Default", "Optional. Business group") + req.Remark = flags.String("remark", "", "Optional. Remark of instance to create.") + bindEipID = flags.String("bind-eip", "", "Optional. Resource ID or IP Address of eip that will be bound to the new created outer mode ulb") + eipReq.Bandwidth = cmd.Flags().Int("create-eip-bandwidth-mb", 0, "Optional. Required if you want to create new EIP. Bandwidth(Unit:Mbps).The range of value related to network charge mode. By traffic [1, 300]; by bandwidth [1,800] (Unit: Mbps); it could be 0 if the eip belong to the shared bandwidth") + eipReq.OperatorName = flags.String("create-eip-line", "", "Optional. Line of created eip to bind with the new created outer mode ulb") + eipReq.PayMode = cmd.Flags().String("create-eip-traffic-mode", "Bandwidth", "Optional. 'Traffic','Bandwidth' or 'ShareBandwidth'") + eipReq.Name = flags.String("create-eip-name", "", "Optional. Name of created eip to bind with the new created outer mode ulb") + eipReq.Remark = cmd.Flags().String("create-eip-remark", "", "Optional. Remark of your EIP.") + + command.SetFlagValues(cmd, "mode", "outer", "inner") + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic") + command.SetFlagValues(cmd, "create-eip-line", "BGP", "International") + command.SetFlagValues(cmd, "create-eip-traffic-mode", "Bandwidth", "Traffic", "ShareBandwidth") + command.SetCompletion(cmd, "bind-eip", func() []string { + return getAllEip(ctx, *req.ProjectId, *req.Region, []string{EIP_FREE}, nil) + }) + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, *req.VPCId, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("mode") + cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/products/ulb/internal/ulb/delete.go b/products/ulb/internal/ulb/delete.go new file mode 100644 index 0000000000..3eb328efd0 --- /dev/null +++ b/products/ulb/internal/ulb/delete.go @@ -0,0 +1,56 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete returns ucloud ulb delete. +func newDelete(ctx *cli.Context) *cobra.Command { + idNames := []string{} + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDeleteULBRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete ULB instances by resource ID", + Long: "Delete ULB instances by resource ID", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.ULBId = sdk.String(id) + _, err := client.DeleteULB(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ulb[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "ulb-id", nil, "Required. Resource ID of the ULB instances to delete") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("ulb-id") + + return cmd +} diff --git a/products/ulb/internal/ulb/eip.go b/products/ulb/internal/ulb/eip.go new file mode 100644 index 0000000000..6de978a5cd --- /dev/null +++ b/products/ulb/internal/ulb/eip.go @@ -0,0 +1,126 @@ +package ulb + +import ( + "fmt" + "net" + "strings" + + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func bindEIP(ctx *cli.Context, resourceID, resourceType, eipID, projectID, region *string) error { + ip := net.ParseIP(*eipID) + if ip != nil { + id, err := getEIPIDbyIP(ctx, ip, *projectID, *region) + if err != nil { + ctx.HandleError(err) + } else { + *eipID = id + } + } + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewBindEIPRequest() + req.ResourceId = resourceID + req.ResourceType = resourceType + req.EIPId = sdk.String(ctx.PickResourceID(*eipID)) + req.ProjectId = sdk.String(ctx.PickResourceID(*projectID)) + req.Region = region + _, err := client.BindEIP(req) + if err != nil { + ctx.HandleError(err) + return err + } + fmt.Fprintf(ctx.ProgressWriter(), "bind EIP[%s] with %s[%s]\n", *req.EIPId, *req.ResourceType, *req.ResourceId) + return nil +} + +func getEIPIDbyIP(ctx *cli.Context, ip net.IP, projectID, region string) (string, error) { + eipList, err := fetchAllEip(ctx, projectID, region) + if err != nil { + return "", err + } + for _, eip := range eipList { + for _, addr := range eip.EIPAddr { + if addr.IP == ip.String() { + return eip.EIPId, nil + } + } + } + return "", fmt.Errorf("IP[%s] not exist", ip.String()) +} + +func fetchAllEip(ctx *cli.Context, projectID, region string) ([]unet.UnetEIPSet, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeEIPRequest() + list := []unet.UnetEIPSet{} + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + for offset, step := 0, 100; ; offset += step { + req.Offset = &offset + req.Limit = &step + resp, err := client.DescribeEIP(req) + if err != nil { + return nil, err + } + for i, size := 0, len(resp.EIPSet); i < size; i++ { + list = append(list, resp.EIPSet[i]) + } + if resp.TotalCount <= offset+step { + break + } + } + return list, nil +} + +func getAllEip(ctx *cli.Context, projectID, region string, states, paymodes []string) []string { + list, err := fetchAllEip(ctx, projectID, region) + if err != nil { + return nil + } + strs := []string{} + for _, item := range list { + rightState := false + if states == nil { + rightState = true + } else { + for _, s := range states { + if item.Status == s { + rightState = true + } + } + } + + rightPayMode := false + if paymodes == nil { + rightPayMode = true + } else { + for _, m := range paymodes { + if item.PayMode == m { + rightPayMode = true + } + } + } + if !rightPayMode || !rightState { + continue + } + + ips := []string{} + for _, ip := range item.EIPAddr { + ips = append(ips, ip.IP) + } + strs = append(strs, item.EIPId+"/"+strings.Join(ips, ",")) + } + return strs +} + +func getEIPLine(region string) (line string) { + if strings.HasPrefix(region, "cn") { + line = "BGP" + } else { + line = "International" + } + return +} diff --git a/products/ulb/internal/ulb/list.go b/products/ulb/internal/ulb/list.go new file mode 100644 index 0000000000..2e2047f676 --- /dev/null +++ b/products/ulb/internal/ulb/list.go @@ -0,0 +1,76 @@ +package ulb + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList returns ucloud ulb list. +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDescribeULBRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List ULB instances", + Long: "List ULB instances", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + resp, err := client.DescribeULB(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []Row{} + for _, ulb := range resp.DataSet { + row := Row{} + row.ResourceID = ulb.ULBId + row.Name = ulb.Name + row.Group = ulb.BusinessId + row.VserverCount = len(ulb.VServerSet) + row.VPC = ulb.VPCId + row.CreationTime = common.FormatDate(ulb.CreateTime) + if ulb.ULBType == "OuterMode" { + ips := []string{} + for _, ip := range ulb.IPSet { + ips = append(ips, fmt.Sprintf("%s(%s)", ip.EIP, ip.EIPId)) + } + row.Network = strings.Join(ips, ",") + } else { + row.Network = ulb.PrivateIP + } + list = append(list, row) + } + + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + req.ULBId = flags.String("ulb-id", "", "Optional. Resource ID of ULB instance to list") + req.VPCId = flags.String("vpc-id", "", "Optional. Resource ID of VPC which the ULB instances to list belong to") + req.SubnetId = flags.String("subnet-id", "", "Optional. Resource ID of subnet which the ULB instances to list belong to") + req.BusinessId = flags.String("group", "", "Optional. Business group of ULB instances to list") + req.Offset = flags.Int("offset", 0, "Optional. Offset") + req.Limit = flags.Int("limit", 50, "Optional. Limit") + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/ulb/internal/ulb/read_file.go b/products/ulb/internal/ulb/read_file.go new file mode 100644 index 0000000000..35b7c4a0e3 --- /dev/null +++ b/products/ulb/internal/ulb/read_file.go @@ -0,0 +1,11 @@ +package ulb + +import "os" + +func readFile(file string) (string, error) { + byts, err := os.ReadFile(file) + if err != nil { + return "", err + } + return string(byts), nil +} diff --git a/products/ulb/internal/ulb/rows.go b/products/ulb/internal/ulb/rows.go new file mode 100644 index 0000000000..6809fd494b --- /dev/null +++ b/products/ulb/internal/ulb/rows.go @@ -0,0 +1,53 @@ +package ulb + +type Row struct { + Name string + ResourceID string + Group string + Network string + VserverCount int + VPC string + CreationTime string +} + +type VServerRow struct { + VServerName string + ResourceID string + ListenType string + Protocol string + Port int + LBMethod string + SessionMaintainMode string + SessionMaintainKey string + ClientTimeout string + HealthCheckMode string + HealthCheckDomain string + HealthCheckPath string +} + +type BackendRow struct { + Name string + ResourceID string + BackendID string + PrivateIP string + Port int + HealthCheck string + NodeMode string + Weight int +} + +type PolicyRow struct { + ForwardMethod string + Expression string + PolicyID string + PolicyType string + Backends string +} + +type SSLCertificate struct { + Name string + ResourceID string + MD5 string + BindResource string + UploadTime string +} diff --git a/products/ulb/internal/ulb/ssl.go b/products/ulb/internal/ulb/ssl.go new file mode 100644 index 0000000000..97178dd0ae --- /dev/null +++ b/products/ulb/internal/ulb/ssl.go @@ -0,0 +1,23 @@ +package ulb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSSL returns ucloud ulb ssl. +func newSSL(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ssl", + Short: "List and manipulate SSL Certificates for ULB", + Long: "List and manipulate SSL Certificates for ULB", + } + cmd.AddCommand(newSSLList(ctx)) + cmd.AddCommand(newSSLDescribe(ctx)) + cmd.AddCommand(newSSLAdd(ctx)) + cmd.AddCommand(newSSLDelete(ctx)) + cmd.AddCommand(newSSLBind(ctx)) + cmd.AddCommand(newSSLUnbind(ctx)) + return cmd +} diff --git a/products/ulb/internal/ulb/ssl_add.go b/products/ulb/internal/ulb/ssl_add.go new file mode 100644 index 0000000000..39c34e166f --- /dev/null +++ b/products/ulb/internal/ulb/ssl_add.go @@ -0,0 +1,97 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSSLAdd returns ucloud ulb ssl add. +func newSSLAdd(ctx *cli.Context) *cobra.Command { + var allPath, sitePath, keyPath, caPath *string + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewCreateSSLRequest() + cmd := &cobra.Command{ + Use: "add", + Short: "Add SSL Certificate", + Long: "Add SSL Certificate", + Run: func(c *cobra.Command, args []string) { + if *allPath == "" && (*sitePath == "" || *keyPath == "") { + fmt.Fprintln(ctx.ProgressWriter(), "if all-in-one-file is omitted, site-certificate-file and private-key-file can't be empty") + return + } + if *allPath != "" { + content, err := readFile(*allPath) + if err != nil { + ctx.HandleError(err) + return + } + req.SSLContent = &content + } + if *sitePath != "" { + content, err := readFile(*sitePath) + if err != nil { + ctx.HandleError(err) + return + } + req.UserCert = &content + } + if *keyPath != "" { + content, err := readFile(*keyPath) + if err != nil { + ctx.HandleError(err) + return + } + req.PrivateKey = &content + } + if *caPath != "" { + content, err := readFile(*caPath) + if err != nil { + ctx.HandleError(err) + return + } + req.CaCert = &content + } + + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + resp, err := client.CreateSSL(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ssl certificate[%s] added\n", resp.SSLId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.SSLId, Action: "add-ssl", Status: "Added"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.SSLName = flags.String("name", "", "Required. Name of ssl certificate to add") + req.SSLType = flags.String("format", "Pem", "Optional. Format of ssl certificate") + allPath = flags.String("all-in-one-file", "", "Optional. Path of file which contain the complete content of the SSL certificate, including the content of site certificate, the private key which encrypted the site certificate, and the CA certificate. ") + sitePath = flags.String("site-certificate-file", "", "Optional. Path of user's certificate file, *.crt. Required if all-in-one-file is omitted") + keyPath = flags.String("private-key-file", "", "Optional. Path of private key file, *.key. Required if all-in-one-file is omitted") + caPath = flags.String("ca-certificate-file", "", "Optional. Path of CA certificate file, *.crt") + cmd.MarkFlagRequired("name") + ctx.SetCompletion(cmd, "all-in-one-file", func() []string { + return common.GetFileList("") + }) + ctx.SetCompletion(cmd, "private-key-file", func() []string { + return common.GetFileList(".key") + }) + ctx.SetCompletion(cmd, "ca-certificate-file", func() []string { + return common.GetFileList(".crt") + }) + ctx.SetCompletion(cmd, "site-certificate-file", func() []string { + return common.GetFileList(".crt") + }) + return cmd +} diff --git a/products/ulb/internal/ulb/ssl_bind.go b/products/ulb/internal/ulb/ssl_bind.go new file mode 100644 index 0000000000..6a6d526050 --- /dev/null +++ b/products/ulb/internal/ulb/ssl_bind.go @@ -0,0 +1,58 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newSSLBind returns ucloud ulb ssl bind. +func newSSLBind(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewBindSSLRequest() + cmd := &cobra.Command{ + Use: "bind", + Short: "Bind SSL Certificate with VServer", + Long: "Bind SSL Certificate with VServer", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + req.VServerId = sdk.String(ctx.PickResourceID(*req.VServerId)) + req.SSLId = sdk.String(ctx.PickResourceID(*req.SSLId)) + _, err := client.BindSSL(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ssl certificate[%s] bind with vserver[%s] of ulb[%s]\n", *req.SSLId, *req.VServerId, *req.ULBId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.SSLId, Action: "bind-ssl", Status: "Bound"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.SSLId = flags.String("ssl-id", "", "Required. Resource ID of SSL Certificate to bind") + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB") + req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer") + command.SetCompletion(cmd, "ssl-id", func() []string { + return getAllSSLCertIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "vserver-id", func() []string { + return getAllVServerIDNames(ctx, *req.ULBId, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("ssl-id") + cmd.MarkFlagRequired("ulb-id") + cmd.MarkFlagRequired("vserver-id") + return cmd +} diff --git a/products/ulb/internal/ulb/ssl_delete.go b/products/ulb/internal/ulb/ssl_delete.go new file mode 100644 index 0000000000..deaa9cf960 --- /dev/null +++ b/products/ulb/internal/ulb/ssl_delete.go @@ -0,0 +1,51 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newSSLDelete returns ucloud ulb ssl delete. +func newSSLDelete(ctx *cli.Context) *cobra.Command { + var idNames []string + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDeleteSSLRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete SSL Certificates by resource id(ssl id)", + Long: "Delete SSL Certificates by resource id(ssl id)", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.SSLId = sdk.String(id) + _, err := client.DeleteSSL(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "ssl certificate[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete-ssl", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + flags.StringSliceVar(&idNames, "ssl-id", nil, "Required. Resource ID of SSL Certificates to delete") + command.SetCompletion(cmd, "ssl-id", func() []string { + return getAllSSLCertIDNames(ctx, *req.ProjectId, *req.Region) + }) + return cmd +} diff --git a/products/ulb/internal/ulb/ssl_describe.go b/products/ulb/internal/ulb/ssl_describe.go new file mode 100644 index 0000000000..fc17c6ae7c --- /dev/null +++ b/products/ulb/internal/ulb/ssl_describe.go @@ -0,0 +1,79 @@ +package ulb + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newSSLDescribe returns ucloud ulb ssl describe. +func newSSLDescribe(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDescribeSSLRequest() + cmd := &cobra.Command{ + Use: "describe", + Short: "Display all data associated with SSL Certificate", + Long: "Display all data associated with SSL Certificate", + Run: func(c *cobra.Command, args []string) { + req.SSLId = sdk.String(ctx.PickResourceID(*req.SSLId)) + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + resp, err := client.DescribeSSL(req) + if err != nil { + ctx.HandleError(err) + return + } + if len(resp.DataSet) <= 0 { + fmt.Fprintf(ctx.ProgressWriter(), "ssl certificate[%s] is not exists\n", *req.SSLId) + return + } + + sslcf := resp.DataSet[0] + targets := []string{} + for _, t := range sslcf.BindedTargetSet { + item := fmt.Sprintf("%s/%s-%s/%s", t.ULBId, t.ULBName, t.VServerId, t.VServerName) + targets = append(targets, item) + } + rows := []cli.DescribeRow{ + {Attribute: "ResourceID", Content: sslcf.SSLId}, + {Attribute: "Name", Content: sslcf.SSLName}, + {Attribute: "Type", Content: sslcf.SSLType}, + {Attribute: "UploadTime", Content: common.FormatDateTime(sslcf.CreateTime)}, + {Attribute: "BindResource", Content: strings.Join(targets, ",")}, + {Attribute: "MD5", Content: sslcf.HashValue}, + {Attribute: "Content", Content: sslcf.SSLContent}, + } + printDescribe(ctx, rows) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.SSLId = flags.String("ssl-id", "", "Required. ResouceID of ssl certificate to describe") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + command.SetCompletion(cmd, "ssl-id", func() []string { + return getAllSSLCertIDNames(ctx, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("ssl-id") + return cmd +} + +func printDescribe(ctx *cli.Context, rows []cli.DescribeRow) { + if ctx.Format() != cli.OutputTable { + ctx.PrintList(rows) + return + } + for _, row := range rows { + fmt.Fprintln(ctx.Out(), row.Attribute) + fmt.Fprintln(ctx.Out(), row.Content) + fmt.Fprintln(ctx.Out()) + } +} diff --git a/products/ulb/internal/ulb/ssl_list.go b/products/ulb/internal/ulb/ssl_list.go new file mode 100644 index 0000000000..2470736d71 --- /dev/null +++ b/products/ulb/internal/ulb/ssl_list.go @@ -0,0 +1,59 @@ +package ulb + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newSSLList returns ucloud ulb ssl list. +func newSSLList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDescribeSSLRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List SSL Certificates", + Long: "List SSL Certificates", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + resp, err := client.DescribeSSL(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := []SSLCertificate{} + for _, ssl := range resp.DataSet { + row := SSLCertificate{} + row.Name = ssl.SSLName + row.ResourceID = ssl.SSLId + row.MD5 = ssl.HashValue + row.UploadTime = common.FormatDateTime(ssl.CreateTime) + targets := []string{} + for _, t := range ssl.BindedTargetSet { + item := fmt.Sprintf("%s/%s(%s/%s)", t.VServerId, t.VServerName, t.ULBId, t.ULBName) + targets = append(targets, item) + } + row.BindResource = strings.Join(targets, ",") + rows = append(rows, row) + } + ctx.PrintList(rows) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.SSLId = flags.String("ssl-id", "", "Optional. ResouceID of ssl certificate to list") + ctx.BindLimit(cmd, req) + ctx.BindOffset(cmd, req) + + return cmd +} diff --git a/products/ulb/internal/ulb/ssl_unbind.go b/products/ulb/internal/ulb/ssl_unbind.go new file mode 100644 index 0000000000..98d57bf5c8 --- /dev/null +++ b/products/ulb/internal/ulb/ssl_unbind.go @@ -0,0 +1,80 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newSSLUnbind returns ucloud ulb ssl unbind. +func newSSLUnbind(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewUnbindSSLRequest() + cmd := &cobra.Command{ + Use: "unbind", + Short: "Unbind SSL Certificate with VServer", + Long: "Unbind SSL Certificate with VServer", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + req.VServerId = sdk.String(ctx.PickResourceID(*req.VServerId)) + req.SSLId = sdk.String(ctx.PickResourceID(*req.SSLId)) + _, err := client.UnbindSSL(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ssl certificate[%s] unbind with vserver[%s] of ulb[%s]\n", *req.SSLId, *req.VServerId, *req.ULBId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.SSLId, Action: "unbind-ssl", Status: "Unbound"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.SSLId = flags.String("ssl-id", "", "Required. Resource ID of SSL Certificate to unbind") + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB") + req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer") + command.SetCompletion(cmd, "ssl-id", func() []string { + return getAllSSLCertIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "ulb-id", func() []string { + if *req.SSLId == "" { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + } + cert, err := getSSLCertByID(ctx, *req.SSLId, *req.ProjectId, *req.Region) + if err != nil { + return nil + } + ulbs := []string{} + for _, b := range cert.BindedTargetSet { + ulbs = append(ulbs, fmt.Sprintf("%s/%s", b.ULBId, b.ULBName)) + } + return ulbs + }) + command.SetCompletion(cmd, "vserver-id", func() []string { + if *req.SSLId == "" { + return getAllVServerIDNames(ctx, *req.ULBId, *req.ProjectId, *req.Region) + } + cert, err := getSSLCertByID(ctx, *req.SSLId, *req.ProjectId, *req.Region) + if err != nil { + return nil + } + vservers := []string{} + for _, b := range cert.BindedTargetSet { + vservers = append(vservers, fmt.Sprintf("%s/%s", b.VServerId, b.VServerName)) + } + return vservers + }) + cmd.MarkFlagRequired("ssl-id") + cmd.MarkFlagRequired("ulb-id") + cmd.MarkFlagRequired("vserver-id") + return cmd +} diff --git a/products/ulb/internal/ulb/status.go b/products/ulb/internal/ulb/status.go new file mode 100644 index 0000000000..1d7d180812 --- /dev/null +++ b/products/ulb/internal/ulb/status.go @@ -0,0 +1,3 @@ +package ulb + +const EIP_FREE = "free" diff --git a/products/ulb/internal/ulb/update.go b/products/ulb/internal/ulb/update.go new file mode 100644 index 0000000000..d82084e753 --- /dev/null +++ b/products/ulb/internal/ulb/update.go @@ -0,0 +1,73 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUpdate returns ucloud ulb update. +func newUpdate(ctx *cli.Context) *cobra.Command { + var name, group, remark string + idNames := []string{} + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewUpdateULBAttributeRequest() + cmd := &cobra.Command{ + Use: "update", + Short: "Update ULB instance", + Long: "Update ULB instance", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.ULBId = sdk.String(id) + if name == "" && group == "" && remark == "" { + fmt.Fprintln(ctx.ProgressWriter(), "Error, name, remark and group can't be all empty") + return + } + if name != "" { + req.Name = &name + } + if group != "" { + req.Tag = &group + } + if remark != "" { + req.Remark = &remark + } + _, err := client.UpdateULBAttribute(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "ulb[%s] updated\n", *req.ULBId) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "update", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + flags.StringSliceVar(&idNames, "ulb-id", nil, "Required. Resource ID of ULB instances to update") + flags.StringVar(&name, "name", "", "Optional, Name of ULB instance") + flags.StringVar(&remark, "remark", "", "Optional, Remark of ULB instance") + flags.StringVar(&group, "group", "", "Optional, Business group of ULB instance") + + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("ulb-id") + + return cmd +} diff --git a/products/ulb/internal/ulb/vpc.go b/products/ulb/internal/ulb/vpc.go new file mode 100644 index 0000000000..953f91ffdd --- /dev/null +++ b/products/ulb/internal/ulb/vpc.go @@ -0,0 +1,70 @@ +package ulb + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func getAllVPCIns(ctx *cli.Context, project, region string) ([]vpc.VPCInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + vpcInsList, err := getAllVPCIns(ctx, project, region) + list := []string{} + if err != nil { + return nil + } + for _, vpc := range vpcInsList { + list = append(list, fmt.Sprintf("%s/%s", vpc.VPCId, vpc.Name)) + } + return list +} + +func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpc.SubnetInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + if vpcID != "" { + req.VPCId = sdk.String(cli.PickResourceID(vpcID)) + } + subnets := []vpc.SubnetInfo{} + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.DescribeSubnet(req) + if err != nil { + return nil, err + } + subnets = append(subnets, resp.DataSet...) + if limit+offset >= resp.TotalCount { + break + } + } + return subnets, nil +} + +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + subnets, err := getAllSubnets(ctx, vpcID, project, region) + if err != nil { + return nil + } + list := []string{} + for _, s := range subnets { + list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return list +} diff --git a/products/ulb/internal/ulb/vserver.go b/products/ulb/internal/ulb/vserver.go new file mode 100644 index 0000000000..7ca07a1233 --- /dev/null +++ b/products/ulb/internal/ulb/vserver.go @@ -0,0 +1,23 @@ +package ulb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newVServer returns ucloud ulb vserver. +func newVServer(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "vserver", + Short: "List and manipulate ULB Vserver instances", + Long: "List and manipulate ULB Vserver instances", + } + cmd.AddCommand(newVServerList(ctx)) + cmd.AddCommand(newVServerCreate(ctx)) + cmd.AddCommand(newVServerUpdate(ctx)) + cmd.AddCommand(newVServerDelete(ctx)) + cmd.AddCommand(newBackend(ctx)) + cmd.AddCommand(newPolicy(ctx)) + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_create.go b/products/ulb/internal/ulb/vserver_create.go new file mode 100644 index 0000000000..08abcdb273 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_create.go @@ -0,0 +1,104 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newVServerCreate returns ucloud ulb vserver create. +func newVServerCreate(ctx *cli.Context) *cobra.Command { + sslID := "" + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewCreateVServerRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create ULB VServer instance", + Long: "Create ULB VServer instance", + Run: func(c *cobra.Command, args []string) { + if *req.ListenType == "RequestProxy" && (*req.ClientTimeout <= 0 || *req.ClientTimeout > 86400) { + fmt.Fprintln(ctx.ProgressWriter(), "Error, client-timeout-seconds in the range of (0,86400]") + return + } + if *req.ListenType == "PacketsTransmit" && (*req.ClientTimeout <= 0 || *req.ClientTimeout > 86400) { + fmt.Fprintln(ctx.ProgressWriter(), "Error, client-timeout-seconds in the range of [60,900]") + return + } + if *req.Protocol == "HTTPS" && sslID == "" { + fmt.Fprintln(ctx.ProgressWriter(), "Error, SSL Certificate is needed when you choose HTTPS") + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + resp, err := client.CreateVServer(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ulb-vserver[%s] created\n", resp.VServerId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.VServerId, Action: "create-vserver", Status: "Created"}) + if *req.Protocol == "HTTPS" && sslID != "" { + bindReq := client.NewBindSSLRequest() + bindReq.Region = req.Region + bindReq.ProjectId = req.ProjectId + bindReq.SSLId = sdk.String(ctx.PickResourceID(sslID)) + bindReq.VServerId = sdk.String(resp.VServerId) + bindReq.ULBId = req.ULBId + _, err := client.BindSSL(bindReq) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ssl certificate[%s] bind with vserver[%s] of ulb[%s]\n", sslID, *bindReq.VServerId, *bindReq.ULBId) + } + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB instance which the VServer to create belongs to") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.VServerName = flags.String("name", "", "Optional. Name of VServer to create") + req.ListenType = flags.String("listen-type", "RequestProxy", "Optional. Listen type, 'RequestProxy' or 'PacketsTransmit'") + req.Protocol = flags.String("protocol", "HTTP", "Optional. Protocol of VServer instance, 'HTTP','HTTPS','TCP' for listen type 'RequestProxy' and 'TCP','UDP' for listen type 'PacketsTransmit'") + req.FrontendPort = flags.Int("port", 80, "Optional. Port of VServer instance") + flags.StringVar(&sslID, "ssl-id", "", "Optional. Required if you choose HTTPS, Resource ID of SSL Certificate") + req.Method = flags.String("lb-method", "Roundrobin", "Optional. LB methods, accept values:Roundrobin,Source,ConsistentHash,SourcePort,ConsistentHashPort,WeightRoundrobin and Leastconn. \nConsistentHash,SourcePort and ConsistentHashPort are effective for listen type PacketsTransmit only;\nLeastconn is effective for listen type RequestProxy only;\nRoundrobin,Source and WeightRoundrobin are effective for both listen types") + req.PersistenceType = flags.String("session-maintain-mode", "None", "Optional. The method of maintaining user's session. Accept values: 'None','ServerInsert' and 'UserDefined'. 'None' meaning don't maintain user's session'; 'ServerInsert' meaning auto create session key; 'UserDefined' meaning specify session key which accpeted by flag seesion-maintain-key by yourself") + req.PersistenceInfo = flags.String("session-maintain-key", "", "Optional. Specify a key for maintaining session") + req.ClientTimeout = flags.Int("client-timeout-seconds", 60, "Optional.Unit seconds. For 'RequestProxy', it's lifetime for idle connections, range (0,86400]. For 'PacketsTransmit', it's the duration of the connection is maintained, range [60,900]") + req.MonitorType = flags.String("health-check-mode", "Port", "Optional. Method of checking real server's status of health. Accept values:'Port','Path'") + req.Domain = flags.String("health-check-domain", "", "Optional. Skip this flag if health-check-mode is assigned Port") + req.Path = flags.String("health-check-path", "", "Optional. Skip this flags if health-check-mode is assigned Port") + + command.SetFlagValues(cmd, "listen-type", "RequestProxy", "PacketsTransmit") + command.SetFlagValues(cmd, "protocol", "HTTP", "HTTPS", "TCP", "UDP") + command.SetCompletion(cmd, "lb-method", func() []string { + if *req.ListenType == "RequestProxy" { + return []string{"Roundrobin", "Source", "WeightRoundrobin", "Leastconn"} + } else if *req.ListenType == "PacketsTransmit" { + return []string{"Roundrobin", "Source", "WeightRoundrobin", "ConsistentHash", "SourcePort", "ConsistentHashPort"} + } + return []string{"Roundrobin", "Source", "WeightRoundrobin", "ConsistentHash", "SourcePort", "ConsistentHashPort", "Leastconn"} + }) + command.SetFlagValues(cmd, "session-maintain-mode", "None", "ServerInsert", "UserDefined") + command.SetFlagValues(cmd, "health-check-mode", "Port", "Path") + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "ssl-id", func() []string { + return getAllSSLCertIDNames(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("ulb-id") + + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_delete.go b/products/ulb/internal/ulb/vserver_delete.go new file mode 100644 index 0000000000..d22719b3b2 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_delete.go @@ -0,0 +1,63 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newVServerDelete returns ucloud ulb vserver delete. +func newVServerDelete(ctx *cli.Context) *cobra.Command { + vserverIDs := []string{} + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDeleteVServerRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete ULB VServer instances", + Long: "Delete ULB VServer instances", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + results := []cli.OpResultRow{} + for _, idname := range vserverIDs { + vsid := ctx.PickResourceID(idname) + req.VServerId = sdk.String(vsid) + _, err := client.DeleteVServer(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ulb-vserver[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: vsid, Action: "delete-vserver", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB instance which the VServer to create belongs to") + flags.StringSliceVar(&vserverIDs, "vserver-id", nil, "Required. Resource ID of Vserver to update") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("ulb-id") + cmd.MarkFlagRequired("vserver-id") + + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "vserver-id", func() []string { + ulbID := ctx.PickResourceID(*req.ULBId) + return getAllVServerIDNames(ctx, ulbID, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_list.go b/products/ulb/internal/ulb/vserver_list.go new file mode 100644 index 0000000000..bc4361079f --- /dev/null +++ b/products/ulb/internal/ulb/vserver_list.go @@ -0,0 +1,67 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newVServerList returns ucloud ulb vserver list. +func newVServerList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDescribeVServerRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List ULB Vserver instances", + Long: "List ULB Vserver instances", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + resp, err := client.DescribeVServer(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []VServerRow{} + for _, vs := range resp.DataSet { + row := VServerRow{} + row.VServerName = vs.VServerName + row.ResourceID = vs.VServerId + row.ListenType = vs.ListenType + row.Protocol = vs.Protocol + row.Port = vs.FrontendPort + row.LBMethod = vs.Method + row.ClientTimeout = fmt.Sprintf("%ds", vs.ClientTimeout) + row.SessionMaintainMode = vs.PersistenceType + row.SessionMaintainKey = vs.PersistenceInfo + row.HealthCheckMode = vs.MonitorType + row.HealthCheckDomain = vs.Domain + row.HealthCheckPath = vs.Path + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB") + req.VServerId = flags.String("vserver-id", "", "Optional. Resource ID of vserver to list") + + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("ulb-id") + + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_node.go b/products/ulb/internal/ulb/vserver_node.go new file mode 100644 index 0000000000..cbe39ab451 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_node.go @@ -0,0 +1,21 @@ +package ulb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newBackend returns ucloud ulb vserver backend. +func newBackend(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "backend", + Short: "List and manipulate VServer backend nodes", + Long: "List and manipulate VServer backend nodes", + } + cmd.AddCommand(newBackendList(ctx)) + cmd.AddCommand(newBackendAdd(ctx)) + cmd.AddCommand(newBackendUpdate(ctx)) + cmd.AddCommand(newBackendDelete(ctx)) + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_node_add.go b/products/ulb/internal/ulb/vserver_node_add.go new file mode 100644 index 0000000000..5db2a56291 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_node_add.go @@ -0,0 +1,83 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBackendAdd returns ucloud ulb vserver backend add. +func newBackendAdd(ctx *cli.Context) *cobra.Command { + var enable *string + var weight *int + var ids []string + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewAllocateBackendRequest() + cmd := &cobra.Command{ + Use: "add", + Short: "Add backend nodes for ULB Vserver instance", + Long: "Add backend nodes for ULB Vserver instance", + Run: func(c *cobra.Command, args []string) { + if *enable == "enable" { + req.Enabled = sdk.Int(1) + } else if *enable == "disable" { + req.Enabled = sdk.Int(0) + } else { + fmt.Fprintln(ctx.ProgressWriter(), "Error, backend-mode must be enable or disable") + return + } + if *weight < 0 || *weight > 100 { + fmt.Fprintln(ctx.ProgressWriter(), "Error, weight must be between 0 and 100") + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + req.VServerId = sdk.String(ctx.PickResourceID(*req.VServerId)) + results := []cli.OpResultRow{} + for _, id := range ids { + req.ResourceId = sdk.String(id) + resp, err := client.AllocateBackend(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "backend node[%s] added, backend-id:%s\n", *req.ResourceId, resp.BackendId) + results = append(results, cli.OpResultRow{ResourceID: resp.BackendId, Action: "add-backend", Status: "Added"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB which the backend nodes belong to") + req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer which the backend nodes belong to") + flags.StringSliceVar(&ids, "resource-id", nil, "Required. Resource ID of the backend nodes to add") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.ResourceType = flags.String("resource-type", "UHost", "Optional. Resource type of the backend node to add. Accept values: UHost,UPM,UDHost,UDocker") + req.Port = flags.Int("port", 80, "Optional. The port of your real server on the backend node listening on") + enable = flags.String("backend-mode", "enable", "Optional. Enable backend node or not. Accept values: enable, disable") + weight = flags.Int("weight", 1, "Optional. effective for lb-method WeightRoundrobin. Rnage [0,100]") + + command.SetFlagValues(cmd, "resource-type", "Uhost", "UPM", "UDHost", "UDocker") + command.SetFlagValues(cmd, "backend-mode", "enable", "disable") + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "vserver-id", func() []string { + ulbID := ctx.PickResourceID(*req.ULBId) + return getAllVServerIDNames(ctx, ulbID, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("ulb-id") + cmd.MarkFlagRequired("vserver-id") + cmd.MarkFlagRequired("resource-id") + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_node_delete.go b/products/ulb/internal/ulb/vserver_node_delete.go new file mode 100644 index 0000000000..b103d44612 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_node_delete.go @@ -0,0 +1,59 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBackendDelete returns ucloud ulb vserver backend delete. +func newBackendDelete(ctx *cli.Context) *cobra.Command { + backendIDs := []string{} + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewReleaseBackendRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete ULB VServer backend nodes", + Long: "Delete ULB VServer backend nodes", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + results := []cli.OpResultRow{} + for _, idname := range backendIDs { + id := ctx.PickResourceID(idname) + req.BackendId = sdk.String(id) + _, err := client.ReleaseBackend(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "backend node[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete-backend", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB which the backend nodes belong to") + flags.StringSliceVar(&backendIDs, "backend-id", nil, "Required. BackendID of backend nodes to update") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("ulb-id") + cmd.MarkFlagRequired("backend-id") + + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "backend-id", func() []string { + return getAllBackendNodeIDNames(ctx, *req.ULBId, "", *req.ProjectId, *req.Region) + }) + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_node_list.go b/products/ulb/internal/ulb/vserver_node_list.go new file mode 100644 index 0000000000..1e8eb91120 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_node_list.go @@ -0,0 +1,81 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBackendList returns ucloud ulb vserver backend list. +func newBackendList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDescribeVServerRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List ULB VServer backend nodes", + Long: "List ULB VServer backend nodes", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + req.VServerId = sdk.String(ctx.PickResourceID(*req.VServerId)) + resp, err := client.DescribeVServer(req) + if err != nil { + ctx.HandleError(err) + return + } + if len(resp.DataSet) != 1 { + fmt.Fprintf(ctx.ProgressWriter(), "ulb[%s] or vserver[%s] may not exist\n", *req.ULBId, *req.VServerId) + return + } + vs := resp.DataSet[0] + list := []BackendRow{} + for _, node := range vs.BackendSet { + row := BackendRow{} + row.Name = node.ResourceName + row.ResourceID = node.ResourceId + row.BackendID = node.BackendId + row.PrivateIP = node.PrivateIP + row.Weight = node.Weight + row.Port = node.Port + if node.Status == 0 { + row.HealthCheck = "Normal" + } else if node.Status == 1 { + row.HealthCheck = "Failed" + } + if node.Enabled == 1 { + row.NodeMode = "enable" + } else if node.Enabled == 0 { + row.NodeMode = "disable" + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB which the backend nodes belong to") + req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer which the backend nodes belong to") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("ulb-id") + cmd.MarkFlagRequired("vserver-id") + + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "vserver-id", func() []string { + ulbID := ctx.PickResourceID(*req.ULBId) + return getAllVServerIDNames(ctx, ulbID, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_node_update.go b/products/ulb/internal/ulb/vserver_node_update.go new file mode 100644 index 0000000000..6deb9f9770 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_node_update.go @@ -0,0 +1,89 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBackendUpdate returns ucloud ulb vserver backend update. +func newBackendUpdate(ctx *cli.Context) *cobra.Command { + var mode *string + var weight *int + backendIDs := []string{} + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewUpdateBackendAttributeRequest() + cmd := &cobra.Command{ + Use: "update", + Short: "Update attributes of ULB backend nodes", + Long: "Update attributes of ULB backend nodes", + Run: func(c *cobra.Command, args []string) { + if *mode == "enable" { + req.Enabled = sdk.Int(1) + } else if *mode == "disable" { + req.Enabled = sdk.Int(0) + } else if *mode == "" { + req.Enabled = nil + } else { + fmt.Fprintln(ctx.ProgressWriter(), "Error, backend-mode must be enable or disable") + return + } + if *weight != -1 && (*weight < 0 || *weight > 100) { + fmt.Fprintln(ctx.ProgressWriter(), "Error, weight must be between 0 and 100") + return + } + if *weight != -1 { + req.Weight = weight + } + + if *req.Port == 0 { + req.Port = nil + } + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, bid := range backendIDs { + id := ctx.PickResourceID(bid) + req.BackendId = sdk.String(id) + _, err := client.UpdateBackendAttribute(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "backend node[%s] updated\n", bid) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "update-backend", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB which the backend nodes belong to") + flags.StringSliceVar(&backendIDs, "backend-id", nil, "Required. BackendID of backend nodes to update") + req.Port = flags.Int("port", 0, "Optional. Port of your real server listening on backend nodes to update. Rnage [1,65535]") + mode = flags.String("backend-mode", "", "Optional. Enable backend node or not. Accept values: enable, disable") + weight = flags.Int("weight", -1, "Optional. effective for lb-method WeightRoundrobin. Rnage [0,100], -1 meaning no update") + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetFlagValues(cmd, "backend-mode", "enable", "disable") + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "backend-id", func() []string { + return getAllBackendNodeIDNames(ctx, *req.ULBId, "", *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("ulb-id") + cmd.MarkFlagRequired("backend-id") + + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_policy.go b/products/ulb/internal/ulb/vserver_policy.go new file mode 100644 index 0000000000..8312cd6712 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_policy.go @@ -0,0 +1,21 @@ +package ulb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPolicy returns ucloud ulb vserver policy. +func newPolicy(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "policy", + Short: "List and manipulate forward policy for VServer", + Long: "List and manipulate forward policy for VServer", + } + cmd.AddCommand(newPolicyAdd(ctx)) + cmd.AddCommand(newPolicyList(ctx)) + cmd.AddCommand(newPolicyUpdate(ctx)) + cmd.AddCommand(newPolicyDelete(ctx)) + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_policy_create.go b/products/ulb/internal/ulb/vserver_policy_create.go new file mode 100644 index 0000000000..5d3dc86b38 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_policy_create.go @@ -0,0 +1,74 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newPolicyAdd returns ucloud ulb vserver policy add. +func newPolicyAdd(ctx *cli.Context) *cobra.Command { + backendIDs := []string{} + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewCreatePolicyRequest() + cmd := &cobra.Command{ + Use: "add", + Short: "Add content forward policy for VServer", + Long: "Add content forward policy for VServer", + Run: func(c *cobra.Command, args []string) { + if *req.Type != "Domain" && *req.Type != "Path" { + fmt.Fprintln(ctx.ProgressWriter(), "Error, forward method must be Domain or Path") + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + req.VServerId = sdk.String(ctx.PickResourceID(*req.VServerId)) + for _, idname := range backendIDs { + req.BackendId = append(req.BackendId, ctx.PickResourceID(idname)) + } + resp, err := client.CreatePolicy(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "policy[%s] created\n", resp.PolicyId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.PolicyId, Action: "create-policy", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB") + req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer") + flags.StringSliceVar(&backendIDs, "backend-id", nil, "Required. BackendID of the VServer's backend nodes") + req.Type = flags.String("forward-method", "", "Required. Forward method, accept values:Domain and Path; Both forwarding methods can be described by using regular expressions or wildcards") + req.Match = flags.String("expression", "", "Required. Expression of domain or path, such as \"www.[123].demo.com\" or \"/path/img/*.jpg\"") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetFlagValues(cmd, "forward-method", "Domain", "Path") + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "vserver-id", func() []string { + return getAllVServerIDNames(ctx, *req.ULBId, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "backend-id", func() []string { + return getAllBackendNodeIDNames(ctx, *req.ULBId, *req.VServerId, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("ulb-id") + cmd.MarkFlagRequired("vserver-id") + cmd.MarkFlagRequired("backend-id") + cmd.MarkFlagRequired("forward-method") + cmd.MarkFlagRequired("expression") + + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_policy_delete.go b/products/ulb/internal/ulb/vserver_policy_delete.go new file mode 100644 index 0000000000..7a1d0ef734 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_policy_delete.go @@ -0,0 +1,51 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newPolicyDelete returns ucloud ulb vserver policy delete. +func newPolicyDelete(ctx *cli.Context) *cobra.Command { + policyIDs := []string{} + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDeletePolicyRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete content forward policies of ULB VServer", + Long: "Delete content forward policies of ULB VServer", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, p := range policyIDs { + req.PolicyId = sdk.String(p) + _, err := client.DeletePolicy(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "policy[%s] deleted\n", p) + results = append(results, cli.OpResultRow{ResourceID: p, Action: "delete-policy", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringSliceVar(&policyIDs, "policy-id", nil, "Required. PolicyID of policies to delete") + req.VServerId = flags.String("vserver-id", "", "Optional. Resource ID of VServer") + + cmd.MarkFlagRequired("policy-id") + + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_policy_list.go b/products/ulb/internal/ulb/vserver_policy_list.go new file mode 100644 index 0000000000..e3e62191e3 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_policy_list.go @@ -0,0 +1,69 @@ +package ulb + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newPolicyList returns ucloud ulb vserver policy list. +func newPolicyList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewDescribeVServerRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List content forward policies of the VServer instance", + Long: "List content forward policies of the VServer instance", + Run: func(c *cobra.Command, args []string) { + ulbID := ctx.PickResourceID(*req.ULBId) + vserverID := ctx.PickResourceID(*req.VServerId) + vsList, err := getAllVServers(ctx, ulbID, vserverID, *req.ProjectId, *req.Region) + if err != nil { + ctx.HandleError(err) + return + } + if len(vsList) == 1 { + vs := vsList[0] + list := []PolicyRow{} + for _, p := range vs.PolicySet { + row := PolicyRow{} + row.ForwardMethod = p.Type + row.Expression = p.Match + row.PolicyID = p.PolicyId + row.PolicyType = p.PolicyType + nodes := []string{} + for _, b := range p.BackendSet { + nodes = append(nodes, fmt.Sprintf("%s|%s:%d|%s", b.BackendId, b.PrivateIP, b.Port, b.ResourceName)) + } + row.Backends = strings.Join(nodes, ",") + list = append(list, row) + } + ctx.PrintList(list) + } + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB") + req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer") + + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "vserver-id", func() []string { + ulb := ctx.PickResourceID(*req.ULBId) + return getAllVServerIDNames(ctx, ulb, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("ulb-id") + cmd.MarkFlagRequired("vserver-id") + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_policy_update.go b/products/ulb/internal/ulb/vserver_policy_update.go new file mode 100644 index 0000000000..bc94b713f3 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_policy_update.go @@ -0,0 +1,133 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newPolicyUpdate returns ucloud ulb vserver policy update. +func newPolicyUpdate(ctx *cli.Context) *cobra.Command { + policyIDs := []string{} + backendIDs := []string{} + addBackendIDs := []string{} + removeBackendIDs := []string{} + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewUpdatePolicyRequest() + cmd := &cobra.Command{ + Use: "update", + Short: "Update content forward policies of ULB VServer", + Long: "Update content forward policies ULB VServer", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + req.VServerId = sdk.String(ctx.PickResourceID(*req.VServerId)) + + vsList, err := getAllVServers(ctx, *req.ULBId, *req.VServerId, *req.ProjectId, *req.Region) + if err != nil { + ctx.HandleError(err) + return + } + vs := vsList[0] + + results := []cli.OpResultRow{} + for _, policyID := range policyIDs { + var policy *ulbsdk.ULBPolicySet + for _, p := range vs.PolicySet { + if p.PolicyId == policyID { + policy = &p + break + } + } + if policy == nil { + fmt.Fprintf(ctx.ProgressWriter(), "policy[%s] not found\n", policyID) + continue + } + req.PolicyId = sdk.String(policyID) + if *req.Type == "" { + req.Type = sdk.String(policy.Type) + } else if *req.Type != "Domain" && *req.Type != "Path" { + fmt.Fprintln(ctx.ProgressWriter(), "Error, forward-method must be Domain or Path") + continue + } + if *req.Match == "" { + req.Match = sdk.String(policy.Match) + } + backendIDMap := map[string]bool{} + if backendIDs == nil { + for _, b := range policy.BackendSet { + backendIDMap[b.BackendId] = true + } + } else { + for _, bid := range backendIDs { + backendIDMap[ctx.PickResourceID(bid)] = true + } + } + for _, bid := range addBackendIDs { + backendIDMap[ctx.PickResourceID(bid)] = true + } + for _, bid := range removeBackendIDs { + backendIDMap[ctx.PickResourceID(bid)] = false + } + req.BackendId = nil + for bid, ok := range backendIDMap { + if ok { + req.BackendId = append(req.BackendId, bid) + } + } + resp, err := client.UpdatePolicy(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "policy[%s] updated\n", resp.PolicyId) + results = append(results, cli.OpResultRow{ResourceID: resp.PolicyId, Action: "update-policy", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB") + req.VServerId = flags.String("vserver-id", "", "Required. Resource ID of VServer") + flags.StringSliceVar(&policyIDs, "policy-id", nil, "Required. PolicyID of policies to update") + flags.StringSliceVar(&backendIDs, "backend-id", nil, "Optional. BackendID of backend nodes. If assign this flag, it will rewrite all backend nodes of the policy") + flags.StringSliceVar(&addBackendIDs, "add-backend-id", nil, "Optional. BackendID of backend nodes. Add backend nodes to the policy") + flags.StringSliceVar(&removeBackendIDs, "remove-backend-id", nil, "Optional. BackendID of backend nodes. Remove those backend nodes from the policy") + req.Type = flags.String("forward-method", "", "Optional. Forward method of policy, accept values:Domain and Path") + req.Match = flags.String("expression", "", "Optional. Expression of domain or path, such as \"www.[123].demo.com\" or \"/path/img/*.jpg\"") + + cmd.MarkFlagRequired("ulb-id") + cmd.MarkFlagRequired("vserver-id") + cmd.MarkFlagRequired("policy-id") + + command.SetFlagValues(cmd, "forward-method", "Domain", "Path") + command.SetCompletion(cmd, "ulb-id", func() []string { + project := ctx.PickResourceID(*req.ProjectId) + return getAllULBIDNames(ctx, project, *req.Region) + }) + command.SetCompletion(cmd, "vserver-id", func() []string { + return getAllVServerIDNames(ctx, *req.ULBId, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "backend-id", func() []string { + return getAllBackendNodeIDNames(ctx, *req.ULBId, *req.VServerId, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "add-backend-id", func() []string { + return getAllBackendNodeIDNames(ctx, *req.ULBId, *req.VServerId, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "remove-backend-id", func() []string { + return getAllBackendNodeIDNames(ctx, *req.ULBId, *req.VServerId, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/ulb/internal/ulb/vserver_update.go b/products/ulb/internal/ulb/vserver_update.go new file mode 100644 index 0000000000..dae228c7a2 --- /dev/null +++ b/products/ulb/internal/ulb/vserver_update.go @@ -0,0 +1,97 @@ +package ulb + +import ( + "fmt" + + "github.com/spf13/cobra" + + ulbsdk "github.com/ucloud/ucloud-sdk-go/services/ulb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newVServerUpdate returns ucloud ulb vserver update. +func newVServerUpdate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ulbsdk.NewClient) + req := client.NewUpdateVServerAttributeRequest() + vserverIDs := []string{} + cmd := &cobra.Command{ + Use: "update", + Short: "Update attributes of VServer instances", + Long: "Update attributes of VServer instances", + Run: func(c *cobra.Command, args []string) { + if *req.VServerName == "" { + req.VServerName = nil + } + if *req.Method == "" { + req.Method = nil + } + if *req.PersistenceType == "" { + req.PersistenceType = nil + } + if *req.PersistenceInfo == "" { + req.PersistenceInfo = nil + } + if *req.ClientTimeout == -1 { + req.ClientTimeout = nil + } + if *req.MonitorType == "" { + req.MonitorType = nil + } + if *req.Domain == "" { + req.Domain = nil + } + if *req.Path == "" { + req.Path = nil + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.ULBId = sdk.String(ctx.PickResourceID(*req.ULBId)) + results := []cli.OpResultRow{} + for _, idname := range vserverIDs { + id := ctx.PickResourceID(idname) + req.VServerId = sdk.String(id) + _, err := client.UpdateVServerAttribute(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "ulb-vserver[%s] updated\n", *req.VServerId) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "update-vserver", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.ULBId = flags.String("ulb-id", "", "Required. Resource ID of ULB instance which the VServer to create belongs to") + flags.StringSliceVar(&vserverIDs, "vserver-id", nil, "Required. Resource ID of Vserver to update") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.VServerName = flags.String("name", "", "Optional. Name of VServer") + req.Method = flags.String("lb-method", "", "Optional. LB methods, accept values:Roundrobin,Source,ConsistentHash,SourcePort,ConsistentHashPort,WeightRoundrobin and Leastconn. \nConsistentHash,SourcePort and ConsistentHashPort are effective for listen type PacketsTransmit only;\nLeastconn is effective for listen type RequestProxy only;\nRoundrobin,Source and WeightRoundrobin are effective for both listen types") + req.PersistenceType = flags.String("session-maintain-mode", "", "Optional. The method of maintaining user's session. Accept values: 'None','ServerInsert' and 'UserDefined'. 'None' meaning don't maintain user's session'; 'ServerInsert' meaning auto create session key; 'UserDefined' meaning specify session key which accpeted by flag seesion-maintain-key by yourself") + req.PersistenceInfo = flags.String("session-maintain-key", "", "Optional. Specify a key for maintaining session") + req.ClientTimeout = flags.Int("client-timeout-seconds", -1, "Optional.Unit seconds. For 'RequestProxy', it's lifetime for idle connections, range (0,86400]. For 'PacketsTransmit', it's the duration of the connection is maintained, range [60,900]") + req.MonitorType = flags.String("health-check-mode", "", "Optional. Method of checking real server's status of health. Accept values:'Port','Path'") + req.Domain = flags.String("health-check-domain", "", "Optional. Skip this flag if health-check-mode is assigned Port") + req.Path = flags.String("health-check-path", "", "Optional. Skip this flags if health-check-mode is assigned Port") + + command.SetFlagValues(cmd, "lb-method", "Roundrobin", "Source", "WeightRoundrobin", "ConsistentHash", "SourcePort", "ConsistentHashPort", "Leastconn") + command.SetFlagValues(cmd, "session-maintain-mode", "None", "ServerInsert", "UserDefined") + command.SetFlagValues(cmd, "health-check-mode", "Port", "Path") + command.SetCompletion(cmd, "ulb-id", func() []string { + return getAllULBIDNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "vserver-id", func() []string { + ulbID := ctx.PickResourceID(*req.ULBId) + return getAllVServerIDNames(ctx, ulbID, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("ulb-id") + cmd.MarkFlagRequired("vserver-id") + + return cmd +} diff --git a/products/ulb/product.go b/products/ulb/product.go new file mode 100644 index 0000000000..9dbb868a06 --- /dev/null +++ b/products/ulb/product.go @@ -0,0 +1,21 @@ +package ulb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalulb "github.com/ucloud/ucloud-cli/products/ulb/internal/ulb" +) + +type product struct{} + +// New returns the ulb product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "ulb", Commands: []string{"ulb"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalulb.NewCommand(ctx)} +} diff --git a/products/ulb/product.yaml b/products/ulb/product.yaml new file mode 100644 index 0000000000..f18008496e --- /dev/null +++ b/products/ulb/product.yaml @@ -0,0 +1,7 @@ +# products/ulb/product.yaml — ULB product metadata. +name: ulb +owners: + - Episkey-G +commands: + - ulb +enabled: true diff --git a/products/ulb/testdata/cmdtree.golden b/products/ulb/testdata/cmdtree.golden new file mode 100644 index 0000000000..456fc2c6b5 --- /dev/null +++ b/products/ulb/testdata/cmdtree.golden @@ -0,0 +1,172 @@ +ucloud ulb use=ulb short=List and manipulate ULB instances +ucloud ulb create use=create short=Create ULB instance + flag=bind-eip short= default= required= + flag=charge-type short= default=Month required= + flag=create-eip-bandwidth-mb short= default=0 required= + flag=create-eip-line short= default= required= + flag=create-eip-name short= default= required= + flag=create-eip-remark short= default= required= + flag=create-eip-traffic-mode short= default=Bandwidth required= + flag=group short= default=Default required= + flag=mode short= default=outer required=true + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= +ucloud ulb delete use=delete short=Delete ULB instances by resource ID + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulb-id short= default=[] required=true +ucloud ulb list use=list short=List ULB instances + flag=group short= default= required= + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=subnet-id short= default= required= + flag=ulb-id short= default= required= + flag=vpc-id short= default= required= +ucloud ulb ssl use=ssl short=List and manipulate SSL Certificates for ULB +ucloud ulb ssl add use=add short=Add SSL Certificate + flag=all-in-one-file short= default= required= + flag=ca-certificate-file short= default= required= + flag=format short= default=Pem required= + flag=name short= default= required=true + flag=private-key-file short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=site-certificate-file short= default= required= +ucloud ulb ssl bind use=bind short=Bind SSL Certificate with VServer + flag=project-id short= default= required= + flag=region short= default= required= + flag=ssl-id short= default= required=true + flag=ulb-id short= default= required=true + flag=vserver-id short= default= required=true +ucloud ulb ssl delete use=delete short=Delete SSL Certificates by resource id(ssl id) + flag=project-id short= default= required= + flag=region short= default= required= + flag=ssl-id short= default=[] required= +ucloud ulb ssl describe use=describe short=Display all data associated with SSL Certificate + flag=project-id short= default= required= + flag=region short= default= required= + flag=ssl-id short= default= required=true +ucloud ulb ssl list use=list short=List SSL Certificates + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=ssl-id short= default= required= +ucloud ulb ssl unbind use=unbind short=Unbind SSL Certificate with VServer + flag=project-id short= default= required= + flag=region short= default= required= + flag=ssl-id short= default= required=true + flag=ulb-id short= default= required=true + flag=vserver-id short= default= required=true +ucloud ulb update use=update short=Update ULB instance + flag=group short= default= required= + flag=name short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=ulb-id short= default=[] required=true +ucloud ulb vserver use=vserver short=List and manipulate ULB Vserver instances +ucloud ulb vserver backend use=backend short=List and manipulate VServer backend nodes +ucloud ulb vserver backend add use=add short=Add backend nodes for ULB Vserver instance + flag=backend-mode short= default=enable required= + flag=port short= default=80 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=resource-id short= default=[] required=true + flag=resource-type short= default=UHost required= + flag=ulb-id short= default= required=true + flag=vserver-id short= default= required=true + flag=weight short= default=1 required= +ucloud ulb vserver backend delete use=delete short=Delete ULB VServer backend nodes + flag=backend-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulb-id short= default= required=true +ucloud ulb vserver backend list use=list short=List ULB VServer backend nodes + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulb-id short= default= required=true + flag=vserver-id short= default= required=true +ucloud ulb vserver backend update use=update short=Update attributes of ULB backend nodes + flag=backend-id short= default=[] required=true + flag=backend-mode short= default= required= + flag=port short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulb-id short= default= required=true + flag=weight short= default=-1 required= +ucloud ulb vserver create use=create short=Create ULB VServer instance + flag=client-timeout-seconds short= default=60 required= + flag=health-check-domain short= default= required= + flag=health-check-mode short= default=Port required= + flag=health-check-path short= default= required= + flag=lb-method short= default=Roundrobin required= + flag=listen-type short= default=RequestProxy required= + flag=name short= default= required= + flag=port short= default=80 required= + flag=project-id short= default= required= + flag=protocol short= default=HTTP required= + flag=region short= default= required= + flag=session-maintain-key short= default= required= + flag=session-maintain-mode short= default=None required= + flag=ssl-id short= default= required= + flag=ulb-id short= default= required=true +ucloud ulb vserver delete use=delete short=Delete ULB VServer instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulb-id short= default= required=true + flag=vserver-id short= default=[] required=true +ucloud ulb vserver list use=list short=List ULB Vserver instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulb-id short= default= required=true + flag=vserver-id short= default= required= +ucloud ulb vserver policy use=policy short=List and manipulate forward policy for VServer +ucloud ulb vserver policy add use=add short=Add content forward policy for VServer + flag=backend-id short= default=[] required=true + flag=expression short= default= required=true + flag=forward-method short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulb-id short= default= required=true + flag=vserver-id short= default= required=true +ucloud ulb vserver policy delete use=delete short=Delete content forward policies of ULB VServer + flag=policy-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=vserver-id short= default= required= +ucloud ulb vserver policy list use=list short=List content forward policies of the VServer instance + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulb-id short= default= required=true + flag=vserver-id short= default= required=true +ucloud ulb vserver policy update use=update short=Update content forward policies of ULB VServer + flag=add-backend-id short= default=[] required= + flag=backend-id short= default=[] required= + flag=expression short= default= required= + flag=forward-method short= default= required= + flag=policy-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remove-backend-id short= default=[] required= + flag=ulb-id short= default= required=true + flag=vserver-id short= default= required=true +ucloud ulb vserver update use=update short=Update attributes of VServer instances + flag=client-timeout-seconds short= default=-1 required= + flag=health-check-domain short= default= required= + flag=health-check-mode short= default= required= + flag=health-check-path short= default= required= + flag=lb-method short= default= required= + flag=name short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=session-maintain-key short= default= required= + flag=session-maintain-mode short= default= required= + flag=ulb-id short= default= required=true + flag=vserver-id short= default=[] required=true diff --git a/products/ulb/testdata/completion.golden b/products/ulb/testdata/completion.golden new file mode 100644 index 0000000000..4d9cd29cbe --- /dev/null +++ b/products/ulb/testdata/completion.golden @@ -0,0 +1,104 @@ +ucloud ulb create bind-eip dynamic +ucloud ulb create charge-type static Dynamic,Month,Year +ucloud ulb create create-eip-line static BGP,International +ucloud ulb create create-eip-traffic-mode static Bandwidth,ShareBandwidth,Traffic +ucloud ulb create mode static inner,outer +ucloud ulb create project-id dynamic +ucloud ulb create region dynamic +ucloud ulb create subnet-id dynamic +ucloud ulb create vpc-id dynamic +ucloud ulb delete project-id dynamic +ucloud ulb delete region dynamic +ucloud ulb delete ulb-id dynamic +ucloud ulb list project-id dynamic +ucloud ulb list region dynamic +ucloud ulb list vpc-id dynamic +ucloud ulb ssl add all-in-one-file static +ucloud ulb ssl add ca-certificate-file static +ucloud ulb ssl add private-key-file static +ucloud ulb ssl add project-id dynamic +ucloud ulb ssl add region dynamic +ucloud ulb ssl add site-certificate-file static +ucloud ulb ssl bind project-id dynamic +ucloud ulb ssl bind region dynamic +ucloud ulb ssl bind ssl-id dynamic +ucloud ulb ssl bind ulb-id dynamic +ucloud ulb ssl bind vserver-id dynamic +ucloud ulb ssl delete project-id dynamic +ucloud ulb ssl delete region dynamic +ucloud ulb ssl delete ssl-id dynamic +ucloud ulb ssl describe project-id dynamic +ucloud ulb ssl describe region dynamic +ucloud ulb ssl describe ssl-id dynamic +ucloud ulb ssl list project-id dynamic +ucloud ulb ssl list region dynamic +ucloud ulb ssl unbind project-id dynamic +ucloud ulb ssl unbind region dynamic +ucloud ulb ssl unbind ssl-id dynamic +ucloud ulb ssl unbind ulb-id dynamic +ucloud ulb ssl unbind vserver-id dynamic +ucloud ulb update project-id dynamic +ucloud ulb update region dynamic +ucloud ulb update ulb-id dynamic +ucloud ulb vserver backend add backend-mode static disable,enable +ucloud ulb vserver backend add project-id dynamic +ucloud ulb vserver backend add region dynamic +ucloud ulb vserver backend add resource-type static UDHost,UDocker,UPM,Uhost +ucloud ulb vserver backend add ulb-id dynamic +ucloud ulb vserver backend add vserver-id dynamic +ucloud ulb vserver backend delete backend-id dynamic +ucloud ulb vserver backend delete project-id dynamic +ucloud ulb vserver backend delete region dynamic +ucloud ulb vserver backend delete ulb-id dynamic +ucloud ulb vserver backend list project-id dynamic +ucloud ulb vserver backend list region dynamic +ucloud ulb vserver backend list ulb-id dynamic +ucloud ulb vserver backend list vserver-id dynamic +ucloud ulb vserver backend update backend-id dynamic +ucloud ulb vserver backend update backend-mode static disable,enable +ucloud ulb vserver backend update project-id dynamic +ucloud ulb vserver backend update region dynamic +ucloud ulb vserver backend update ulb-id dynamic +ucloud ulb vserver create health-check-mode static Path,Port +ucloud ulb vserver create lb-method static Leastconn,Roundrobin,Source,WeightRoundrobin +ucloud ulb vserver create listen-type static PacketsTransmit,RequestProxy +ucloud ulb vserver create project-id dynamic +ucloud ulb vserver create protocol static HTTP,HTTPS,TCP,UDP +ucloud ulb vserver create region dynamic +ucloud ulb vserver create session-maintain-mode static None,ServerInsert,UserDefined +ucloud ulb vserver create ssl-id dynamic +ucloud ulb vserver create ulb-id dynamic +ucloud ulb vserver delete project-id dynamic +ucloud ulb vserver delete region dynamic +ucloud ulb vserver delete ulb-id dynamic +ucloud ulb vserver delete vserver-id dynamic +ucloud ulb vserver list project-id dynamic +ucloud ulb vserver list region dynamic +ucloud ulb vserver list ulb-id dynamic +ucloud ulb vserver policy add backend-id dynamic +ucloud ulb vserver policy add forward-method static Domain,Path +ucloud ulb vserver policy add project-id dynamic +ucloud ulb vserver policy add region dynamic +ucloud ulb vserver policy add ulb-id dynamic +ucloud ulb vserver policy add vserver-id dynamic +ucloud ulb vserver policy delete project-id dynamic +ucloud ulb vserver policy delete region dynamic +ucloud ulb vserver policy list project-id dynamic +ucloud ulb vserver policy list region dynamic +ucloud ulb vserver policy list ulb-id dynamic +ucloud ulb vserver policy list vserver-id dynamic +ucloud ulb vserver policy update add-backend-id dynamic +ucloud ulb vserver policy update backend-id dynamic +ucloud ulb vserver policy update forward-method static Domain,Path +ucloud ulb vserver policy update project-id dynamic +ucloud ulb vserver policy update region dynamic +ucloud ulb vserver policy update remove-backend-id dynamic +ucloud ulb vserver policy update ulb-id dynamic +ucloud ulb vserver policy update vserver-id dynamic +ucloud ulb vserver update health-check-mode static Path,Port +ucloud ulb vserver update lb-method static ConsistentHash,ConsistentHashPort,Leastconn,Roundrobin,Source,SourcePort,WeightRoundrobin +ucloud ulb vserver update project-id dynamic +ucloud ulb vserver update region dynamic +ucloud ulb vserver update session-maintain-mode static None,ServerInsert,UserDefined +ucloud ulb vserver update ulb-id dynamic +ucloud ulb vserver update vserver-id dynamic diff --git a/products/ulhost/internal/image/cmd.go b/products/ulhost/internal/image/cmd.go new file mode 100644 index 0000000000..7474b4468d --- /dev/null +++ b/products/ulhost/internal/image/cmd.go @@ -0,0 +1,22 @@ +package image + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `image` subcommand nested under `ulhost` (ucloud ulhost +// image list), mirroring the uhost image subcommand pattern. ULHost uses the +// same DescribeImage API as UHost but with different defaults. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "image", + Short: "List ULHost images", + Long: `List available images for ULHost instances`, + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + + return cmd +} diff --git a/products/ulhost/internal/image/list.go b/products/ulhost/internal/image/list.go new file mode 100644 index 0000000000..d0ed69362c --- /dev/null +++ b/products/ulhost/internal/image/list.go @@ -0,0 +1,58 @@ +package image + +import ( + "strings" + + "github.com/spf13/cobra" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList ucloud image list (ulhost variant) +// ULHost uses the same DescribeImage API as UHost but with different defaults. +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeImageRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List ULHost images", + Long: "List available images for ULHost instances", + Example: "ucloud image list --image-type Base", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.DescribeImage(req) + if err != nil { + ctx.HandleError(err) + return + } + list := make([]ImageRow, 0) + for _, image := range resp.ImageSet { + row := ImageRow{} + row.ImageName = image.ImageName + row.ImageID = image.ImageId + row.ImageType = image.ImageType + row.BasicImage = image.OsName + row.ExtensibleFeature = strings.Join(image.Features, ",") + row.CreationTime = common.FormatDate(image.CreateTime) + row.State = image.State + if row.State == imageStateAvailable { + list = append(list, row) + } + } + ctx.PrintList(list) + }, + } + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone") + req.ImageType = cmd.Flags().String("image-type", "Base", "Optional. 'Base',Standard image; 'Business',image market; 'Custom',custom image") + req.OsType = cmd.Flags().String("os-type", "", "Optional. Linux or Windows. Return all types by default") + req.ImageId = cmd.Flags().String("image-id", "", "Optional. Resource ID of image") + req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0") + req.Limit = cmd.Flags().Int("limit", 500, "Optional. Max count") + command.SetFlagValues(cmd, "image-type", "Base", "Business", "Custom") + return cmd +} diff --git a/products/ulhost/internal/image/rows.go b/products/ulhost/internal/image/rows.go new file mode 100644 index 0000000000..27963cbf6d --- /dev/null +++ b/products/ulhost/internal/image/rows.go @@ -0,0 +1,19 @@ +package image + +// ImageRow 表格行 — mirrors uhost's ImageRow for ulhost image list display. +type ImageRow struct { + ImageName string + ImageID string + ImageType string + BasicImage string + ExtensibleFeature string + CreationTime string + State string +} + +// Image-state constants mirrored from the uhost image product. Used by list to +// filter to Available images only. +const ( + imageStateAvailable = "Available" + imageStateUnavailable = "Unavailable" +) diff --git a/products/ulhost/internal/ulhost/bundles.go b/products/ulhost/internal/ulhost/bundles.go new file mode 100644 index 0000000000..7dca4533ca --- /dev/null +++ b/products/ulhost/internal/ulhost/bundles.go @@ -0,0 +1,50 @@ +package ulhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBundles ucloud ulhost bundles +func newBundles(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewDescribeULHostBundlesRequest() + cmd := &cobra.Command{ + Use: "bundles", + Short: "List all ULHost bundles", + Long: `List all ULHost bundles (套餐列表)`, + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.DescribeULHostBundles(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]bundleRow, 0, len(resp.Bundles)) + for _, bundle := range resp.Bundles { + row := bundleRow{ + BundleID: bundle.BundleId, + CPU: fmt.Sprintf("%d", bundle.CPU), + Memory: fmt.Sprintf("%dG", bundle.Memory/1024), + SysDiskSpace: fmt.Sprintf("%dG", bundle.SysDiskSpace), + Bandwidth: fmt.Sprintf("%dM", bundle.Bandwidth), + TrafficPacket: fmt.Sprintf("%dG", bundle.TrafficPacket), + } + rows = append(rows, row) + } + ctx.PrintList(rows) + }, + } + cmd.Flags().SortFlags = false + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "region", ctx.RegionList) + + return cmd +} diff --git a/products/ulhost/internal/ulhost/cmd.go b/products/ulhost/internal/ulhost/cmd.go new file mode 100644 index 0000000000..9627b38c8e --- /dev/null +++ b/products/ulhost/internal/ulhost/cmd.go @@ -0,0 +1,47 @@ +package ulhost + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalimage "github.com/ucloud/ucloud-cli/products/ulhost/internal/image" +) + +// NewCommand builds the `ulhost` root command and mounts the subcommands in +// the same AddCommand order as the uhost product: list, create, delete, stop, +// start, restart, poweroff, reset-password, reinstall-os, modify-attribute, +// bundles, price. The ulhost-scoped `image list` is mounted as a nested +// subcommand here rather than a top-level `image` command: the top-level +// `image` name is already claimed by the uhost product (uhost's image command +// exposes copy/delete/create; ulhost only lists), so a second top-level +// `image` would shadow uhost's at runtime (cobra registers top-level commands +// by name) and break the snapshot partition golden. +// +// NOTE: The backend API also exposes UpdateULHostInstanceFirewall, +// ModifyULHostProxyIp, CheckULHostResourceCapacity, and share-bandwidth +// management, but the public ucompshare SDK does not yet support these +// operations. When the SDK adds them, corresponding CLI commands should be +// added here following the same pattern. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ulhost", + Short: "List,create,delete,stop,restart,poweroff or resize ULHost instance", + Long: `List,create,delete,stop,restart,poweroff or resize ULHost instance`, + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newStop(ctx)) + cmd.AddCommand(newStart(ctx)) + cmd.AddCommand(newReboot(ctx)) + cmd.AddCommand(newPoweroff(ctx)) + cmd.AddCommand(newResetPassword(ctx)) + cmd.AddCommand(newReinstallOS(ctx)) + cmd.AddCommand(newModifyAttribute(ctx)) + cmd.AddCommand(newBundles(ctx)) + cmd.AddCommand(newPrice(ctx)) + cmd.AddCommand(internalimage.NewCommand(ctx)) + + return cmd +} diff --git a/products/ulhost/internal/ulhost/completion.go b/products/ulhost/internal/ulhost/completion.go new file mode 100644 index 0000000000..2c4aab62c2 --- /dev/null +++ b/products/ulhost/internal/ulhost/completion.go @@ -0,0 +1,51 @@ +package ulhost + +import ( + "fmt" + "strings" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// completion.go holds the cross-product completion-data fetchers that ulhost's +// flags need (--ulhost-id, --bundle-id). Each is a self-contained SDK call +// (NOT imported — products stay boundary-isolated), with cli.NewServiceClient. + +// getULHostList returns "ULHostId/Name" completion candidates filtered by states +// (nil = all). Mirrors uhost's getUhostList pattern. +func getULHostList(ctx *cli.Context, states []string, project, region string) []string { + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewDescribeULHostInstanceRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Limit = sdk.Int(100) + resp, err := client.DescribeULHostInstance(req) + if err != nil { + return nil + } + list := []string{} + for _, host := range resp.ULHostInstanceSets { + if states != nil { + for _, s := range states { + if host.State == s { + list = append(list, host.ULHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) + } + } + } else { + list = append(list, host.ULHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) + } + } + return list +} + +// formatBundleInfo returns a human-readable description of a bundle. +func formatBundleInfo(cpu, memory, sysDiskSpace, bandwidth, trafficPacket int) string { + memoryGB := memory / 1024 + if trafficPacket > 0 { + return fmt.Sprintf("cpu:%d memory:%dG disk:%dG bandwidth:%dM traffic:%dG", cpu, memoryGB, sysDiskSpace, bandwidth, trafficPacket) + } + return fmt.Sprintf("cpu:%d memory:%dG disk:%dG bandwidth:%dM", cpu, memoryGB, sysDiskSpace, bandwidth) +} diff --git a/products/ulhost/internal/ulhost/create.go b/products/ulhost/internal/ulhost/create.go new file mode 100644 index 0000000000..b684105a64 --- /dev/null +++ b/products/ulhost/internal/ulhost/create.go @@ -0,0 +1,98 @@ +package ulhost + +import ( + "encoding/base64" + "fmt" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud ulhost create +func newCreate(ctx *cli.Context) *cobra.Command { + var async bool + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewCreateULHostInstanceRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create ULHost instance", + Long: "Create ULHost instance", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + req.ImageId = sdk.String(ctx.PickResourceID(*req.ImageId)) + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId)) + req.SecurityGroupId = sdk.String(ctx.PickResourceID(*req.SecurityGroupId)) + // Encode password to base64 + if *req.Password != "" { + req.Password = sdk.String(base64.StdEncoding.EncodeToString([]byte(*req.Password))) + } + + resp, err := client.CreateULHostInstance(req) + if err != nil { + return err + } + w := ctx.ProgressWriter() + text := fmt.Sprintf("ulhost[%s] is creating", resp.ULHostId) + if async { + fmt.Fprintln(w, text) + } else { + prog := ctx.NewProgress() + block := prog.NewBlock() + prog.Sspoll(sdescribeULHostByID(ctx), resp.ULHostId, text, []string{HOST_RUNNING, HOST_FAIL}, block, &req.CommonBase) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ULHostId, Action: "create", Status: "Initializing"}) + return nil + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + ctx.BindRegion(cmd, req) + req.BundleId = flags.String("bundle-id", "", "Required. Bundle ID of the ULHost instance, e.g. ulh.c1m1s40b30t800") + req.ImageId = flags.String("image-id", "", "Required. Image ID. See 'ucloud ulhost image list'") + req.Password = flags.String("password", "", "Required. Password of the ulhost instance") + req.Name = flags.String("name", "", "Optional. ULHost instance name") + req.ChargeType = flags.String("charge-type", "Month", "Optional. 'Year', pay yearly; 'Month', pay monthly; default: Month") + req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.") + req.SecurityGroupId = flags.String("security-group-id", "", "Optional. Firewall ID, default: Web recommended firewall") + req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID. Default VPC will be used if not specified") + req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID. Default subnet will be used if not specified") + req.CouponId = flags.String("coupon-id", "", "Optional. Coupon ID") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for the long-running operation to finish.") + + command.SetFlagValues(cmd, "charge-type", "Month", "Year") + command.SetCompletion(cmd, "bundle-id", func() []string { + return getULHostBundleIDList(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("bundle-id") + cmd.MarkFlagRequired("image-id") + cmd.MarkFlagRequired("password") + + return cmd +} + +// getULHostBundleIDList returns bundle ID completion candidates. +func getULHostBundleIDList(ctx *cli.Context, project, region string) []string { + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewDescribeULHostBundlesRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + resp, err := client.DescribeULHostBundles(req) + if err != nil { + return nil + } + list := []string{} + for _, bundle := range resp.Bundles { + desc := formatBundleInfo(bundle.CPU, bundle.Memory, bundle.SysDiskSpace, bundle.Bandwidth, bundle.TrafficPacket) + list = append(list, bundle.BundleId+"/"+desc) + } + return list +} diff --git a/products/ulhost/internal/ulhost/delete.go b/products/ulhost/internal/ulhost/delete.go new file mode 100644 index 0000000000..22e38a45d3 --- /dev/null +++ b/products/ulhost/internal/ulhost/delete.go @@ -0,0 +1,74 @@ +package ulhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud ulhost delete +func newDelete(ctx *cli.Context) *cobra.Command { + var ulhostIDs *[]string + var yes *bool + var releaseUDisk bool + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewTerminateULHostInstanceRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete ULHost instance", + Long: "Delete ULHost instance", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ok, err := ctx.Confirm(*yes, "Are you sure you want to delete the ulhost instance(s)?") + if err != nil { + return err + } + if !ok { + return nil + } + req.ReleaseUDisk = sdk.Bool(releaseUDisk) + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *ulhostIDs { + id = ctx.PickResourceID(id) + req.ULHostId = sdk.String(id) + resp, err := client.TerminateULHostInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + status := "Deleted" + if resp.InRecycle == "Yes" { + status = "Recycled" + fmt.Fprintf(w, "ulhost[%s] has been moved to recycle bin\n", resp.ULHostId) + } else { + fmt.Fprintf(w, "ulhost[%s] deleted\n", resp.ULHostId) + } + results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "delete", Status: status}) + } + ctx.EmitResult(results...) + return nil + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ulhostIDs = flags.StringSlice("ulhost-id", nil, "Required. ResourceIDs(ULHostIds) of the ulhost instance") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + flags.BoolVar(&releaseUDisk, "delete-cloud-disk", true, "Optional. false, detach cloud disk only; true, detach cloud disk and delete it") + yes = flags.BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + command.SetFlagValues(cmd, "delete-cloud-disk", "true", "false") + command.SetCompletion(cmd, "ulhost-id", func() []string { + return getULHostList(ctx, []string{HOST_RUNNING, HOST_STOPPED, HOST_FAIL}, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("ulhost-id") + + return cmd +} diff --git a/products/ulhost/internal/ulhost/describe.go b/products/ulhost/internal/ulhost/describe.go new file mode 100644 index 0000000000..f785a3d091 --- /dev/null +++ b/products/ulhost/internal/ulhost/describe.go @@ -0,0 +1,64 @@ +package ulhost + +import ( + "fmt" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// sdescribeULHostByID is the ctx-based concurrent-spoller describe variant +// (mirrors uhost's sdescribeUHostByID pattern): it uses cli.NewServiceClient +// instead of base.BizClient. Nil-on-not-found and CommonBase-aware (a non-nil +// commonBase carries region/project/zone; nil falls back to the client's +// default-config region, which the SDK marshaler fills when the request region +// is empty). For use by ulhost product commands (create polling). Returns +// *ucompsharesdk.ULHostInstanceSet. +// +// The legacy base.BizClient variant (SdescribeULHostByID) lived here previously +// to support cmd/api.go's CreateULHostInstance repeats polling. Product +// packages must not import the legacy base package (hack/check-product rule2), +// so that variant now lives on the platform side in cmd/api_repeats_ulhost.go. +func sdescribeULHostByID(ctx *cli.Context) func(ulhostID string, commonBase *request.CommonBase) (interface{}, error) { + return func(ulhostID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewDescribeULHostInstanceRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.ULHostIds = []string{ulhostID} + resp, err := client.DescribeULHostInstance(req) + if err != nil { + return nil, err + } + if len(resp.ULHostInstanceSets) < 1 { + return nil, nil + } + return &resp.ULHostInstanceSets[0], nil + } +} + +// describeULHostByID mirrors uhost's describeUHostByID (the ERROR-on-not-found +// variant): it binds projectID/region into the request and returns an error +// (not nil) when the ulhost does not exist. Used by sequential pollers. +// Returns *ucompsharesdk.ULHostInstanceSet. +func describeULHostByID(ctx *cli.Context, projectID, region string) func(ulhostID string, commonBase *request.CommonBase) (interface{}, error) { + return func(ulhostID string, _ *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewDescribeULHostInstanceRequest() + req.ULHostIds = []string{ulhostID} + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + resp, err := client.DescribeULHostInstance(req) + if err != nil { + return nil, err + } + if len(resp.ULHostInstanceSets) < 1 { + return nil, fmt.Errorf("ulhost [%s] does not exist", ulhostID) + } + return &resp.ULHostInstanceSets[0], nil + } +} diff --git a/products/ulhost/internal/ulhost/list.go b/products/ulhost/internal/ulhost/list.go new file mode 100644 index 0000000000..145157d197 --- /dev/null +++ b/products/ulhost/internal/ulhost/list.go @@ -0,0 +1,205 @@ +package ulhost + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList ucloud ulhost list +func newList(ctx *cli.Context) *cobra.Command { + var allRegion, pageOff, idOnly bool + var ulhostIds []string + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewDescribeULHostInstanceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List all ULHost Instances", + Long: `List all ULHost Instances`, + Run: func(cmd *cobra.Command, args []string) { + for _, ulhost := range ulhostIds { + req.ULHostIds = append(req.ULHostIds, ctx.PickResourceID(ulhost)) + } + + ulhosts, err := getAllULHosts(ctx, client, req, pageOff, allRegion) + if err != nil { + ctx.HandleError(err) + return + } + if idOnly { + listULHostID(ctx, ulhosts) + } else { + listULHost(ctx, ulhosts, allRegion) + } + }, + } + cmd.Flags().SortFlags = false + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region.") + req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0") + req.Limit = cmd.Flags().Int("limit", 50, "Optional. Limit default 50, max value 100") + cmd.Flags().StringSliceVar(&ulhostIds, "ulhost-id", make([]string, 0), "Optional. Resource ID of ulhost instances, multiple values separated by comma(without space)") + cmd.Flags().BoolVar(&allRegion, "all-region", false, "Optional. Accept values: true or false. List ulhost instances of all regions when assigned true") + cmd.Flags().BoolVar(&pageOff, "page-off", false, "Optional. Paging or not. If all-region is specified this flag will be true. Accept values: true or false. If assigned, the limit flag will be disabled and list all ulhost instances") + cmd.Flags().BoolVar(&idOnly, "ulhost-id-only", false, "Optional. Just display resource id of ulhost") + // NOTE: Unlike uhost, the ucompshare DescribeULHostInstanceRequest does not have + // a Tag field, so ctx.BindGroup is not applicable here. Group filtering is not + // supported by the ulhost describe API. + + command.SetFlagValues(cmd, "page-off", "true", "false") + command.SetFlagValues(cmd, "ulhost-id-only", "true", "false") + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "ulhost-id", func() []string { + return getULHostList(ctx, nil, *req.ProjectId, *req.Region) + }) + + return cmd +} + +// listULHost renders the ulhost slice via ctx.PrintList, selecting columns per +// output mode using the per-mode row structs (rows.go). +func listULHost(ctx *cli.Context, ulhosts []ucompsharesdk.ULHostInstanceSet, listAllRegion bool) { + list := make([]ulhostRow, 0) + for _, host := range ulhosts { + row := ulhostRow{} + row.Name = host.Name + row.Remark = host.Remark + row.ResourceID = host.ULHostId + row.Group = host.Tag + for _, ip := range host.IPSet { + if ip.Type == "Private" { + row.PrivateIP = ip.IP + } else { + if row.PublicIP != "" { + row.PublicIP += " | " + } + row.PublicIP += ip.IP + } + } + memorySize := host.Memory / 1024 + var disks []string + for _, disk := range host.DiskSet { + disks = append(disks, fmt.Sprintf("%s:%s:%dG", disk.Type, disk.DiskType, disk.Size)) + } + row.Zone = host.Zone + row.DiskSet = strings.Join(disks, "|") + row.Config = fmt.Sprintf("cpu:%d memory:%dG", host.CPU, memorySize) + row.Image = fmt.Sprintf("%s|%s", host.ImageId, host.ImageName) + row.CreationTime = common.FormatDate(host.CreateTime) + row.State = host.State + row.ChargeType = host.ChargeType + row.AutoRenew = host.AutoRenew + row.ExpireTime = common.FormatDate(host.ExpireTime) + list = append(list, row) + } + + // JSON/YAML mode: print the full row set. + if ctx.Format() != cli.OutputTable { + ctx.PrintList(list) + return + } + + if listAllRegion { + rows := make([]ulhostRowAllRegion, 0, len(list)) + for _, r := range list { + rows = append(rows, ulhostRowAllRegion{ + Name: r.Name, ResourceID: r.ResourceID, Group: r.Group, + PublicIP: r.PublicIP, Config: r.Config, + Image: r.Image, State: r.State, ChargeType: r.ChargeType, + CreationTime: r.CreationTime, Zone: r.Zone, + }) + } + ctx.PrintList(rows) + return + } + rows := make([]ulhostRowDefault, 0, len(list)) + for _, r := range list { + rows = append(rows, ulhostRowDefault{ + Name: r.Name, ResourceID: r.ResourceID, Group: r.Group, + PublicIP: r.PublicIP, Config: r.Config, + Image: r.Image, State: r.State, ChargeType: r.ChargeType, + CreationTime: r.CreationTime, + }) + } + ctx.PrintList(rows) +} + +func listULHostID(ctx *cli.Context, ulhosts []ucompsharesdk.ULHostInstanceSet) { + ids := make([]string, 0) + for _, h := range ulhosts { + ids = append(ids, h.ULHostId) + } + fmt.Fprintln(ctx.Out(), strings.Join(ids, ",")) +} + +func fetchULHosts(client *ucompsharesdk.UCompShareClient, req *ucompsharesdk.DescribeULHostInstanceRequest) ([]ucompsharesdk.ULHostInstanceSet, error) { + resp, err := client.DescribeULHostInstance(req) + if err != nil { + return nil, err + } + return resp.ULHostInstanceSets, nil +} + +func fetchULHostsPageOff(client *ucompsharesdk.UCompShareClient, req *ucompsharesdk.DescribeULHostInstanceRequest) ([]ucompsharesdk.ULHostInstanceSet, error) { + _req := *req + result := make([]ucompsharesdk.ULHostInstanceSet, 0) + for limit, offset := 50, 0; ; offset += limit { + _req.Offset = sdk.Int(offset) + _req.Limit = sdk.Int(limit) + ulhosts, err := fetchULHosts(client, &_req) + if err != nil { + return nil, err + } + result = append(result, ulhosts...) + // The ucompshare SDK does not return TotalCount, so we stop when + // fewer results than the limit are returned. + if len(ulhosts) < limit { + break + } + } + return result, nil +} + +func getAllULHosts(ctx *cli.Context, client *ucompsharesdk.UCompShareClient, req *ucompsharesdk.DescribeULHostInstanceRequest, pageOff bool, allRegion bool) ([]ucompsharesdk.ULHostInstanceSet, error) { + if allRegion { + result := make([]ucompsharesdk.ULHostInstanceSet, 0) + regions, err := ctx.AllRegions() + if err != nil { + return nil, err + } + for _, region := range regions { + _req := *req + _req.Region = sdk.String(region) + ulhosts, err := fetchULHostsPageOff(client, &_req) + if err != nil { + continue + } + result = append(result, ulhosts...) + } + return result, nil + } + + if pageOff { + _req := *req + ulhosts, err := fetchULHostsPageOff(client, &_req) + if err != nil { + return nil, err + } + return ulhosts, nil + } + + ulhosts, err := fetchULHosts(client, req) + if err != nil { + return nil, err + } + return ulhosts, nil +} diff --git a/products/ulhost/internal/ulhost/modify_attribute.go b/products/ulhost/internal/ulhost/modify_attribute.go new file mode 100644 index 0000000000..f0fe58af36 --- /dev/null +++ b/products/ulhost/internal/ulhost/modify_attribute.go @@ -0,0 +1,47 @@ +package ulhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newModifyAttribute ucloud ulhost modify-attribute +func newModifyAttribute(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewModifyULHostAttributeRequest() + cmd := &cobra.Command{ + Use: "modify-attribute", + Short: "Modify the attribute of ULHost instance", + Long: "Modify the attribute (name or remark) of ULHost instance. At least one of Name or Remark must be specified.", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + req.ULHostId = sdk.String(ctx.PickResourceID(*req.ULHostId)) + resp, err := client.ModifyULHostAttribute(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(w, "ulhost[%s] attribute modified\n", resp.ULHostId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ULHostId, Action: "modify-attribute", Status: "OK"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ULHostId = flags.String("ulhost-id", "", "Required. Resource ID of the ulhost instance") + req.Name = flags.String("name", "", "Optional. New name of the ulhost instance") + req.Remark = flags.String("remark", "", "Optional. New remark of the ulhost instance") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + command.SetCompletion(cmd, "ulhost-id", func() []string { + return getULHostList(ctx, nil, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("ulhost-id") + return cmd +} diff --git a/products/ulhost/internal/ulhost/poweroff.go b/products/ulhost/internal/ulhost/poweroff.go new file mode 100644 index 0000000000..668977ec5e --- /dev/null +++ b/products/ulhost/internal/ulhost/poweroff.go @@ -0,0 +1,66 @@ +package ulhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newPoweroff ucloud ulhost poweroff +func newPoweroff(ctx *cli.Context) *cobra.Command { + var yes *bool + var ulhostIDs *[]string + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewPoweroffULHostInstanceRequest() + cmd := &cobra.Command{ + Use: "poweroff", + Short: "Analog power off ULHost instance", + Long: "Analog power off ULHost instance. Danger, it may affect data integrity.", + Example: "ucloud ulhost poweroff --ulhost-id ulhost-xxx1,ulhost-xxx2", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + confirmText := "Danger, it may affect data integrity. Are you sure you want to poweroff this ulhost?" + if len(*ulhostIDs) > 1 { + confirmText = "Danger, it may affect data integrity. Are you sure you want to poweroff those ulhosts?" + } + ok, err := ctx.Confirm(*yes, confirmText) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + results := []cli.OpResultRow{} + for _, id := range *ulhostIDs { + id = ctx.PickResourceID(id) + req.ULHostId = &id + resp, err := client.PoweroffULHostInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "ulhost[%v] is power off\n", resp.ULHostId) + results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "poweroff", Status: "Stopped"}) + } + ctx.EmitResult(results...) + }, + } + cmd.Flags().SortFlags = false + ulhostIDs = cmd.Flags().StringSlice("ulhost-id", nil, "ResourceIDs(ULHostIds) of the ulhost instance") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Assign region") + yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + + command.SetCompletion(cmd, "ulhost-id", func() []string { + return getULHostList(ctx, []string{HOST_FAIL, HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("ulhost-id") + + return cmd +} diff --git a/products/ulhost/internal/ulhost/price.go b/products/ulhost/internal/ulhost/price.go new file mode 100644 index 0000000000..46b542a5a1 --- /dev/null +++ b/products/ulhost/internal/ulhost/price.go @@ -0,0 +1,115 @@ +package ulhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newPrice ucloud ulhost price +func newPrice(ctx *cli.Context) *cobra.Command { + var renew bool + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + cmd := &cobra.Command{ + Use: "price", + Short: "Get ULHost instance price", + Long: `Get ULHost instance price for creating or renewing`, + Run: func(cmd *cobra.Command, args []string) { + if renew { + showRenewPrice(ctx, client, cmd) + } else { + showCreatePrice(ctx, client, cmd) + } + }, + } + flags := cmd.Flags() + flags.SortFlags = false + flags.String("bundle-id", "", "Required for create price. Bundle ID of the ULHost instance") + flags.String("charge-type", "", "Optional. 'Year' or 'Month'. If not specified, return all charge types") + flags.Int("count", 1, "Optional. Number of instances. Range [1,5]") + flags.Int("quantity", 1, "Optional. Purchase duration. Default: 1") + flags.String("ulhost-id", "", "Required for renew price. ULHost instance ID") + flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + flags.BoolVar(&renew, "renew", false, "Optional. Get renew price instead of create price") + command.SetFlagValues(cmd, "charge-type", "Month", "Year") + command.SetCompletion(cmd, "region", ctx.RegionList) + + return cmd +} + +func showCreatePrice(ctx *cli.Context, client *ucompsharesdk.UCompShareClient, cmd *cobra.Command) { + req := client.NewGetULHostInstancePriceRequest() + bundleID, _ := cmd.Flags().GetString("bundle-id") + chargeType, _ := cmd.Flags().GetString("charge-type") + count, _ := cmd.Flags().GetInt("count") + quantity, _ := cmd.Flags().GetInt("quantity") + projectID, _ := cmd.Flags().GetString("project-id") + region, _ := cmd.Flags().GetString("region") + + req.BundleId = sdk.String(bundleID) + req.ChargeType = sdk.String(chargeType) + req.Count = sdk.Int(count) + req.Quantity = sdk.Int(quantity) + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + + if bundleID == "" { + ctx.HandleError(fmt.Errorf("--bundle-id is required for create price")) + return + } + + resp, err := client.GetULHostInstancePrice(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]priceRow, 0, len(resp.PriceSet)) + for _, price := range resp.PriceSet { + rows = append(rows, priceRow{ + ChargeType: price.ChargeType, + Price: fmt.Sprintf("%.2f", price.Price), + OriginalPrice: fmt.Sprintf("%.2f", price.OriginalPrice), + }) + } + ctx.PrintList(rows) +} + +func showRenewPrice(ctx *cli.Context, client *ucompsharesdk.UCompShareClient, cmd *cobra.Command) { + req := client.NewGetULHostRenewPriceRequest() + ulhostID, _ := cmd.Flags().GetString("ulhost-id") + chargeType, _ := cmd.Flags().GetString("charge-type") + projectID, _ := cmd.Flags().GetString("project-id") + region, _ := cmd.Flags().GetString("region") + + req.ULHostId = sdk.String(ulhostID) + req.ChargeType = sdk.String(chargeType) + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + + if ulhostID == "" { + ctx.HandleError(fmt.Errorf("--ulhost-id is required for renew price")) + return + } + + resp, err := client.GetULHostRenewPrice(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]priceRow, 0, len(resp.PriceSet)) + for _, price := range resp.PriceSet { + rows = append(rows, priceRow{ + ChargeType: price.ChargeType, + Price: fmt.Sprintf("%.2f", price.Price), + OriginalPrice: fmt.Sprintf("%.2f", price.OriginalPrice), + }) + } + ctx.PrintList(rows) +} diff --git a/products/ulhost/internal/ulhost/reinstall_os.go b/products/ulhost/internal/ulhost/reinstall_os.go new file mode 100644 index 0000000000..7e384542bf --- /dev/null +++ b/products/ulhost/internal/ulhost/reinstall_os.go @@ -0,0 +1,60 @@ +package ulhost + +import ( + "encoding/base64" + "fmt" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newReinstallOS ucloud ulhost reinstall-os +func newReinstallOS(ctx *cli.Context) *cobra.Command { + var async *bool + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewReinstallULHostInstanceRequest() + cmd := &cobra.Command{ + Use: "reinstall-os", + Short: "Reinstall the operating system of the ULHost instance", + Long: "Reinstall the operating system of the ULHost instance.", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + req.ULHostId = sdk.String(ctx.PickResourceID(*req.ULHostId)) + req.ImageId = sdk.String(ctx.PickResourceID(*req.ImageId)) + // Encode password to base64 + req.Password = sdk.String(base64.StdEncoding.EncodeToString([]byte(*req.Password))) + resp, err := client.ReinstallULHostInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("ulhost[%s] is reinstalling OS", resp.ULHostId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeULHostByID(ctx, *req.ProjectId, *req.Region)).Spoll(resp.ULHostId, text, []string{HOST_RUNNING, HOST_FAIL}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ULHostId, Action: "reinstall-os", Status: "Reinstalling"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ULHostId = flags.String("ulhost-id", "", "Required. Resource ID of the ulhost to reinstall operating system") + req.ImageId = flags.String("image-id", "", "Required. Resource ID of the image to install. See 'ucloud ulhost image list'") + req.Password = flags.String("password", "", "Required. Password of the ulhost instance") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + async = flags.BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.") + command.SetCompletion(cmd, "ulhost-id", func() []string { + return getULHostList(ctx, []string{HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("ulhost-id") + cmd.MarkFlagRequired("image-id") + cmd.MarkFlagRequired("password") + return cmd +} diff --git a/products/ulhost/internal/ulhost/reset_password.go b/products/ulhost/internal/ulhost/reset_password.go new file mode 100644 index 0000000000..f5cbc5a5ba --- /dev/null +++ b/products/ulhost/internal/ulhost/reset_password.go @@ -0,0 +1,57 @@ +package ulhost + +import ( + "encoding/base64" + "fmt" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResetPassword ucloud ulhost reset-password +func newResetPassword(ctx *cli.Context) *cobra.Command { + var ulhostIDs *[]string + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewResetULHostInstancePasswordRequest() + cmd := &cobra.Command{ + Use: "reset-password", + Short: "Reset the administrator password for the ULHost instances.", + Long: "Reset the administrator password for the ULHost instances.", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + // Encode password to base64 + req.Password = sdk.String(base64.StdEncoding.EncodeToString([]byte(*req.Password))) + results := []cli.OpResultRow{} + for _, id := range *ulhostIDs { + id = ctx.PickResourceID(id) + req.ULHostId = &id + resp, err := client.ResetULHostInstancePassword(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "ulhost[%s] reset password\n", resp.ULHostId) + results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "reset-password", Status: "OK"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ulhostIDs = flags.StringSlice("ulhost-id", nil, "Required. Resource IDs of the ulhosts to reset the administrator's password") + req.Password = flags.String("password", "", "Required. New Password") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + command.SetCompletion(cmd, "ulhost-id", func() []string { + return getULHostList(ctx, []string{HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("ulhost-id") + cmd.MarkFlagRequired("password") + return cmd +} diff --git a/products/ulhost/internal/ulhost/restart.go b/products/ulhost/internal/ulhost/restart.go new file mode 100644 index 0000000000..79a868a9a7 --- /dev/null +++ b/products/ulhost/internal/ulhost/restart.go @@ -0,0 +1,57 @@ +package ulhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newReboot ucloud ulhost restart +func newReboot(ctx *cli.Context) *cobra.Command { + var ulhostIDs *[]string + var async *bool + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewRebootULHostInstanceRequest() + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart ULHost instance", + Long: "Restart ULHost instance", + Example: "ucloud ulhost restart --ulhost-id ulhost-xxx1,ulhost-xxx2", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *ulhostIDs { + id = ctx.PickResourceID(id) + req.ULHostId = &id + resp, err := client.RebootULHostInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("ulhost[%v] is restarting", resp.ULHostId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeULHostByID(ctx, *req.ProjectId, *req.Region)).Spoll(resp.ULHostId, text, []string{HOST_RUNNING, HOST_FAIL}) + } + results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "restart", Status: "Rebooting"}) + } + ctx.EmitResult(results...) + }, + } + cmd.Flags().SortFlags = false + ulhostIDs = cmd.Flags().StringSlice("ulhost-id", nil, "Required. ResourceIDs(ULHostIds) of the ulhost instance") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + command.SetCompletion(cmd, "ulhost-id", func() []string { + return getULHostList(ctx, []string{HOST_FAIL, HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("ulhost-id") + return cmd +} diff --git a/products/ulhost/internal/ulhost/rows.go b/products/ulhost/internal/ulhost/rows.go new file mode 100644 index 0000000000..7e6bfb377d --- /dev/null +++ b/products/ulhost/internal/ulhost/rows.go @@ -0,0 +1,71 @@ +package ulhost + +// rows.go holds the table-row structs for ulhost list output. The platform +// printer (ctx.PrintList) derives table columns from a struct's exported fields +// in declaration order. JSON output uses the full ulhostRow (matching the +// original convention of marshalling the full row slice in --json mode). + +// ulhostRow is the full row (wide mode + json). Field set+order matches the +// ucompshare SDK ULHostInstanceSet fields relevant for CLI display. +type ulhostRow struct { + Name string + ResourceID string + Remark string + Group string + PrivateIP string + PublicIP string + Config string + DiskSet string + Zone string + Image string + State string + ChargeType string + AutoRenew string + ExpireTime string + CreationTime string +} + +// ulhostRowDefault is the default (non-all-region) column set: +// Name, ResourceID, Group, PublicIP, Config, Image, State, ChargeType, CreationTime +type ulhostRowDefault struct { + Name string + ResourceID string + Group string + PublicIP string + Config string + Image string + State string + ChargeType string + CreationTime string +} + +// ulhostRowAllRegion is the default column set plus a trailing Zone column. +type ulhostRowAllRegion struct { + Name string + ResourceID string + Group string + PublicIP string + Config string + Image string + State string + ChargeType string + CreationTime string + Zone string +} + +// bundleRow mirrors the Bundle struct for table display. +type bundleRow struct { + BundleID string + CPU string + Memory string + SysDiskSpace string + Bandwidth string + TrafficPacket string +} + +// priceRow mirrors the ULHostPriceSet for table display. +type priceRow struct { + ChargeType string + Price string + OriginalPrice string +} diff --git a/products/ulhost/internal/ulhost/start.go b/products/ulhost/internal/ulhost/start.go new file mode 100644 index 0000000000..f1ed634245 --- /dev/null +++ b/products/ulhost/internal/ulhost/start.go @@ -0,0 +1,57 @@ +package ulhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStart ucloud ulhost start +func newStart(ctx *cli.Context) *cobra.Command { + var async *bool + var ulhostIDs *[]string + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewStartULHostInstanceRequest() + cmd := &cobra.Command{ + Use: "start", + Short: "Start ULHost instance", + Long: "Start ULHost instance", + Example: "ucloud ulhost start --ulhost-id ulhost-xxx1,ulhost-xxx2", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *ulhostIDs { + id := ctx.PickResourceID(id) + req.ULHostId = &id + resp, err := client.StartULHostInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("ulhost[%v] is starting", resp.ULHostId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeULHostByID(ctx, *req.ProjectId, *req.Region)).Spoll(resp.ULHostId, text, []string{HOST_RUNNING, HOST_FAIL}) + } + results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "start", Status: "Starting"}) + } + ctx.EmitResult(results...) + }, + } + cmd.Flags().SortFlags = false + ulhostIDs = cmd.Flags().StringSlice("ulhost-id", nil, "Required. ResourceIDs(ULHostIds) of the ulhost instance") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + command.SetCompletion(cmd, "ulhost-id", func() []string { + return getULHostList(ctx, []string{HOST_STOPPED}, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("ulhost-id") + return cmd +} diff --git a/products/ulhost/internal/ulhost/status.go b/products/ulhost/internal/ulhost/status.go new file mode 100644 index 0000000000..d6098686ea --- /dev/null +++ b/products/ulhost/internal/ulhost/status.go @@ -0,0 +1,15 @@ +package ulhost + +// ULHost-domain state/type constants. Product-owned copies (mirrors uhost +// status.go pattern). ULHost states follow the ucompshare SDK +// ULHostInstanceSet.State enumeration. Only the states the CLI polls against +// (terminal Running/Stopped/Install Fail) are kept; the SDK also reports +// Initializing/Starting/Stopping/Rebooting, but no ulhost command waits on +// those, so they are omitted to avoid unused-constant drift. +const ( + HOST_RUNNING = "Running" + HOST_STOPPED = "Stopped" + HOST_FAIL = "Install Fail" + + REGEXP_NAME = "^[A-Za-z0-9-_.一-龥]{1,63}$" +) diff --git a/products/ulhost/internal/ulhost/stop.go b/products/ulhost/internal/ulhost/stop.go new file mode 100644 index 0000000000..33fd98b5d6 --- /dev/null +++ b/products/ulhost/internal/ulhost/stop.go @@ -0,0 +1,58 @@ +package ulhost + +import ( + "fmt" + + "github.com/spf13/cobra" + + ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStop ucloud ulhost stop +func newStop(ctx *cli.Context) *cobra.Command { + var ulhostIDs *[]string + var async *bool + client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient) + req := client.NewStopULHostInstanceRequest() + cmd := &cobra.Command{ + Use: "stop", + Short: "Shut down ULHost instance", + Long: "Shut down ULHost instance", + Example: "ucloud ulhost stop --ulhost-id ulhost-xxx1,ulhost-xxx2", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *ulhostIDs { + id = ctx.PickResourceID(id) + req.ULHostId = &id + resp, err := client.StopULHostInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("ulhost[%v] is shutting down", resp.ULHostId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeULHostByID(ctx, *req.ProjectId, *req.Region)).Spoll(resp.ULHostId, text, []string{HOST_STOPPED, HOST_FAIL}) + } + results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "stop", Status: "Stopping"}) + } + ctx.EmitResult(results...) + }, + } + cmd.Flags().SortFlags = false + ulhostIDs = cmd.Flags().StringSlice("ulhost-id", nil, "Required. ResourceIDs(ULHostIds) of the ulhost instances") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region") + async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + command.SetCompletion(cmd, "ulhost-id", func() []string { + return getULHostList(ctx, []string{HOST_RUNNING}, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("ulhost-id") + + return cmd +} diff --git a/products/ulhost/product.go b/products/ulhost/product.go new file mode 100644 index 0000000000..c5bc46090c --- /dev/null +++ b/products/ulhost/product.go @@ -0,0 +1,23 @@ +package ulhost + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalulhost "github.com/ucloud/ucloud-cli/products/ulhost/internal/ulhost" +) + +type product struct{} + +// New returns the ulhost product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "ulhost", Commands: []string{"ulhost"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{ + internalulhost.NewCommand(ctx), + } +} diff --git a/products/ulhost/product.yaml b/products/ulhost/product.yaml new file mode 100644 index 0000000000..e189c3cf12 --- /dev/null +++ b/products/ulhost/product.yaml @@ -0,0 +1,7 @@ +# products/ulhost/product.yaml — ulhost 产品元数据(归属真源,owner 自治维护) +name: ulhost +owners: + - calmxkk +commands: + - ulhost +enabled: true diff --git a/products/ulhost/testdata/cmdtree.golden b/products/ulhost/testdata/cmdtree.golden new file mode 100644 index 0000000000..ab40349e28 --- /dev/null +++ b/products/ulhost/testdata/cmdtree.golden @@ -0,0 +1,90 @@ +ucloud ulhost use=ulhost short=List,create,delete,stop,restart,poweroff or resize ULHost instance +ucloud ulhost bundles use=bundles short=List all ULHost bundles + flag=project-id short= default= required= + flag=region short= default= required= +ucloud ulhost create use=create short=Create ULHost instance + flag=async short= default=false required= + flag=bundle-id short= default= required=true + flag=charge-type short= default=Month required= + flag=coupon-id short= default= required= + flag=image-id short= default= required=true + flag=name short= default= required= + flag=password short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=security-group-id short= default= required= + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= +ucloud ulhost delete use=delete short=Delete ULHost instance + flag=delete-cloud-disk short= default=true required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulhost-id short= default=[] required=true + flag=yes short=y default=false required= +ucloud ulhost image use=image short=List ULHost images +ucloud ulhost image list use=list short=List ULHost images + flag=image-id short= default= required= + flag=image-type short= default=Base required= + flag=limit short= default=500 required= + flag=offset short= default=0 required= + flag=os-type short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud ulhost list use=list short=List all ULHost Instances + flag=all-region short= default=false required= + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=page-off short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulhost-id short= default=[] required= + flag=ulhost-id-only short= default=false required= +ucloud ulhost modify-attribute use=modify-attribute short=Modify the attribute of ULHost instance + flag=name short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=ulhost-id short= default= required=true +ucloud ulhost poweroff use=poweroff short=Analog power off ULHost instance + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulhost-id short= default=[] required=true + flag=yes short=y default=false required= +ucloud ulhost price use=price short=Get ULHost instance price + flag=bundle-id short= default= required= + flag=charge-type short= default= required= + flag=count short= default=1 required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=renew short= default=false required= + flag=ulhost-id short= default= required= +ucloud ulhost reinstall-os use=reinstall-os short=Reinstall the operating system of the ULHost instance + flag=async short=a default=false required= + flag=image-id short= default= required=true + flag=password short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulhost-id short= default= required=true +ucloud ulhost reset-password use=reset-password short=Reset the administrator password for the ULHost instances. + flag=password short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulhost-id short= default=[] required=true +ucloud ulhost restart use=restart short=Restart ULHost instance + flag=async short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulhost-id short= default=[] required=true +ucloud ulhost start use=start short=Start ULHost instance + flag=async short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulhost-id short= default=[] required=true +ucloud ulhost stop use=stop short=Shut down ULHost instance + flag=async short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=ulhost-id short= default=[] required=true diff --git a/products/ulhost/testdata/completion.golden b/products/ulhost/testdata/completion.golden new file mode 100644 index 0000000000..df4145568a --- /dev/null +++ b/products/ulhost/testdata/completion.golden @@ -0,0 +1,22 @@ +ucloud ulhost bundles project-id dynamic +ucloud ulhost bundles region dynamic +ucloud ulhost create bundle-id dynamic +ucloud ulhost create charge-type static Month,Year +ucloud ulhost create region dynamic +ucloud ulhost delete delete-cloud-disk static false,true +ucloud ulhost delete ulhost-id dynamic +ucloud ulhost image list image-type static Base,Business,Custom +ucloud ulhost list page-off static false,true +ucloud ulhost list project-id dynamic +ucloud ulhost list region dynamic +ucloud ulhost list ulhost-id dynamic +ucloud ulhost list ulhost-id-only static false,true +ucloud ulhost modify-attribute ulhost-id dynamic +ucloud ulhost poweroff ulhost-id dynamic +ucloud ulhost price charge-type static Month,Year +ucloud ulhost price region dynamic +ucloud ulhost reinstall-os ulhost-id dynamic +ucloud ulhost reset-password ulhost-id dynamic +ucloud ulhost restart ulhost-id dynamic +ucloud ulhost start ulhost-id dynamic +ucloud ulhost stop ulhost-id dynamic diff --git a/products/umodelverse/internal/umodelverse/api.go b/products/umodelverse/internal/umodelverse/api.go new file mode 100644 index 0000000000..cb249c45fc --- /dev/null +++ b/products/umodelverse/internal/umodelverse/api.go @@ -0,0 +1,152 @@ +package umodelverse + +import ( + "encoding/json" + "fmt" + "sort" + + uai "github.com/ucloud/ucloud-sdk-go/services/uai_modelverse" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + "github.com/ucloud/ucloud-sdk-go/ucloud/response" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +const productName = "umodelverse" + +type apiResponse struct { + response.BaseGenericResponse +} + +func newClient(ctx *cli.Context) *uai.UAI_ModelverseClient { + return cli.NewServiceClient(ctx, uai.NewClient) +} + +func newRequest(client *uai.UAI_ModelverseClient, req request.Common, retryable bool) { + client.Client.SetupRequest(req) + req.SetRetryable(retryable) +} + +func invokeUMAction(client *uai.UAI_ModelverseClient, action string, req request.Common) (*apiResponse, error) { + var resp apiResponse + err := client.Client.InvokeAction(action, req, &resp) + return &resp, err +} + +func printResponse(ctx *cli.Context, resp *apiResponse) { + payload := resp.GetPayload() + if ctx.Format() != cli.OutputTable { + ctx.PrintList(payload) + return + } + + keys := make([]string, 0, len(payload)) + for key := range payload { + keys = append(keys, key) + } + sort.Strings(keys) + + rows := make([]fieldRow, 0, len(keys)) + for _, key := range keys { + rows = append(rows, fieldRow{Field: key, Value: renderValue(payload[key])}) + } + ctx.PrintList(rows) +} + +func renderValue(v interface{}) string { + switch val := v.(type) { + case nil: + return "" + case string: + return val + case float64, bool: + return fmt.Sprintf("%v", val) + default: + b, err := json.Marshal(val) + if err != nil { + return fmt.Sprintf("%v", val) + } + return string(b) + } +} + +type apiKeyRequest struct { + request.CommonBase + + KeyId *string `required:"false"` + Name *string `required:"false"` + Status *int `required:"false"` + ModelverseDisabled *int `required:"false"` + SandBoxDisabled *int `required:"false"` + DailyLimitAmount *string `required:"false"` + MonthlyLimitAmount *string `required:"false"` + ExpireTime *int64 `required:"false"` + GrantAllModels *bool `required:"false"` + GrantedModels *string `required:"false"` + IPWhitelist *string `required:"false"` + DailyQuotaAlertThreshold *int `required:"false"` + MonthlyQuotaAlertThreshold *int `required:"false"` + QuotaAlertChannels *string `required:"false"` + QuotaAlertEmail *string `required:"false"` + QuotaAlertPhone *string `required:"false"` + QuotaAlertEmailVerificationToken *string `required:"false"` + QuotaAlertPhoneVerificationToken *string `required:"false"` + Offset *int `required:"false"` + Limit *int `required:"false"` +} + +type squareModelRequest struct { + request.CommonBase + + ModelType *string `required:"false"` + KeyWord *string `required:"false"` + Offset *int `required:"false"` + Limit *int `required:"false"` + OrderBy *string `required:"false"` + Order *string `required:"false"` + MaxModelLen *string `required:"false"` + Language *string `required:"false"` +} + +type requestLogRequest struct { + request.CommonBase + + StartTime *int64 `required:"false"` + EndTime *int64 `required:"false"` + Email *string `required:"false"` + RequestId *string `required:"false"` + ModelNames *string `required:"false"` + ApiKeyIds *string `required:"false"` + Offset *int `required:"false"` + Limit *int `required:"false"` +} + +type logDetailRequest struct { + request.CommonBase + + RequestId *string `required:"true"` +} + +type orderRequest struct { + request.CommonBase + + StartTime *int64 `required:"false"` + EndTime *int64 `required:"false"` + Page *int `required:"false"` + PageSize *int `required:"false"` + ResourceIds []string `required:"false"` + ModelIds []string `required:"false"` + PricingUnits []int `required:"false"` + PricingSkus []string `required:"false"` + OrderTypes []int `required:"false"` + ChargeTypes []int `required:"false"` + OrganizationIds []int `required:"false"` + Regions []string `required:"false"` + ProductCodes []string `required:"false"` +} + +type filterOptionsRequest struct { + request.CommonBase + + ProductCode *string `required:"false"` +} diff --git a/products/umodelverse/internal/umodelverse/apikey.go b/products/umodelverse/internal/umodelverse/apikey.go new file mode 100644 index 0000000000..e4fcc0b4d9 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/apikey.go @@ -0,0 +1,21 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newAPIKey(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "apikey", + Short: "Manage uModelVerse API keys", + Long: "Manage uModelVerse API keys.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newAPIKeyCreate(ctx)) + cmd.AddCommand(newAPIKeyDelete(ctx)) + cmd.AddCommand(newAPIKeyUpdate(ctx)) + cmd.AddCommand(newAPIKeyList(ctx)) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/apikey_create.go b/products/umodelverse/internal/umodelverse/apikey_create.go new file mode 100644 index 0000000000..4ca83beb5b --- /dev/null +++ b/products/umodelverse/internal/umodelverse/apikey_create.go @@ -0,0 +1,75 @@ +package umodelverse + +import ( + "fmt" + + "github.com/spf13/cobra" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newAPIKeyCreate(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &apiKeyRequest{} + newRequest(client, req, false) + var grantedModels []string + var quotaAlertChannels []string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a uModelVerse API key", + Long: "Create a uModelVerse API key.", + Run: func(c *cobra.Command, args []string) { + flags := c.Flags() + clearIntIfUnchanged(flags, "modelverse-disabled", &req.ModelverseDisabled) + clearIntIfUnchanged(flags, "sandbox-disabled", &req.SandBoxDisabled) + clearStringIfUnchanged(flags, "daily-limit-amount", &req.DailyLimitAmount) + clearStringIfUnchanged(flags, "monthly-limit-amount", &req.MonthlyLimitAmount) + clearInt64IfUnchanged(flags, "expire-time", &req.ExpireTime) + clearBoolIfUnchanged(flags, "grant-all-models", &req.GrantAllModels) + req.GrantedModels = stringSliceJSONRef(grantedModels) + clearStringIfUnchanged(flags, "ip-whitelist", &req.IPWhitelist) + clearIntIfUnchanged(flags, "daily-quota-alert-threshold", &req.DailyQuotaAlertThreshold) + clearIntIfUnchanged(flags, "monthly-quota-alert-threshold", &req.MonthlyQuotaAlertThreshold) + req.QuotaAlertChannels = stringSliceJSONRef(quotaAlertChannels) + clearStringIfUnchanged(flags, "quota-alert-email", &req.QuotaAlertEmail) + clearStringIfUnchanged(flags, "quota-alert-phone", &req.QuotaAlertPhone) + clearStringIfUnchanged(flags, "quota-alert-email-verification-token", &req.QuotaAlertEmailVerificationToken) + clearStringIfUnchanged(flags, "quota-alert-phone-verification-token", &req.QuotaAlertPhoneVerificationToken) + if req.IPWhitelist != nil { + req.IPWhitelist = sdk.String(cleanMultilineFlag(*req.IPWhitelist)) + } + resp, err := invokeUMAction(client, "CreateUMInferAPIKey", req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintln(ctx.ProgressWriter(), "umodelverse apikey created") + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.Name = flags.String("name", "", "Required. API key name.") + req.ModelverseDisabled = flags.Int("modelverse-disabled", 0, "Optional. Whether ModelVerse is disabled: 0 enabled, 1 disabled.") + req.SandBoxDisabled = flags.Int("sandbox-disabled", 0, "Optional. Whether sandbox is disabled: 0 enabled, 1 disabled.") + req.DailyLimitAmount = flags.String("daily-limit-amount", "", "Optional. Daily limit amount.") + req.MonthlyLimitAmount = flags.String("monthly-limit-amount", "", "Optional. Monthly limit amount.") + req.ExpireTime = flags.Int64("expire-time", 0, "Optional. API key expire time, Unix timestamp. Use -1 for never expire.") + req.GrantAllModels = flags.Bool("grant-all-models", true, "Optional. Grant access to all models.") + flags.StringSliceVar(&grantedModels, "granted-models", nil, "Optional. Granted model IDs when --grant-all-models=false. Can be repeated, comma-separated, or a JSON array string.") + req.IPWhitelist = flags.String("ip-whitelist", "", "Optional. IP whitelist, newline-separated; literal \\n is also accepted.") + req.DailyQuotaAlertThreshold = flags.Int("daily-quota-alert-threshold", 0, "Optional. Daily quota alert threshold.") + req.MonthlyQuotaAlertThreshold = flags.Int("monthly-quota-alert-threshold", 0, "Optional. Monthly quota alert threshold.") + flags.StringSliceVar("aAlertChannels, "quota-alert-channel", nil, "Optional. Quota alert channel, e.g. email or sms. Can be repeated or comma-separated.") + req.QuotaAlertEmail = flags.String("quota-alert-email", "", "Optional. Email address for quota alerts.") + req.QuotaAlertPhone = flags.String("quota-alert-phone", "", "Optional. Phone number for quota alerts.") + req.QuotaAlertEmailVerificationToken = flags.String("quota-alert-email-verification-token", "", "Optional. Email verification token for quota alerts.") + req.QuotaAlertPhoneVerificationToken = flags.String("quota-alert-phone-verification-token", "", "Optional. Phone verification token for quota alerts.") + bindProject(cmd, req, ctx.DefaultProjectID()) + + cmd.MarkFlagRequired("name") + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/apikey_delete.go b/products/umodelverse/internal/umodelverse/apikey_delete.go new file mode 100644 index 0000000000..51ecbd8c7c --- /dev/null +++ b/products/umodelverse/internal/umodelverse/apikey_delete.go @@ -0,0 +1,51 @@ +package umodelverse + +import ( + "fmt" + + "github.com/spf13/cobra" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newAPIKeyDelete(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &apiKeyRequest{} + newRequest(client, req, true) + + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a uModelVerse API key", + Long: "Delete a uModelVerse API key by key ID.", + Run: func(c *cobra.Command, args []string) { + id := ctx.PickResourceID(*req.KeyId) + ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure you want to delete API key %s?", id)) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.KeyId = sdk.String(id) + if _, err := invokeUMAction(client, "DeleteUMInferAPIKey", req); err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "umodelverse apikey[%s] deleted\n", id) + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.KeyId = flags.String("key-id", "", "Required. API key ID to delete.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + bindProject(cmd, req, ctx.DefaultProjectID()) + + cmd.MarkFlagRequired("key-id") + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/apikey_list.go b/products/umodelverse/internal/umodelverse/apikey_list.go new file mode 100644 index 0000000000..502ae02118 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/apikey_list.go @@ -0,0 +1,41 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newAPIKeyList(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &apiKeyRequest{} + newRequest(client, req, true) + + cmd := &cobra.Command{ + Use: "list", + Short: "List uModelVerse API keys", + Long: "List uModelVerse API keys.", + Run: func(c *cobra.Command, args []string) { + flags := c.Flags() + clearStringIfUnchanged(flags, "key-id", &req.KeyId) + clearIntIfUnchanged(flags, "modelverse-disabled", &req.ModelverseDisabled) + clearIntIfUnchanged(flags, "sandbox-disabled", &req.SandBoxDisabled) + resp, err := invokeUMAction(client, "ListUMInferAPIKey", req) + if err != nil { + ctx.HandleError(err) + return + } + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.KeyId = flags.String("key-id", "", "Optional. API key ID filter.") + req.Offset = flags.Int("offset", 0, "Optional. The index of API key which start to list.") + req.Limit = flags.Int("limit", 20, "Optional. The maximum number of API keys per page.") + req.ModelverseDisabled = flags.Int("modelverse-disabled", 0, "Optional. Whether ModelVerse is disabled: 0 enabled, 1 disabled.") + req.SandBoxDisabled = flags.Int("sandbox-disabled", 0, "Optional. Whether sandbox is disabled: 0 enabled, 1 disabled.") + bindProject(cmd, req, ctx.DefaultProjectID()) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/apikey_update.go b/products/umodelverse/internal/umodelverse/apikey_update.go new file mode 100644 index 0000000000..e91a7fe45d --- /dev/null +++ b/products/umodelverse/internal/umodelverse/apikey_update.go @@ -0,0 +1,80 @@ +package umodelverse + +import ( + "fmt" + + "github.com/spf13/cobra" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newAPIKeyUpdate(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &apiKeyRequest{} + newRequest(client, req, false) + var grantedModels []string + var quotaAlertChannels []string + + cmd := &cobra.Command{ + Use: "update", + Short: "Update a uModelVerse API key", + Long: "Update a uModelVerse API key.", + Run: func(c *cobra.Command, args []string) { + flags := c.Flags() + req.KeyId = sdk.String(ctx.PickResourceID(*req.KeyId)) + clearStringIfUnchanged(flags, "name", &req.Name) + clearIntIfUnchanged(flags, "status", &req.Status) + clearIntIfUnchanged(flags, "modelverse-disabled", &req.ModelverseDisabled) + clearIntIfUnchanged(flags, "sandbox-disabled", &req.SandBoxDisabled) + clearStringIfUnchanged(flags, "daily-limit-amount", &req.DailyLimitAmount) + clearStringIfUnchanged(flags, "monthly-limit-amount", &req.MonthlyLimitAmount) + clearInt64IfUnchanged(flags, "expire-time", &req.ExpireTime) + clearBoolIfUnchanged(flags, "grant-all-models", &req.GrantAllModels) + req.GrantedModels = stringSliceJSONRef(grantedModels) + clearStringIfUnchanged(flags, "ip-whitelist", &req.IPWhitelist) + clearIntIfUnchanged(flags, "daily-quota-alert-threshold", &req.DailyQuotaAlertThreshold) + clearIntIfUnchanged(flags, "monthly-quota-alert-threshold", &req.MonthlyQuotaAlertThreshold) + req.QuotaAlertChannels = stringSliceJSONRef(quotaAlertChannels) + clearStringIfUnchanged(flags, "quota-alert-email", &req.QuotaAlertEmail) + clearStringIfUnchanged(flags, "quota-alert-phone", &req.QuotaAlertPhone) + clearStringIfUnchanged(flags, "quota-alert-email-verification-token", &req.QuotaAlertEmailVerificationToken) + clearStringIfUnchanged(flags, "quota-alert-phone-verification-token", &req.QuotaAlertPhoneVerificationToken) + if req.IPWhitelist != nil { + req.IPWhitelist = sdk.String(cleanMultilineFlag(*req.IPWhitelist)) + } + resp, err := invokeUMAction(client, "UpdateUMInferAPIKey", req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "umodelverse apikey[%s] updated\n", *req.KeyId) + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.KeyId = flags.String("key-id", "", "Required. API key ID to update.") + req.Name = flags.String("name", "", "Optional. Updated API key name.") + req.Status = flags.Int("status", 0, "Optional. API key status: 1 enabled, 2 disabled.") + req.ModelverseDisabled = flags.Int("modelverse-disabled", 0, "Optional. Whether ModelVerse is disabled: 0 enabled, 1 disabled.") + req.SandBoxDisabled = flags.Int("sandbox-disabled", 0, "Optional. Whether sandbox is disabled: 0 enabled, 1 disabled.") + req.DailyLimitAmount = flags.String("daily-limit-amount", "", "Optional. Daily limit amount.") + req.MonthlyLimitAmount = flags.String("monthly-limit-amount", "", "Optional. Monthly limit amount.") + req.ExpireTime = flags.Int64("expire-time", 0, "Optional. API key expire time, Unix timestamp. Use -1 for never expire.") + req.GrantAllModels = flags.Bool("grant-all-models", true, "Optional. Grant access to all models.") + flags.StringSliceVar(&grantedModels, "granted-models", nil, "Optional. Granted model IDs when --grant-all-models=false. Can be repeated, comma-separated, or a JSON array string.") + req.IPWhitelist = flags.String("ip-whitelist", "", "Optional. IP whitelist, newline-separated; literal \\n is also accepted.") + req.DailyQuotaAlertThreshold = flags.Int("daily-quota-alert-threshold", 0, "Optional. Daily quota alert threshold.") + req.MonthlyQuotaAlertThreshold = flags.Int("monthly-quota-alert-threshold", 0, "Optional. Monthly quota alert threshold.") + flags.StringSliceVar("aAlertChannels, "quota-alert-channel", nil, "Optional. Quota alert channel, e.g. email or sms. Can be repeated or comma-separated.") + req.QuotaAlertEmail = flags.String("quota-alert-email", "", "Optional. Email address for quota alerts.") + req.QuotaAlertPhone = flags.String("quota-alert-phone", "", "Optional. Phone number for quota alerts.") + req.QuotaAlertEmailVerificationToken = flags.String("quota-alert-email-verification-token", "", "Optional. Email verification token for quota alerts.") + req.QuotaAlertPhoneVerificationToken = flags.String("quota-alert-phone-verification-token", "", "Optional. Phone verification token for quota alerts.") + bindProject(cmd, req, ctx.DefaultProjectID()) + + cmd.MarkFlagRequired("key-id") + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/cmd.go b/products/umodelverse/internal/umodelverse/cmd.go new file mode 100644 index 0000000000..64c3ff9821 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/cmd.go @@ -0,0 +1,23 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `umodelverse` root command. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: productName, + Short: "Manipulate uModelVerse resources", + Long: "Manipulate uModelVerse resources, API keys, model catalog, inference logs, and billing data.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newAPIKey(ctx)) + cmd.AddCommand(newModel(ctx)) + cmd.AddCommand(newLog(ctx)) + cmd.AddCommand(newOrder(ctx)) + cmd.AddCommand(newFilterOptions(ctx)) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/filter_options.go b/products/umodelverse/internal/umodelverse/filter_options.go new file mode 100644 index 0000000000..1c6eff7a7f --- /dev/null +++ b/products/umodelverse/internal/umodelverse/filter_options.go @@ -0,0 +1,33 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newFilterOptions(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &filterOptionsRequest{} + newRequest(client, req, true) + + cmd := &cobra.Command{ + Use: "filter-options", + Short: "Get uModelVerse order filter options", + Long: "Get uModelVerse order filter options.", + Run: func(c *cobra.Command, args []string) { + clearStringIfUnchanged(c.Flags(), "product-code", &req.ProductCode) + resp, err := invokeUMAction(client, "GetFilterOptions", req) + if err != nil { + ctx.HandleError(err) + return + } + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + req.ProductCode = flags.String("product-code", "", "Optional. Product code, e.g. modelverse or sandbox.") + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/flags.go b/products/umodelverse/internal/umodelverse/flags.go new file mode 100644 index 0000000000..a948b5e1d3 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/flags.go @@ -0,0 +1,119 @@ +package umodelverse + +import ( + "encoding/json" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func bindProject(cmd *cobra.Command, req interface{ SetProjectIdRef(*string) error }, defaultProject string) { + projectID := defaultProject + cmd.Flags().StringVar(&projectID, "project-id", defaultProject, "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + _ = req.SetProjectIdRef(&projectID) +} + +func bindTimeRange(cmd *cobra.Command, req *orderRequest) { + req.StartTime = cmd.Flags().Int64("start-time", 0, "Required. Query start time, Unix timestamp in seconds.") + req.EndTime = cmd.Flags().Int64("end-time", 0, "Required. Query end time, Unix timestamp in seconds.") + cmd.MarkFlagRequired("start-time") + cmd.MarkFlagRequired("end-time") +} + +func bindOrderFilters(cmd *cobra.Command, req *orderRequest) { + flags := cmd.Flags() + flags.StringSliceVar(&req.ResourceIds, "resource-id", nil, "Optional. Resource ID filter. Can be repeated or comma-separated.") + flags.StringSliceVar(&req.ModelIds, "model-id", nil, "Optional. Model ID filter. Can be repeated or comma-separated.") + flags.IntSliceVar(&req.PricingUnits, "pricing-unit", nil, "Optional. Pricing unit filter. Can be repeated or comma-separated.") + flags.StringSliceVar(&req.PricingSkus, "pricing-sku", nil, "Optional. Pricing SKU filter. Can be repeated or comma-separated.") + flags.IntSliceVar(&req.OrderTypes, "order-type", nil, "Optional. Order type filter. Can be repeated or comma-separated.") + flags.IntSliceVar(&req.OrganizationIds, "organization-id", nil, "Optional. Organization ID filter. Can be repeated or comma-separated.") + flags.StringSliceVar(&req.Regions, "order-region", nil, "Optional. Order region filter. Can be repeated or comma-separated.") + flags.StringSliceVar(&req.ProductCodes, "product-code", nil, "Optional. Product code filter, e.g. modelverse or sandbox.") +} + +func bindPage(cmd *cobra.Command, req *orderRequest) { + req.Page = cmd.Flags().Int("page", 1, "Required. Page number, starting from 1.") + req.PageSize = cmd.Flags().Int("page-size", 20, "Required. Page size.") + cmd.MarkFlagRequired("page") + cmd.MarkFlagRequired("page-size") +} + +func cleanMultilineFlag(s string) string { + return strings.ReplaceAll(s, "\\n", "\n") +} + +func stringSliceJSONRef(values []string) *string { + values = normalizeStringSliceValues(values) + if len(values) == 0 { + return nil + } + b, _ := json.Marshal(values) + s := string(b) + return &s +} + +func intSliceJSONRef(values []int) *string { + if len(values) == 0 { + return nil + } + b, _ := json.Marshal(values) + s := string(b) + return &s +} + +func bindOrderChargeTypes(cmd *cobra.Command, req *orderRequest) { + cmd.Flags().IntSliceVar(&req.ChargeTypes, "charge-type-code", nil, "Optional. Charge type code filter. Can be repeated or comma-separated.") +} + +func normalizeStringSliceValues(values []string) []string { + if len(values) != 1 { + return values + } + raw := strings.TrimSpace(values[0]) + if len(raw) < 2 || raw[0] != '[' || raw[len(raw)-1] != ']' { + return values + } + var parsed []string + if err := json.Unmarshal([]byte(raw), &parsed); err == nil { + return parsed + } + raw = strings.TrimSpace(raw[1 : len(raw)-1]) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + parsed = make([]string, 0, len(parts)) + for _, part := range parts { + item := strings.Trim(strings.TrimSpace(part), `"'`) + if item != "" { + parsed = append(parsed, item) + } + } + return parsed +} + +func clearStringIfUnchanged(flags *pflag.FlagSet, name string, target **string) { + if !flags.Changed(name) { + *target = nil + } +} + +func clearIntIfUnchanged(flags *pflag.FlagSet, name string, target **int) { + if !flags.Changed(name) { + *target = nil + } +} + +func clearInt64IfUnchanged(flags *pflag.FlagSet, name string, target **int64) { + if !flags.Changed(name) { + *target = nil + } +} + +func clearBoolIfUnchanged(flags *pflag.FlagSet, name string, target **bool) { + if !flags.Changed(name) { + *target = nil + } +} diff --git a/products/umodelverse/internal/umodelverse/log.go b/products/umodelverse/internal/umodelverse/log.go new file mode 100644 index 0000000000..8969741f47 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/log.go @@ -0,0 +1,20 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newLog(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "log", + Short: "Query and export uModelVerse inference logs", + Long: "Query and export uModelVerse inference logs.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newLogList(ctx)) + cmd.AddCommand(newLogDescribe(ctx)) + cmd.AddCommand(newLogExport(ctx)) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/log_describe.go b/products/umodelverse/internal/umodelverse/log_describe.go new file mode 100644 index 0000000000..fcf44e4e85 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/log_describe.go @@ -0,0 +1,38 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newLogDescribe(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &logDetailRequest{} + newRequest(client, req, true) + + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe a uModelVerse inference request log", + Long: "Describe a uModelVerse inference request log by request ID.", + Run: func(c *cobra.Command, args []string) { + resp, err := invokeUMAction(client, "GetUMInferRequestLogDetail", req) + if err != nil { + ctx.HandleError(err) + return + } + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + bindProject(cmd, req, ctx.DefaultProjectID()) + req.RequestId = flags.String("request-id", "", "Required. Request ID.") + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + cmd.MarkFlagRequired("request-id") + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/log_export.go b/products/umodelverse/internal/umodelverse/log_export.go new file mode 100644 index 0000000000..cdbecabf23 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/log_export.go @@ -0,0 +1,53 @@ +package umodelverse + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newLogExport(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &requestLogRequest{} + newRequest(client, req, false) + var modelNames []string + var apiKeyIds []string + + cmd := &cobra.Command{ + Use: "export", + Short: "Export uModelVerse inference request logs", + Long: "Export uModelVerse inference request logs. Time flags use Unix milliseconds.", + Run: func(c *cobra.Command, args []string) { + clearStringIfUnchanged(c.Flags(), "request-id", &req.RequestId) + req.ModelNames = stringSliceJSONRef(modelNames) + req.ApiKeyIds = stringSliceJSONRef(apiKeyIds) + resp, err := invokeUMAction(client, "DownloadUMInferRequestLog", req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintln(ctx.ProgressWriter(), "umodelverse log export task created") + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + bindProject(cmd, req, ctx.DefaultProjectID()) + req.StartTime = flags.Int64("start-time-ms", 0, "Required. Export start time, Unix timestamp in milliseconds.") + req.EndTime = flags.Int64("end-time-ms", 0, "Required. Export end time, Unix timestamp in milliseconds.") + req.Email = flags.String("email", "", "Required. Email address to receive export result.") + flags.StringSliceVar(&modelNames, "model-name", nil, "Optional. Model name filter. Can be repeated or comma-separated.") + flags.StringSliceVar(&apiKeyIds, "key-id", nil, "Optional. API key ID filter. Can be repeated or comma-separated.") + req.RequestId = flags.String("request-id", "", "Optional. Request ID filter.") + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + cmd.MarkFlagRequired("start-time-ms") + cmd.MarkFlagRequired("end-time-ms") + cmd.MarkFlagRequired("email") + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/log_list.go b/products/umodelverse/internal/umodelverse/log_list.go new file mode 100644 index 0000000000..c4ee339f41 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/log_list.go @@ -0,0 +1,67 @@ +package umodelverse + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newLogList(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &requestLogRequest{} + newRequest(client, req, true) + var modelNames []string + var apiKeyIds []string + + cmd := &cobra.Command{ + Use: "list", + Short: "List uModelVerse inference request logs", + Long: "List uModelVerse inference request logs.", + PreRunE: func(c *cobra.Command, args []string) error { + if req.Limit != nil && !isAllowedLogLimit(*req.Limit) { + return fmt.Errorf("limit must be one of [10, 20, 50, 100]") + } + return nil + }, + Run: func(c *cobra.Command, args []string) { + clearStringIfUnchanged(c.Flags(), "request-id", &req.RequestId) + req.ModelNames = stringSliceJSONRef(modelNames) + req.ApiKeyIds = stringSliceJSONRef(apiKeyIds) + resp, err := invokeUMAction(client, "ListUMInferRequestLogs", req) + if err != nil { + ctx.HandleError(err) + return + } + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + bindProject(cmd, req, ctx.DefaultProjectID()) + req.StartTime = flags.Int64("start-time-ms", 0, "Required. Query start time, Unix timestamp in milliseconds.") + req.EndTime = flags.Int64("end-time-ms", 0, "Required. Query end time, Unix timestamp in milliseconds.") + flags.StringSliceVar(&modelNames, "model-name", nil, "Optional. Model name filter. Can be repeated or comma-separated.") + flags.StringSliceVar(&apiKeyIds, "key-id", nil, "Optional. API key ID filter. Can be repeated or comma-separated.") + req.RequestId = flags.String("request-id", "", "Optional. Request ID filter.") + req.Offset = flags.Int("offset", 0, "Optional. The index of log which start to list.") + req.Limit = flags.Int("limit", 20, "Optional. The maximum number of logs per page. Allowed values: 10, 20, 50, 100.") + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("zone") + cmd.MarkFlagRequired("start-time-ms") + cmd.MarkFlagRequired("end-time-ms") + return cmd +} + +func isAllowedLogLimit(limit int) bool { + switch limit { + case 10, 20, 50, 100: + return true + default: + return false + } +} diff --git a/products/umodelverse/internal/umodelverse/model.go b/products/umodelverse/internal/umodelverse/model.go new file mode 100644 index 0000000000..dde559bc3e --- /dev/null +++ b/products/umodelverse/internal/umodelverse/model.go @@ -0,0 +1,18 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newModel(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "model", + Short: "Query uModelVerse models", + Long: "Query uModelVerse models.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newModelList(ctx)) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/model_list.go b/products/umodelverse/internal/umodelverse/model_list.go new file mode 100644 index 0000000000..55aded952e --- /dev/null +++ b/products/umodelverse/internal/umodelverse/model_list.go @@ -0,0 +1,51 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newModelList(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &squareModelRequest{} + newRequest(client, req, true) + var maxModelLen []int + var language []string + + cmd := &cobra.Command{ + Use: "list", + Short: "List uModelVerse square models", + Long: "List uModelVerse square models.", + Run: func(c *cobra.Command, args []string) { + flags := c.Flags() + clearStringIfUnchanged(flags, "model-type", &req.ModelType) + clearStringIfUnchanged(flags, "keyword", &req.KeyWord) + clearStringIfUnchanged(flags, "order-by", &req.OrderBy) + clearStringIfUnchanged(flags, "order", &req.Order) + req.MaxModelLen = intSliceJSONRef(maxModelLen) + req.Language = stringSliceJSONRef(language) + resp, err := invokeUMAction(client, "ListUFSquareModel", req) + if err != nil { + ctx.HandleError(err) + return + } + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + req.ModelType = flags.String("model-type", "", "Optional. Model type.") + req.KeyWord = flags.String("keyword", "", "Optional. Keyword filter.") + req.Offset = flags.Int("offset", 0, "Optional. The index of model which start to list.") + req.Limit = flags.Int("limit", 20, "Optional. The maximum number of models per page.") + req.OrderBy = flags.String("order-by", "", "Optional. Sort field.") + req.Order = flags.String("order", "", "Optional. Sort order.") + flags.IntSliceVar(&maxModelLen, "max-model-len", nil, "Optional. Context length filter. Can be repeated or comma-separated.") + flags.StringSliceVar(&language, "language", nil, "Optional. Language filter, e.g. chinese or english. Can be repeated or comma-separated.") + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order.go b/products/umodelverse/internal/umodelverse/order.go new file mode 100644 index 0000000000..62b64ec350 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order.go @@ -0,0 +1,21 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrder(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "order", + Short: "Query and export uModelVerse orders", + Long: "Query and export uModelVerse orders.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newOrderAmount(ctx)) + cmd.AddCommand(newOrderPaid(ctx)) + cmd.AddCommand(newOrderUnpaid(ctx)) + cmd.AddCommand(newOrderSummary(ctx)) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order_amount.go b/products/umodelverse/internal/umodelverse/order_amount.go new file mode 100644 index 0000000000..34d9f15359 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order_amount.go @@ -0,0 +1,33 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrderAmount(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &orderRequest{} + newRequest(client, req, true) + + cmd := &cobra.Command{ + Use: "amount", + Short: "Get uModelVerse order amount statistics", + Long: "Get uModelVerse order amount statistics.", + Run: func(c *cobra.Command, args []string) { + resp, err := invokeUMAction(client, "GetOrderAmount", req) + if err != nil { + ctx.HandleError(err) + return + } + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + bindTimeRange(cmd, req) + bindOrderFilters(cmd, req) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order_paid.go b/products/umodelverse/internal/umodelverse/order_paid.go new file mode 100644 index 0000000000..65f314afc0 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order_paid.go @@ -0,0 +1,20 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrderPaid(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "paid", + Short: "Query and export paid uModelVerse orders", + Long: "Query and export paid uModelVerse orders.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newOrderPaidList(ctx)) + cmd.AddCommand(newOrderPaidSummary(ctx)) + cmd.AddCommand(newOrderPaidExport(ctx)) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order_paid_export.go b/products/umodelverse/internal/umodelverse/order_paid_export.go new file mode 100644 index 0000000000..1a00360810 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order_paid_export.go @@ -0,0 +1,36 @@ +package umodelverse + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrderPaidExport(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &orderRequest{} + newRequest(client, req, false) + + cmd := &cobra.Command{ + Use: "export", + Short: "Export paid uModelVerse order details", + Long: "Export paid uModelVerse order details as an Excel file download link.", + Run: func(c *cobra.Command, args []string) { + resp, err := invokeUMAction(client, "DownloadListPaidOrders", req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintln(ctx.ProgressWriter(), "umodelverse paid order export created") + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + bindTimeRange(cmd, req) + bindOrderFilters(cmd, req) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order_paid_list.go b/products/umodelverse/internal/umodelverse/order_paid_list.go new file mode 100644 index 0000000000..7a32553380 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order_paid_list.go @@ -0,0 +1,34 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrderPaidList(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &orderRequest{} + newRequest(client, req, true) + + cmd := &cobra.Command{ + Use: "list", + Short: "List paid uModelVerse orders", + Long: "List paid uModelVerse orders. Time range is [start-time, end-time), in Unix seconds.", + Run: func(c *cobra.Command, args []string) { + resp, err := invokeUMAction(client, "ListPaidOrders", req) + if err != nil { + ctx.HandleError(err) + return + } + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + bindTimeRange(cmd, req) + bindPage(cmd, req) + bindOrderFilters(cmd, req) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order_paid_summary.go b/products/umodelverse/internal/umodelverse/order_paid_summary.go new file mode 100644 index 0000000000..52c5aa7509 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order_paid_summary.go @@ -0,0 +1,34 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrderPaidSummary(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &orderRequest{} + newRequest(client, req, true) + + cmd := &cobra.Command{ + Use: "summary", + Short: "Summarize paid uModelVerse orders", + Long: "Summarize paid uModelVerse orders.", + Run: func(c *cobra.Command, args []string) { + resp, err := invokeUMAction(client, "ListPaidOrderSummary", req) + if err != nil { + ctx.HandleError(err) + return + } + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + bindTimeRange(cmd, req) + bindOrderFilters(cmd, req) + bindOrderChargeTypes(cmd, req) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order_summary.go b/products/umodelverse/internal/umodelverse/order_summary.go new file mode 100644 index 0000000000..eeebe5bc01 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order_summary.go @@ -0,0 +1,18 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrderSummary(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "summary", + Short: "Export uModelVerse order summaries", + Long: "Export uModelVerse order summaries.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newOrderSummaryExport(ctx)) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order_summary_export.go b/products/umodelverse/internal/umodelverse/order_summary_export.go new file mode 100644 index 0000000000..ad8bdfe1fb --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order_summary_export.go @@ -0,0 +1,37 @@ +package umodelverse + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrderSummaryExport(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &orderRequest{} + newRequest(client, req, false) + + cmd := &cobra.Command{ + Use: "export", + Short: "Export uModelVerse order summary", + Long: "Export uModelVerse order summary as an Excel file download link.", + Run: func(c *cobra.Command, args []string) { + resp, err := invokeUMAction(client, "DownloadOrderSummary", req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintln(ctx.ProgressWriter(), "umodelverse order summary export created") + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + bindTimeRange(cmd, req) + bindOrderFilters(cmd, req) + bindOrderChargeTypes(cmd, req) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order_unpaid.go b/products/umodelverse/internal/umodelverse/order_unpaid.go new file mode 100644 index 0000000000..9b3207f550 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order_unpaid.go @@ -0,0 +1,20 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrderUnpaid(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "unpaid", + Short: "Query and export unpaid uModelVerse orders", + Long: "Query and export unpaid uModelVerse orders.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newOrderUnpaidList(ctx)) + cmd.AddCommand(newOrderUnpaidSummary(ctx)) + cmd.AddCommand(newOrderUnpaidExport(ctx)) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order_unpaid_export.go b/products/umodelverse/internal/umodelverse/order_unpaid_export.go new file mode 100644 index 0000000000..9f2e2f024d --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order_unpaid_export.go @@ -0,0 +1,36 @@ +package umodelverse + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrderUnpaidExport(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &orderRequest{} + newRequest(client, req, false) + + cmd := &cobra.Command{ + Use: "export", + Short: "Export unpaid uModelVerse order details", + Long: "Export unpaid uModelVerse order details as an Excel file download link.", + Run: func(c *cobra.Command, args []string) { + resp, err := invokeUMAction(client, "DownloadListUnpaidOrders", req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintln(ctx.ProgressWriter(), "umodelverse unpaid order export created") + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + bindTimeRange(cmd, req) + bindOrderFilters(cmd, req) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order_unpaid_list.go b/products/umodelverse/internal/umodelverse/order_unpaid_list.go new file mode 100644 index 0000000000..dcd18e6ab0 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order_unpaid_list.go @@ -0,0 +1,34 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrderUnpaidList(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &orderRequest{} + newRequest(client, req, true) + + cmd := &cobra.Command{ + Use: "list", + Short: "List unpaid uModelVerse orders", + Long: "List unpaid uModelVerse orders.", + Run: func(c *cobra.Command, args []string) { + resp, err := invokeUMAction(client, "ListUnpaidOrders", req) + if err != nil { + ctx.HandleError(err) + return + } + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + bindTimeRange(cmd, req) + bindPage(cmd, req) + bindOrderFilters(cmd, req) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/order_unpaid_summary.go b/products/umodelverse/internal/umodelverse/order_unpaid_summary.go new file mode 100644 index 0000000000..917c2b8cc9 --- /dev/null +++ b/products/umodelverse/internal/umodelverse/order_unpaid_summary.go @@ -0,0 +1,34 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func newOrderUnpaidSummary(ctx *cli.Context) *cobra.Command { + client := newClient(ctx) + req := &orderRequest{} + newRequest(client, req, true) + + cmd := &cobra.Command{ + Use: "summary", + Short: "Summarize unpaid uModelVerse orders", + Long: "Summarize unpaid uModelVerse orders.", + Run: func(c *cobra.Command, args []string) { + resp, err := invokeUMAction(client, "ListUnpaidOrderSummary", req) + if err != nil { + ctx.HandleError(err) + return + } + printResponse(ctx, resp) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + bindTimeRange(cmd, req) + bindOrderFilters(cmd, req) + bindOrderChargeTypes(cmd, req) + return cmd +} diff --git a/products/umodelverse/internal/umodelverse/rows.go b/products/umodelverse/internal/umodelverse/rows.go new file mode 100644 index 0000000000..764b0e9bde --- /dev/null +++ b/products/umodelverse/internal/umodelverse/rows.go @@ -0,0 +1,6 @@ +package umodelverse + +type fieldRow struct { + Field string + Value string +} diff --git a/products/umodelverse/product.go b/products/umodelverse/product.go new file mode 100644 index 0000000000..7c1c64afe6 --- /dev/null +++ b/products/umodelverse/product.go @@ -0,0 +1,21 @@ +package umodelverse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalumodelverse "github.com/ucloud/ucloud-cli/products/umodelverse/internal/umodelverse" +) + +type product struct{} + +// New returns the umodelverse product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "umodelverse", Commands: []string{"umodelverse"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalumodelverse.NewCommand(ctx)} +} diff --git a/products/umodelverse/product.yaml b/products/umodelverse/product.yaml new file mode 100644 index 0000000000..93e6551663 --- /dev/null +++ b/products/umodelverse/product.yaml @@ -0,0 +1,7 @@ +# products/umodelverse/product.yaml — umodelverse 产品元数据(归属真源,owner 自治维护) +name: umodelverse +owners: + - Episkey-G +commands: + - umodelverse +enabled: true diff --git a/products/umodelverse/testdata/cmdtree.golden b/products/umodelverse/testdata/cmdtree.golden new file mode 100644 index 0000000000..d3209208c9 --- /dev/null +++ b/products/umodelverse/testdata/cmdtree.golden @@ -0,0 +1,192 @@ +ucloud umodelverse use=umodelverse short=Manipulate uModelVerse resources +ucloud umodelverse apikey use=apikey short=Manage uModelVerse API keys +ucloud umodelverse apikey create use=create short=Create a uModelVerse API key + flag=daily-limit-amount short= default= required= + flag=daily-quota-alert-threshold short= default=0 required= + flag=expire-time short= default=0 required= + flag=grant-all-models short= default=true required= + flag=granted-models short= default=[] required= + flag=ip-whitelist short= default= required= + flag=modelverse-disabled short= default=0 required= + flag=monthly-limit-amount short= default= required= + flag=monthly-quota-alert-threshold short= default=0 required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=quota-alert-channel short= default=[] required= + flag=quota-alert-email short= default= required= + flag=quota-alert-email-verification-token short= default= required= + flag=quota-alert-phone short= default= required= + flag=quota-alert-phone-verification-token short= default= required= + flag=sandbox-disabled short= default=0 required= +ucloud umodelverse apikey delete use=delete short=Delete a uModelVerse API key + flag=key-id short= default= required=true + flag=project-id short= default= required= + flag=yes short=y default=false required= +ucloud umodelverse apikey list use=list short=List uModelVerse API keys + flag=key-id short= default= required= + flag=limit short= default=20 required= + flag=modelverse-disabled short= default=0 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=sandbox-disabled short= default=0 required= +ucloud umodelverse apikey update use=update short=Update a uModelVerse API key + flag=daily-limit-amount short= default= required= + flag=daily-quota-alert-threshold short= default=0 required= + flag=expire-time short= default=0 required= + flag=grant-all-models short= default=true required= + flag=granted-models short= default=[] required= + flag=ip-whitelist short= default= required= + flag=key-id short= default= required=true + flag=modelverse-disabled short= default=0 required= + flag=monthly-limit-amount short= default= required= + flag=monthly-quota-alert-threshold short= default=0 required= + flag=name short= default= required= + flag=project-id short= default= required= + flag=quota-alert-channel short= default=[] required= + flag=quota-alert-email short= default= required= + flag=quota-alert-email-verification-token short= default= required= + flag=quota-alert-phone short= default= required= + flag=quota-alert-phone-verification-token short= default= required= + flag=sandbox-disabled short= default=0 required= + flag=status short= default=0 required= +ucloud umodelverse filter-options use=filter-options short=Get uModelVerse order filter options + flag=product-code short= default= required= +ucloud umodelverse log use=log short=Query and export uModelVerse inference logs +ucloud umodelverse log describe use=describe short=Describe a uModelVerse inference request log + flag=project-id short= default= required= + flag=region short= default= required=true + flag=request-id short= default= required=true + flag=zone short= default= required=true +ucloud umodelverse log export use=export short=Export uModelVerse inference request logs + flag=email short= default= required=true + flag=end-time-ms short= default=0 required=true + flag=key-id short= default=[] required= + flag=model-name short= default=[] required= + flag=project-id short= default= required= + flag=region short= default= required=true + flag=request-id short= default= required= + flag=start-time-ms short= default=0 required=true + flag=zone short= default= required=true +ucloud umodelverse log list use=list short=List uModelVerse inference request logs + flag=end-time-ms short= default=0 required=true + flag=key-id short= default=[] required= + flag=limit short= default=20 required= + flag=model-name short= default=[] required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required=true + flag=request-id short= default= required= + flag=start-time-ms short= default=0 required=true + flag=zone short= default= required=true +ucloud umodelverse model use=model short=Query uModelVerse models +ucloud umodelverse model list use=list short=List uModelVerse square models + flag=keyword short= default= required= + flag=language short= default=[] required= + flag=limit short= default=20 required= + flag=max-model-len short= default=[] required= + flag=model-type short= default= required= + flag=offset short= default=0 required= + flag=order short= default= required= + flag=order-by short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud umodelverse order use=order short=Query and export uModelVerse orders +ucloud umodelverse order amount use=amount short=Get uModelVerse order amount statistics + flag=end-time short= default=0 required=true + flag=model-id short= default=[] required= + flag=order-region short= default=[] required= + flag=order-type short= default=[] required= + flag=organization-id short= default=[] required= + flag=pricing-sku short= default=[] required= + flag=pricing-unit short= default=[] required= + flag=product-code short= default=[] required= + flag=resource-id short= default=[] required= + flag=start-time short= default=0 required=true +ucloud umodelverse order paid use=paid short=Query and export paid uModelVerse orders +ucloud umodelverse order paid export use=export short=Export paid uModelVerse order details + flag=end-time short= default=0 required=true + flag=model-id short= default=[] required= + flag=order-region short= default=[] required= + flag=order-type short= default=[] required= + flag=organization-id short= default=[] required= + flag=pricing-sku short= default=[] required= + flag=pricing-unit short= default=[] required= + flag=product-code short= default=[] required= + flag=resource-id short= default=[] required= + flag=start-time short= default=0 required=true +ucloud umodelverse order paid list use=list short=List paid uModelVerse orders + flag=end-time short= default=0 required=true + flag=model-id short= default=[] required= + flag=order-region short= default=[] required= + flag=order-type short= default=[] required= + flag=organization-id short= default=[] required= + flag=page short= default=1 required=true + flag=page-size short= default=20 required=true + flag=pricing-sku short= default=[] required= + flag=pricing-unit short= default=[] required= + flag=product-code short= default=[] required= + flag=resource-id short= default=[] required= + flag=start-time short= default=0 required=true +ucloud umodelverse order paid summary use=summary short=Summarize paid uModelVerse orders + flag=charge-type-code short= default=[] required= + flag=end-time short= default=0 required=true + flag=model-id short= default=[] required= + flag=order-region short= default=[] required= + flag=order-type short= default=[] required= + flag=organization-id short= default=[] required= + flag=pricing-sku short= default=[] required= + flag=pricing-unit short= default=[] required= + flag=product-code short= default=[] required= + flag=resource-id short= default=[] required= + flag=start-time short= default=0 required=true +ucloud umodelverse order summary use=summary short=Export uModelVerse order summaries +ucloud umodelverse order summary export use=export short=Export uModelVerse order summary + flag=charge-type-code short= default=[] required= + flag=end-time short= default=0 required=true + flag=model-id short= default=[] required= + flag=order-region short= default=[] required= + flag=order-type short= default=[] required= + flag=organization-id short= default=[] required= + flag=pricing-sku short= default=[] required= + flag=pricing-unit short= default=[] required= + flag=product-code short= default=[] required= + flag=resource-id short= default=[] required= + flag=start-time short= default=0 required=true +ucloud umodelverse order unpaid use=unpaid short=Query and export unpaid uModelVerse orders +ucloud umodelverse order unpaid export use=export short=Export unpaid uModelVerse order details + flag=end-time short= default=0 required=true + flag=model-id short= default=[] required= + flag=order-region short= default=[] required= + flag=order-type short= default=[] required= + flag=organization-id short= default=[] required= + flag=pricing-sku short= default=[] required= + flag=pricing-unit short= default=[] required= + flag=product-code short= default=[] required= + flag=resource-id short= default=[] required= + flag=start-time short= default=0 required=true +ucloud umodelverse order unpaid list use=list short=List unpaid uModelVerse orders + flag=end-time short= default=0 required=true + flag=model-id short= default=[] required= + flag=order-region short= default=[] required= + flag=order-type short= default=[] required= + flag=organization-id short= default=[] required= + flag=page short= default=1 required=true + flag=page-size short= default=20 required=true + flag=pricing-sku short= default=[] required= + flag=pricing-unit short= default=[] required= + flag=product-code short= default=[] required= + flag=resource-id short= default=[] required= + flag=start-time short= default=0 required=true +ucloud umodelverse order unpaid summary use=summary short=Summarize unpaid uModelVerse orders + flag=charge-type-code short= default=[] required= + flag=end-time short= default=0 required=true + flag=model-id short= default=[] required= + flag=order-region short= default=[] required= + flag=order-type short= default=[] required= + flag=organization-id short= default=[] required= + flag=pricing-sku short= default=[] required= + flag=pricing-unit short= default=[] required= + flag=product-code short= default=[] required= + flag=resource-id short= default=[] required= + flag=start-time short= default=0 required=true diff --git a/products/umodelverse/testdata/completion.golden b/products/umodelverse/testdata/completion.golden new file mode 100644 index 0000000000..b1bae7f71b --- /dev/null +++ b/products/umodelverse/testdata/completion.golden @@ -0,0 +1,9 @@ +ucloud umodelverse log describe region dynamic +ucloud umodelverse log describe zone dynamic +ucloud umodelverse log export region dynamic +ucloud umodelverse log export zone dynamic +ucloud umodelverse log list region dynamic +ucloud umodelverse log list zone dynamic +ucloud umodelverse model list project-id dynamic +ucloud umodelverse model list region dynamic +ucloud umodelverse model list zone dynamic diff --git a/products/umongodb/internal/umongodb/api.go b/products/umongodb/internal/umongodb/api.go new file mode 100644 index 0000000000..60504b3919 --- /dev/null +++ b/products/umongodb/internal/umongodb/api.go @@ -0,0 +1,39 @@ +package umongodb + +import ( + "fmt" + "time" + + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// defaultCallTimeout is the HTTP timeout for GenericInvoke calls. +// MongoDB creation APIs are slow; 5 minutes avoids premature timeouts. +const defaultCallTimeout = 5 * time.Minute + +// genericCall invokes a UMongoDB API via GenericInvoke and returns the +// raw payload map. All MongoDB commands use this instead of typed SDK methods. +func genericCall(ctx *cli.Context, action string, params map[string]interface{}) (map[string]interface{}, error) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + return nil, fmt.Errorf("set payload for %s: %w", action, err) + } + req.WithTimeout(defaultCallTimeout) + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, err + } + payload := resp.GetPayload() + retCode, _ := payload["RetCode"].(float64) + if retCode != 0 { + msg, _ := payload["Message"].(string) + if msg == "" { + msg = fmt.Sprintf("API %s returned RetCode %v", action, retCode) + } + return nil, fmt.Errorf("%s: %s", action, msg) + } + return payload, nil +} diff --git a/products/umongodb/internal/umongodb/cmd.go b/products/umongodb/internal/umongodb/cmd.go new file mode 100644 index 0000000000..75da68ed73 --- /dev/null +++ b/products/umongodb/internal/umongodb/cmd.go @@ -0,0 +1,31 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand assembles the umongodb command tree. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: productName, + Short: "Manipulate MongoDB on UCloud platform", + Long: "Manipulate MongoDB on UCloud platform", + } + + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newListVersions(ctx)) + cmd.AddCommand(newListTemplates(ctx)) + cmd.AddCommand(newListMachineSpecs(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newCreateReplset(ctx)) + cmd.AddCommand(newCreateSharded(ctx)) + cmd.AddCommand(newStart(ctx)) + cmd.AddCommand(newStop(ctx)) + cmd.AddCommand(newRestart(ctx)) + cmd.AddCommand(newDeleteReplset(ctx)) + cmd.AddCommand(newDeleteSharded(ctx)) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/completion.go b/products/umongodb/internal/umongodb/completion.go new file mode 100644 index 0000000000..50fce56062 --- /dev/null +++ b/products/umongodb/internal/umongodb/completion.go @@ -0,0 +1,289 @@ +package umongodb + +import ( + "fmt" + "strings" + + "github.com/ucloud/ucloud-sdk-go/services/vpc" + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// --------------------------------------------------------------------------- +// Generic helpers +// --------------------------------------------------------------------------- + +// derefStr safely dereferences a *string bound by a flag, returning "" for nil. +func derefStr(p *string) string { + if p == nil { + return "" + } + return *p +} + +// stateAllowed reports whether state passes the optional allow-list. A nil +// allow-list means "any state". +func stateAllowed(state string, states []string) bool { + if states == nil { + return true + } + for _, s := range states { + if s == state { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// MongoDB instance ID completion (ListUMongoDBInstances) — GenericInvoke +// SDK has no typed method for ListUMongoDBInstances. +// --------------------------------------------------------------------------- + +// getMongoDBIDList returns the resource-id completion candidates for the +// --umongodb-id flag, in the conventional "id/name" form. states restricts +// candidates to those whose State is in the set (nil = any state). +func getMongoDBIDList(ctx *cli.Context, states []string, region, zone, projectID string) []string { + params := map[string]interface{}{ + "Action": "ListUMongoDBInstances", + "Region": region, + } + if zone != "" { + params["Zone"] = zone + } + if projectID != "" { + params["ProjectId"] = projectID + } + payload, err := genericCall(ctx, "ListUMongoDBInstances", params) + if err != nil { + return nil + } + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + return nil + } + candidates := make([]string, 0, len(dataSet)) + for _, item := range dataSet { + ins, ok := item.(map[string]interface{}) + if !ok { + continue + } + if states != nil { + // ListUMongoDBInstances has no state filter param; + // client-side filter if states provided. + st, _ := ins["State"].(string) + if !stateAllowed(st, states) { + continue + } + } + id, _ := ins["ClusterId"].(string) + name, _ := ins["Name"].(string) + candidates = append(candidates, fmt.Sprintf("%s/%s", id, name)) + } + return candidates +} + +// --------------------------------------------------------------------------- +// MongoDB version completion (ListUMongoDBVersion) — GenericInvoke +// SDK typed response lacks EngineType and DefaultDBVersion fields. +// --------------------------------------------------------------------------- + +// getMongoDBVersionList returns available MongoDB version strings via +// ListUMongoDBVersion. +func getMongoDBVersionList(ctx *cli.Context, region, zone string) []string { + params := map[string]interface{}{ + "Action": "ListUMongoDBVersion", + "Region": region, + "Zone": zone, + } + payload, err := genericCall(ctx, "ListUMongoDBVersion", params) + if err != nil { + return nil + } + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + return nil + } + list := make([]string, 0, len(dataSet)) + for _, item := range dataSet { + v, ok := item.(map[string]interface{}) + if !ok { + continue + } + ver, _ := v["DBVersion"].(string) + if ver != "" { + list = append(list, ver) + } + } + return list +} + +// --------------------------------------------------------------------------- +// Config template completion and auto-default (ListUMongoDBConfigTemplate) +// Migrated to typed SDK. +// --------------------------------------------------------------------------- + +// getDefaultTemplateID fetches the default config template ID for a given +// MongoDB version and cluster type via typed ListUMongoDBConfigTemplate. +func getDefaultTemplateID(ctx *cli.Context, dbVersion, clusterType, project, region string) (string, error) { + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewListUMongoDBConfigTemplateRequest() + req.Region = ®ion + if project != "" { + req.ProjectId = &project + } + resp, err := client.ListUMongoDBConfigTemplate(req) + if err != nil { + return "", err + } + if len(resp.DataSet) == 0 { + return "", fmt.Errorf("no config template found for version %s / type %s in %s", dbVersion, clusterType, region) + } + for _, t := range resp.DataSet { + if t.MongodbVersion == dbVersion && t.ClusterType == clusterType && t.TemplateType == "DefaultTemplate" { + if t.TemplateId != "" { + return t.TemplateId, nil + } + } + } + return "", fmt.Errorf("no default config template found for version %s / type %s in %s", dbVersion, clusterType, region) +} + +// getMongoDBTemplateList returns config template candidates as "id/name" strings +// via typed ListUMongoDBConfigTemplate. +func getMongoDBTemplateList(ctx *cli.Context, dbVersion, project, region string) []string { + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewListUMongoDBConfigTemplateRequest() + req.Region = ®ion + if project != "" { + req.ProjectId = &project + } + resp, err := client.ListUMongoDBConfigTemplate(req) + if err != nil { + return nil + } + var list []string + for _, t := range resp.DataSet { + // Filter by version if specified + if dbVersion != "" { + if !strings.EqualFold(t.MongodbVersion, dbVersion) { + continue + } + } + if t.TemplateId != "" { + list = append(list, fmt.Sprintf("%s/%s", t.TemplateId, t.TemplateName)) + } + } + return list +} + +// --------------------------------------------------------------------------- +// Machine spec completion (ListUMongoDBMachineSpec) — GenericInvoke +// SDK has no typed method for ListUMongoDBMachineSpec. +// --------------------------------------------------------------------------- + +// getMongoDBMachineSpecList returns machine type candidates from +// ListUMongoDBMachineSpec, flattened from nested ComputeType arrays. +func getMongoDBMachineSpecList(ctx *cli.Context, region, zone string) []string { + params := map[string]interface{}{ + "Action": "ListUMongoDBMachineSpec", + "Region": region, + "Zone": zone, + } + payload, err := genericCall(ctx, "ListUMongoDBMachineSpec", params) + if err != nil { + return nil + } + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + return nil + } + var list []string + for _, item := range dataSet { + spec, ok := item.(map[string]interface{}) + if !ok { + continue + } + ct, ok := spec["ComputeType"].([]interface{}) + if !ok { + continue + } + for _, c := range ct { + m, ok := c.(map[string]interface{}) + if !ok { + continue + } + id, _ := m["MachineTypeId"].(string) + desc, _ := m["Description"].(string) + if id != "" { + list = append(list, fmt.Sprintf("%s/%s", id, desc)) + } + } + } + return list +} + +// --------------------------------------------------------------------------- +// VPC / Subnet completion (copied per boundary rule, products must be self-contained) +// --------------------------------------------------------------------------- + +func getAllVPCIns(ctx *cli.Context, project, region string) ([]vpc.VPCInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + vpcInsList, err := getAllVPCIns(ctx, project, region) + if err != nil { + return nil + } + list := make([]string, 0, len(vpcInsList)) + for _, v := range vpcInsList { + list = append(list, fmt.Sprintf("%s/%s", v.VPCId, v.Name)) + } + return list +} + +func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpc.SubnetInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = &project + req.Region = ®ion + if vpcID != "" { + req.VPCId = &vpcID + } + subnets := []vpc.SubnetInfo{} + for limit, offset := 50, 0; ; offset += limit { + req.Limit = &limit + req.Offset = &offset + resp, err := client.DescribeSubnet(req) + if err != nil { + return nil, err + } + subnets = append(subnets, resp.DataSet...) + if limit+offset >= resp.TotalCount { + break + } + } + return subnets, nil +} + +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + subnets, err := getAllSubnets(ctx, vpcID, project, region) + if err != nil { + return nil + } + list := make([]string, 0, len(subnets)) + for _, s := range subnets { + list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return list +} diff --git a/products/umongodb/internal/umongodb/const.go b/products/umongodb/internal/umongodb/const.go new file mode 100644 index 0000000000..b310b00914 --- /dev/null +++ b/products/umongodb/internal/umongodb/const.go @@ -0,0 +1,9 @@ +package umongodb + +// productName is the single source of truth for the umongodb command name +// and its resource-id flag (--umongodb-id). +const productName = "umongodb" + +// resourceIDFlag is the resource-id flag, named after the product per the +// onboarding contract. +const resourceIDFlag = productName + "-id" // "umongodb-id" diff --git a/products/umongodb/internal/umongodb/create_replset.go b/products/umongodb/internal/umongodb/create_replset.go new file mode 100644 index 0000000000..98aa064f61 --- /dev/null +++ b/products/umongodb/internal/umongodb/create_replset.go @@ -0,0 +1,145 @@ +package umongodb + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreateReplset implements `umongodb create-replset`. +// Uses GenericInvoke because the typed CreateUMongoDBReplSetRequest +// lacks the TemplateId field required by the API. +func newCreateReplset(ctx *cli.Context) *cobra.Command { + var common request.CommonBase + + var name, password, version string + var diskSpaceGB, nodeCount int + var machineTypeID string + var port int + var templateID string + var vpcID, subnetID, tag string + var chargeType string + var quantity int + + cmd := &cobra.Command{ + Use: "create-replset", + Short: "Create a MongoDB replica set", + Long: "Create a MongoDB replica set asynchronously. Use 'umongodb list' to check creation status.", + Run: func(c *cobra.Command, args []string) { + region := common.GetRegion() + zone := common.GetZone() + projectID := common.GetProjectId() + + // Auto-default template ID if not specified + if templateID == "" { + id, err := getDefaultTemplateID(ctx, version, "ReplicaSet", projectID, region) + if err != nil { + ctx.HandleError(err) + return + } + templateID = id + } + + params := map[string]interface{}{ + "Action": "CreateUMongoDBReplSet", + "Region": region, + "Zone": zone, + "Name": name, + "AdminPassword": password, + "DBVersion": version, + "DiskSpace": diskSpaceGB, + "MachineTypeId": machineTypeID, + "NodeCount": nodeCount, + "TemplateId": templateID, + } + if projectID != "" { + params["ProjectId"] = projectID + } + + // Optional params — only set when explicitly changed + if c.Flags().Changed("port") { + params["ListenPort"] = port + } + if c.Flags().Changed("vpc-id") { + params["VPCId"] = vpcID + } + if c.Flags().Changed("subnet-id") { + params["SubnetId"] = subnetID + } + if c.Flags().Changed("tag") { + params["Tag"] = tag + } + if c.Flags().Changed("charge-type") { + params["ChargeType"] = chargeType + } + if c.Flags().Changed("quantity") { + params["Quantity"] = quantity + } + + if _, err := genericCall(ctx, "CreateUMongoDBReplSet", params); err != nil { + ctx.HandleError(err) + return + } + + w := ctx.ProgressWriter() + fmt.Fprintln(w, fmt.Sprintf("%s is creating (use 'umongodb list' to check status)", name)) + ctx.EmitResult(cli.OpResultRow{ResourceID: name, Action: "create", Status: "Creating"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + // Required flags + flags.StringVar(&name, "name", "", "Required. Instance name, at least 6 characters.") + flags.StringVar(&password, "password", "", "Required. Admin password.") + flags.StringVar(&version, "version", "", "Required. MongoDB version, e.g. \"MongoDB 6.0\".") + flags.IntVar(&diskSpaceGB, "disk-space-gb", 0, "Required. Disk space in GB (20-32000, multiples of 10).") + flags.StringVar(&machineTypeID, "machine-type-id", "", "Required. Machine type ID, e.g. o.mongo2m.medium.") + flags.IntVar(&nodeCount, "node-count", 3, "Node count (3, 5, or 7).") + + // Optional flags + flags.IntVar(&port, "port", 27017, "Optional. Service port.") + flags.StringVar(&templateID, "template-id", "", "Optional. Config template ID. Auto-fetched if omitted.") + flags.StringVar(&vpcID, "vpc-id", "", "Optional. VPC ID. See 'ucloud vpc list'.") + flags.StringVar(&subnetID, "subnet-id", "", "Optional. Subnet ID. See 'ucloud subnet list'.") + flags.StringVar(&tag, "tag", "", "Optional. Business group name.") + flags.StringVar(&chargeType, "charge-type", "Month", "Optional. Charge type: Year / Month / Dynamic / Trial.") + flags.IntVar(&quantity, "quantity", 1, "Optional. Purchase duration in months.") + + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + // Completions + command.SetCompletion(cmd, "version", func() []string { + return getMongoDBVersionList(ctx, common.GetRegion(), common.GetZone()) + }) + command.SetCompletion(cmd, "machine-type-id", func() []string { + return getMongoDBMachineSpecList(ctx, common.GetRegion(), common.GetZone()) + }) + command.SetCompletion(cmd, "template-id", func() []string { + return getMongoDBTemplateList(ctx, version, common.GetProjectId(), common.GetRegion()) + }) + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, common.GetProjectId(), common.GetRegion()) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, vpcID, common.GetProjectId(), common.GetRegion()) + }) + command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic", "Trial") + command.SetFlagValues(cmd, "node-count", "3", "5", "7") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("version") + cmd.MarkFlagRequired("disk-space-gb") + cmd.MarkFlagRequired("machine-type-id") + + return cmd +} diff --git a/products/umongodb/internal/umongodb/create_sharded.go b/products/umongodb/internal/umongodb/create_sharded.go new file mode 100644 index 0000000000..79fa53ba5b --- /dev/null +++ b/products/umongodb/internal/umongodb/create_sharded.go @@ -0,0 +1,151 @@ +package umongodb + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreateSharded implements `umongodb create-sharded`. +func newCreateSharded(ctx *cli.Context) *cobra.Command { + + var name, password, version string + var shardCount, nodeCount, diskSpaceGB int + var machineTypeID string + var mongosNodeCount int + var mongosMachineTypeID string + var port int + var templateID string + var vpcID, subnetID, tag string + var chargeType string + var quantity int + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewCreateUMongoDBShardedClusterRequest() + + cmd := &cobra.Command{ + Use: "create-sharded", + Short: "Create a MongoDB sharded cluster", + Long: "Create a MongoDB sharded cluster asynchronously. Use 'umongodb list' to check creation status.", + Run: func(c *cobra.Command, args []string) { + // Auto-default template ID if not specified + if templateID == "" { + id, err := getDefaultTemplateID(ctx, version, "SharedCluster", *req.ProjectId, *req.Region) + if err != nil { + ctx.HandleError(err) + return + } + templateID = id + } + + req.Name = &name + req.AdminPassword = &password + req.DBVersion = &version + req.ShardCount = &shardCount + req.NodeCount = &nodeCount + req.DiskSpace = &diskSpaceGB + req.MachineTypeId = &machineTypeID + req.TemplateId = &templateID + + // Optional params — only set when explicitly changed + if c.Flags().Changed("mongos-node-count") { + req.MongosNodeCount = &mongosNodeCount + } + if c.Flags().Changed("mongos-machine-type-id") { + req.MongosMachineTypeId = &mongosMachineTypeID + } + if c.Flags().Changed("port") { + req.ListenPort = &port + } + if c.Flags().Changed("vpc-id") { + req.VPCId = &vpcID + } + if c.Flags().Changed("subnet-id") { + req.SubnetId = &subnetID + } + if c.Flags().Changed("tag") { + req.Tag = &tag + } + if c.Flags().Changed("charge-type") { + req.ChargeType = &chargeType + } + if c.Flags().Changed("quantity") { + req.Quantity = &quantity + } + + // MongoDB creation is slow; use 5-minute timeout + req.WithTimeout(5 * time.Minute) + + _, err := client.CreateUMongoDBShardedCluster(req) + if err != nil { + ctx.HandleError(err) + return + } + + w := ctx.ProgressWriter() + fmt.Fprintln(w, fmt.Sprintf("%s is creating (use 'umongodb list' to check status)", name)) + ctx.EmitResult(cli.OpResultRow{ResourceID: name, Action: "create", Status: "Creating"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + // Required flags + flags.StringVar(&name, "name", "", "Required. Instance name, at least 6 characters.") + flags.StringVar(&password, "password", "", "Required. Admin password.") + flags.StringVar(&version, "version", "", "Required. MongoDB version, e.g. \"MongoDB 6.0\".") + flags.IntVar(&shardCount, "shard-count", 0, "Required. Number of shards.") + flags.IntVar(&nodeCount, "node-count", 0, "Required. Number of nodes per shard.") + flags.IntVar(&diskSpaceGB, "disk-space-gb", 0, "Required. Data node disk space in GB (20-32000, multiples of 10).") + flags.StringVar(&machineTypeID, "machine-type-id", "", "Required. Data node machine type ID, e.g. o.mongo2m.medium.") + + // Optional flags + flags.IntVar(&mongosNodeCount, "mongos-node-count", 0, "Optional. Mongos node count.") + flags.StringVar(&mongosMachineTypeID, "mongos-machine-type-id", "", "Optional. Mongos node machine type ID.") + flags.IntVar(&port, "port", 27017, "Optional. Service port.") + flags.StringVar(&templateID, "template-id", "", "Optional. Config template ID. Auto-fetched if omitted.") + flags.StringVar(&vpcID, "vpc-id", "", "Optional. VPC ID. See 'ucloud vpc list'.") + flags.StringVar(&subnetID, "subnet-id", "", "Optional. Subnet ID. See 'ucloud subnet list'.") + flags.StringVar(&tag, "tag", "", "Optional. Business group name.") + flags.StringVar(&chargeType, "charge-type", "Month", "Optional. Charge type: Year / Month / Dynamic / Trial.") + flags.IntVar(&quantity, "quantity", 1, "Optional. Purchase duration in months.") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + // Completions + command.SetCompletion(cmd, "version", func() []string { + return getMongoDBVersionList(ctx, *req.Region, *req.Zone) + }) + command.SetCompletion(cmd, "machine-type-id", func() []string { + return getMongoDBMachineSpecList(ctx, *req.Region, *req.Zone) + }) + command.SetCompletion(cmd, "template-id", func() []string { + return getMongoDBTemplateList(ctx, version, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, vpcID, *req.ProjectId, *req.Region) + }) + command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic", "Trial") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("version") + cmd.MarkFlagRequired("shard-count") + cmd.MarkFlagRequired("node-count") + cmd.MarkFlagRequired("disk-space-gb") + cmd.MarkFlagRequired("machine-type-id") + + return cmd +} diff --git a/products/umongodb/internal/umongodb/delete.go b/products/umongodb/internal/umongodb/delete.go new file mode 100644 index 0000000000..9156fc47d7 --- /dev/null +++ b/products/umongodb/internal/umongodb/delete.go @@ -0,0 +1,141 @@ +package umongodb + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// deleteOpts captures the configuration that differs between delete-replset +// and delete-sharded so the shared newDeleteCmd can handle both. +type deleteOpts struct { + use string + short string + long string + action string // GenericInvoke action name + idParam string // GenericInvoke cluster ID param name ("ClusterId" or "ShardedClusterId") + idFlagDesc string // help text for the --umongodb-id flag +} + +// newDeleteCmd returns a cobra.Command that stops (optionally) and then +// deletes MongoDB clusters via GenericInvoke. The stop step uses the typed +// SDK; the delete step uses GenericInvoke because the SDK has no typed +// delete methods. +func newDeleteCmd(ctx *cli.Context, opts deleteOpts) *cobra.Command { + var async bool + var skipStop bool + var yes bool + var ids []string + + stopClient := cli.NewServiceClient(ctx, umongodb.NewClient) + stopReq := stopClient.NewStopUMongoDBClusterRequest() + + var common request.CommonBase + + cmd := &cobra.Command{ + Use: opts.use, + Short: opts.short, + Long: opts.long, + Run: func(c *cobra.Command, args []string) { + // Confirm before destructive operation + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the umongodb cluster(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + + region := common.GetRegion() + zone := common.GetZone() + projectID := common.GetProjectId() + + // Set loop-invariant stop fields once + if !skipStop { + stopReq.Region = ®ion + if zone != "" { + stopReq.Zone = &zone + } + if projectID != "" { + stopReq.ProjectId = &projectID + } + } + + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + + // Step 1: Stop the cluster (skip when --skip-stop is set) + if !skipStop { + stopReq.ClusterId = sdk.String(id) + _, err := stopClient.StopUMongoDBCluster(stopReq) + if err != nil { + ctx.HandleError(fmt.Errorf("stop %s before delete: %w", id, err)) + continue + } + + // Always poll for Stopped before deleting, even in async mode. + // Only the final delete-step polling is skipped when --async is set. + text := fmt.Sprintf("%s[%s] is stopping before delete", productName, id) + ctx.PollerTo(w, describeByID(ctx, region, zone)).Spoll(id, text, []string{stateStopped, stateFail}) + } + + // Step 2: Delete the cluster via GenericInvoke + params := map[string]interface{}{ + "Action": opts.action, + "Region": region, + opts.idParam: id, + } + if zone != "" { + params["Zone"] = zone + } + if projectID != "" { + params["ProjectId"] = projectID + } + + if _, err := genericCall(ctx, opts.action, params); err != nil { + ctx.HandleError(fmt.Errorf("delete %s: %w", id, err)) + continue + } + + text := fmt.Sprintf("%s[%s] is deleting", productName, id) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx, region, zone)).Spoll(id, text, []string{stateFail}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. "+opts.idFlagDesc) + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the operation to finish.") + flags.BoolVar(&skipStop, "skip-stop", false, "Optional. Skip the stop-before-delete step.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.") + + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getMongoDBIDList(ctx, nil, common.GetRegion(), common.GetZone(), common.GetProjectId()) + }) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/delete_replset.go b/products/umongodb/internal/umongodb/delete_replset.go new file mode 100644 index 0000000000..94ea789098 --- /dev/null +++ b/products/umongodb/internal/umongodb/delete_replset.go @@ -0,0 +1,19 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDeleteReplset implements `umongodb delete-replset`. +func newDeleteReplset(ctx *cli.Context) *cobra.Command { + return newDeleteCmd(ctx, deleteOpts{ + use: "delete-replset", + short: "Delete MongoDB replica set instances", + long: "Delete one or more MongoDB replica set instances. The cluster is stopped before deletion unless --skip-stop is set.", + action: "DeleteUMongoDBReplSet", + idParam: "ClusterId", + idFlagDesc: "Cluster ID(s) of replica set instances to delete.", + }) +} diff --git a/products/umongodb/internal/umongodb/delete_sharded.go b/products/umongodb/internal/umongodb/delete_sharded.go new file mode 100644 index 0000000000..c316f35113 --- /dev/null +++ b/products/umongodb/internal/umongodb/delete_sharded.go @@ -0,0 +1,19 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDeleteSharded implements `umongodb delete-sharded`. +func newDeleteSharded(ctx *cli.Context) *cobra.Command { + return newDeleteCmd(ctx, deleteOpts{ + use: "delete-sharded", + short: "Delete MongoDB sharded cluster instances", + long: "Delete one or more MongoDB sharded cluster instances. The cluster is stopped before deletion unless --skip-stop is set.", + action: "DeleteUMongoDBShardedCluster", + idParam: "ShardedClusterId", + idFlagDesc: "Cluster ID(s) of sharded cluster instances to delete.", + }) +} diff --git a/products/umongodb/internal/umongodb/describe.go b/products/umongodb/internal/umongodb/describe.go new file mode 100644 index 0000000000..101d5cc0c1 --- /dev/null +++ b/products/umongodb/internal/umongodb/describe.go @@ -0,0 +1,87 @@ +package umongodb + +import ( + "strconv" + "time" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDescribe implements `umongodb describe`. +func newDescribe(ctx *cli.Context) *cobra.Command { + var clusterID string + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewDescribeUMongoDBInstanceRequest() + + cmd := &cobra.Command{ + Use: "describe", + Short: "Show details of one MongoDB instance", + Long: "Show the full attribute/value detail of a single MongoDB instance.", + Run: func(c *cobra.Command, args []string) { + req.ClusterId = sdk.String(ctx.PickResourceID(clusterID)) + + resp, err := client.DescribeUMongoDBInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + + ci := resp.ClusterInfo + rows := []cli.DescribeRow{ + {Attribute: "ClusterId", Content: ci.ClusterId}, + {Attribute: "Name", Content: ci.InstanceName}, + {Attribute: "ClusterType", Content: ci.ClusterType}, + {Attribute: "State", Content: ci.State}, + {Attribute: "DBVersion", Content: ci.DBVersion}, + {Attribute: "ConnectURL", Content: ci.ConnectURL}, + {Attribute: "VPCId", Content: ci.VPCId}, + {Attribute: "SubnetId", Content: ci.SubnetId}, + {Attribute: "Tag", Content: ci.Tag}, + {Attribute: "DiskSpace(GB)", Content: strconv.Itoa(ci.DiskSpace)}, + {Attribute: "MachineType", Content: ci.MachineTypeId}, + } + + // Shard info (for sharded clusters) + if ci.ShardCount > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "ShardCount", Content: strconv.Itoa(ci.ShardCount)}) + } + if ci.ShardNodeCount > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "ShardNodeCount", Content: strconv.Itoa(ci.ShardNodeCount)}) + } + if ci.MongosCount > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "MongosCount", Content: strconv.Itoa(ci.MongosCount)}) + } + + // CreateTime + if ci.CreateTime > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "CreateTime", Content: time.Unix(int64(ci.CreateTime), 0).Format("2006-01-02 15:04:05")}) + } + + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&clusterID, resourceIDFlag, "", "Required. Cluster ID of the MongoDB instance to describe.") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getMongoDBIDList(ctx, nil, *req.Region, *req.Zone, *req.ProjectId) + }) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/list.go b/products/umongodb/internal/umongodb/list.go new file mode 100644 index 0000000000..4a105008a8 --- /dev/null +++ b/products/umongodb/internal/umongodb/list.go @@ -0,0 +1,92 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList implements `umongodb list`. +func newList(ctx *cli.Context) *cobra.Command { + var common request.CommonBase + + cmd := &cobra.Command{ + Use: "list", + Short: "List MongoDB instances", + Long: "List MongoDB instances in the active region/zone/project.", + Run: func(c *cobra.Command, args []string) { + params := map[string]interface{}{ + "Action": "ListUMongoDBInstances", + "Region": common.GetRegion(), + } + if zone := common.GetZone(); zone != "" { + params["Zone"] = zone + } + if projectID := common.GetProjectId(); projectID != "" { + params["ProjectId"] = projectID + } + + payload, err := genericCall(ctx, "ListUMongoDBInstances", params) + if err != nil { + ctx.HandleError(err) + return + } + + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + // No instances in this region — return empty list, not an error. + ctx.PrintList([]instanceRow{}) + return + } + + rows := make([]instanceRow, 0, len(dataSet)) + for _, item := range dataSet { + ins, ok := item.(map[string]interface{}) + if !ok { + continue + } + row := instanceRow{ + ResourceID: strVal(ins, "ClusterId"), + Name: strVal(ins, "Name"), + Version: strVal(ins, "DBVersion"), + Status: strVal(ins, "State"), + } + row.ClusterType = strVal(ins, "ClusterType") + row.ConnectURL = strVal(ins, "ConnectURL") + + // DiskSpace + if v, ok := ins["DiskSpace"].(float64); ok { + row.DiskGB = int(v) + } + + // Machine type from nested DataComputeType + if dct, ok := ins["DataComputeType"].(map[string]interface{}); ok { + row.MachineType = strVal(dct, "Description") + } + + rows = append(rows, row) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + return cmd +} + +// strVal extracts a string value from a map, returning "" if missing or wrong type. +func strVal(m map[string]interface{}, key string) string { + v, ok := m[key].(string) + if !ok { + return "" + } + return v +} diff --git a/products/umongodb/internal/umongodb/list_machine_specs.go b/products/umongodb/internal/umongodb/list_machine_specs.go new file mode 100644 index 0000000000..b41906b19d --- /dev/null +++ b/products/umongodb/internal/umongodb/list_machine_specs.go @@ -0,0 +1,120 @@ +package umongodb + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListMachineSpecs implements `umongodb list-machine-specs`. +func newListMachineSpecs(ctx *cli.Context) *cobra.Command { + var common request.CommonBase + var classTypeFilter string + + type specRow struct { + MachineTypeId string + Description string + Cpu int + MemoryGB int + ClassType string + DiskTypes string + } + + cmd := &cobra.Command{ + Use: "list-machine-specs", + Short: "List MongoDB machine specifications", + Long: "List available MongoDB machine types grouped by class type, including supported disk types.", + Run: func(c *cobra.Command, args []string) { + params := map[string]interface{}{ + "Action": "ListUMongoDBMachineSpec", + "Region": common.GetRegion(), + "Zone": common.GetZone(), + } + if projectID := common.GetProjectId(); projectID != "" { + params["ProjectId"] = projectID + } + if classTypeFilter != "" { + params["ClassType"] = classTypeFilter + } + + payload, err := genericCall(ctx, "ListUMongoDBMachineSpec", params) + if err != nil { + ctx.HandleError(err) + return + } + + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + // No machine specs — return empty list, not an error. + ctx.PrintList([]specRow{}) + return + } + + rows := make([]specRow, 0) + for _, item := range dataSet { + spec, ok := item.(map[string]interface{}) + if !ok { + continue + } + classType, _ := spec["ClassType"].(string) + + // Flatten disk types + diskTypes := "" + if dt, ok := spec["DiskType"].([]interface{}); ok { + parts := make([]string, 0, len(dt)) + for _, d := range dt { + if s, ok := d.(string); ok { + parts = append(parts, s) + } + } + diskTypes = fmt.Sprintf("[%s]", strings.Join(parts, ", ")) + } + + // Flatten compute types + if ct, ok := spec["ComputeType"].([]interface{}); ok { + for _, c := range ct { + m, ok := c.(map[string]interface{}) + if !ok { + continue + } + id, _ := m["MachineTypeId"].(string) + desc, _ := m["Description"].(string) + var cpu, mem int + if v, ok := m["Cpu"].(float64); ok { + cpu = int(v) + } + if v, ok := m["Memory"].(float64); ok { + mem = int(v) + } + rows = append(rows, specRow{ + MachineTypeId: id, + Description: desc, + Cpu: cpu, + MemoryGB: mem, + ClassType: classType, + DiskTypes: diskTypes, + }) + } + } + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&classTypeFilter, "class-type", "", "Optional. Filter by class type: O or N.") + + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + return cmd +} + diff --git a/products/umongodb/internal/umongodb/list_templates.go b/products/umongodb/internal/umongodb/list_templates.go new file mode 100644 index 0000000000..d284a96298 --- /dev/null +++ b/products/umongodb/internal/umongodb/list_templates.go @@ -0,0 +1,71 @@ +package umongodb + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListTemplates implements `umongodb list-templates`. +func newListTemplates(ctx *cli.Context) *cobra.Command { + var versionFilter, clusterTypeFilter string + + type templateRow struct { + TemplateId string + Name string + MongodbVersion string + ClusterType string + TemplateType string + } + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewListUMongoDBConfigTemplateRequest() + + cmd := &cobra.Command{ + Use: "list-templates", + Short: "List MongoDB config templates", + Long: "List MongoDB config templates for the current region.", + Run: func(c *cobra.Command, args []string) { + resp, err := client.ListUMongoDBConfigTemplate(req) + if err != nil { + ctx.HandleError(err) + return + } + + rows := make([]templateRow, 0, len(resp.DataSet)) + for _, t := range resp.DataSet { + // Apply filters + if versionFilter != "" && !strings.EqualFold(t.MongodbVersion, versionFilter) { + continue + } + if clusterTypeFilter != "" && !strings.EqualFold(t.ClusterType, clusterTypeFilter) { + continue + } + + rows = append(rows, templateRow{ + TemplateId: t.TemplateId, + Name: t.TemplateName, + MongodbVersion: t.MongodbVersion, + ClusterType: t.ClusterType, + TemplateType: t.TemplateType, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&versionFilter, "version", "", "Optional. Filter by MongoDB version, e.g. \"MongoDB 6.0\".") + flags.StringVar(&clusterTypeFilter, "cluster-type", "", "Optional. Filter by cluster type: ReplicaSet or SharedCluster.") + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/list_versions.go b/products/umongodb/internal/umongodb/list_versions.go new file mode 100644 index 0000000000..991da77c80 --- /dev/null +++ b/products/umongodb/internal/umongodb/list_versions.go @@ -0,0 +1,80 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListVersions implements `umongodb list-versions`. +func newListVersions(ctx *cli.Context) *cobra.Command { + var common request.CommonBase + + type versionRow struct { + Version string + EngineType string + } + + cmd := &cobra.Command{ + Use: "list-versions", + Short: "List available MongoDB versions", + Long: "List MongoDB versions supported in the current region/zone.", + Run: func(c *cobra.Command, args []string) { + params := map[string]interface{}{ + "Action": "ListUMongoDBVersion", + "Region": common.GetRegion(), + "Zone": common.GetZone(), + } + if projectID := common.GetProjectId(); projectID != "" { + params["ProjectId"] = projectID + } + + payload, err := genericCall(ctx, "ListUMongoDBVersion", params) + if err != nil { + ctx.HandleError(err) + return + } + + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + // No versions — return empty list, not an error. + ctx.PrintList([]versionRow{}) + return + } + + // Get default version + defaultVer := "" + if dv, ok := payload["DefaultDBVersion"].(map[string]interface{}); ok { + defaultVer, _ = dv["DBVersion"].(string) + } + + rows := make([]versionRow, 0, len(dataSet)) + for _, item := range dataSet { + v, ok := item.(map[string]interface{}) + if !ok { + continue + } + ver, _ := v["DBVersion"].(string) + eng, _ := v["EngineType"].(string) + if ver != "" { + if ver == defaultVer { + eng += " (default)" + } + rows = append(rows, versionRow{Version: ver, EngineType: eng}) + } + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/poll.go b/products/umongodb/internal/umongodb/poll.go new file mode 100644 index 0000000000..974e344190 --- /dev/null +++ b/products/umongodb/internal/umongodb/poll.go @@ -0,0 +1,29 @@ +package umongodb + +import ( + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// describeByID returns the Poller describe func: given a ClusterId it fetches +// the current cluster state via typed DescribeUMongoDBInstance. +// region/zone are captured at call time because the Poller always passes nil +// for CommonBase (see image/internal/image/describe.go for the same pattern). +func describeByID(ctx *cli.Context, region, zone string) func(string, *request.CommonBase) (interface{}, error) { + return func(id string, _ *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewDescribeUMongoDBInstanceRequest() + req.Region = ®ion + if zone != "" { + req.Zone = &zone + } + req.ClusterId = &id + resp, err := client.DescribeUMongoDBInstance(req) + if err != nil { + return nil, err + } + return &resp.ClusterInfo, nil + } +} diff --git a/products/umongodb/internal/umongodb/restart.go b/products/umongodb/internal/umongodb/restart.go new file mode 100644 index 0000000000..4204898450 --- /dev/null +++ b/products/umongodb/internal/umongodb/restart.go @@ -0,0 +1,66 @@ +package umongodb + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestart implements `umongodb restart`. +func newRestart(ctx *cli.Context) *cobra.Command { + var async bool + var ids []string + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewRestartUMongoDBClusterRequest() + + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart MongoDB instances", + Long: "Restart one or more MongoDB instances.", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + req.ClusterId = sdk.String(id) + _, err := client.RestartUMongoDBCluster(req) + if err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("%s[%s] is restarting", productName, id) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx, *req.Region, *req.Zone)).Spoll(id, text, []string{stateRunning, stateFail}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "restart", Status: "Restarting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. Cluster ID(s) of MongoDB instances to restart.") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the operation to finish.") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getMongoDBIDList(ctx, nil, *req.Region, *req.Zone, *req.ProjectId) + }) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/rows.go b/products/umongodb/internal/umongodb/rows.go new file mode 100644 index 0000000000..0d5dd81bf8 --- /dev/null +++ b/products/umongodb/internal/umongodb/rows.go @@ -0,0 +1,15 @@ +package umongodb + +// instanceRow is the output struct for `umongodb list`. When passed to +// ctx.PrintList in table mode, the exported field NAMES become the column +// headers, in declaration order. +type instanceRow struct { + ResourceID string + Name string + ClusterType string + Version string + MachineType string + DiskGB int + ConnectURL string + Status string +} diff --git a/products/umongodb/internal/umongodb/start.go b/products/umongodb/internal/umongodb/start.go new file mode 100644 index 0000000000..ea9c9fd31a --- /dev/null +++ b/products/umongodb/internal/umongodb/start.go @@ -0,0 +1,66 @@ +package umongodb + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStart implements `umongodb start`. +func newStart(ctx *cli.Context) *cobra.Command { + var async bool + var ids []string + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewStartUMongoDBClusterRequest() + + cmd := &cobra.Command{ + Use: "start", + Short: "Start MongoDB instances", + Long: "Start one or more stopped MongoDB instances.", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + req.ClusterId = sdk.String(id) + _, err := client.StartUMongoDBCluster(req) + if err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("%s[%s] is starting", productName, id) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx, *req.Region, *req.Zone)).Spoll(id, text, []string{stateRunning, stateFail}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "start", Status: "Starting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. Cluster ID(s) of MongoDB instances to start.") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the operation to finish.") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getMongoDBIDList(ctx, []string{stateStopped}, *req.Region, *req.Zone, *req.ProjectId) + }) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/status.go b/products/umongodb/internal/umongodb/status.go new file mode 100644 index 0000000000..71033cdba4 --- /dev/null +++ b/products/umongodb/internal/umongodb/status.go @@ -0,0 +1,8 @@ +package umongodb + +// Terminal states for the Poller. UMongoDB uses "Stopped" (not UDB's "Shutoff"). +const ( + stateRunning = "Running" + stateStopped = "Stopped" + stateFail = "InitFailed" +) diff --git a/products/umongodb/internal/umongodb/stop.go b/products/umongodb/internal/umongodb/stop.go new file mode 100644 index 0000000000..4b204a4e48 --- /dev/null +++ b/products/umongodb/internal/umongodb/stop.go @@ -0,0 +1,66 @@ +package umongodb + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStop implements `umongodb stop`. +func newStop(ctx *cli.Context) *cobra.Command { + var async bool + var ids []string + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewStopUMongoDBClusterRequest() + + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop MongoDB instances", + Long: "Stop one or more running MongoDB instances.", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + req.ClusterId = sdk.String(id) + _, err := client.StopUMongoDBCluster(req) + if err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("%s[%s] is stopping", productName, id) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx, *req.Region, *req.Zone)).Spoll(id, text, []string{stateStopped, stateFail}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "stop", Status: "Stopping"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. Cluster ID(s) of MongoDB instances to stop.") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the operation to finish.") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getMongoDBIDList(ctx, []string{stateRunning}, *req.Region, *req.Zone, *req.ProjectId) + }) + + return cmd +} diff --git a/products/umongodb/product.go b/products/umongodb/product.go new file mode 100644 index 0000000000..55227b4e0f --- /dev/null +++ b/products/umongodb/product.go @@ -0,0 +1,21 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalumongodb "github.com/ucloud/ucloud-cli/products/umongodb/internal/umongodb" +) + +type product struct{} + +// New returns the umongodb product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "umongodb", Commands: []string{"umongodb"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalumongodb.NewCommand(ctx)} +} diff --git a/products/umongodb/product.yaml b/products/umongodb/product.yaml new file mode 100644 index 0000000000..233960f635 --- /dev/null +++ b/products/umongodb/product.yaml @@ -0,0 +1,8 @@ +# products/umongodb/product.yaml — umongodb 产品元数据(归属真源,owner 自治维护) +name: umongodb +owners: + - xingxingso + - jinfz12 +commands: + - umongodb +enabled: true diff --git a/products/umongodb/testdata/cmdtree.golden b/products/umongodb/testdata/cmdtree.golden new file mode 100644 index 0000000000..0504806674 --- /dev/null +++ b/products/umongodb/testdata/cmdtree.golden @@ -0,0 +1,95 @@ +ucloud umongodb use=umongodb short=Manipulate MongoDB on UCloud platform +ucloud umongodb create-replset use=create-replset short=Create a MongoDB replica set + flag=charge-type short= default=Month required= + flag=disk-space-gb short= default=0 required=true + flag=machine-type-id short= default= required=true + flag=name short= default= required=true + flag=node-count short= default=3 required= + flag=password short= default= required=true + flag=port short= default=27017 required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=subnet-id short= default= required= + flag=tag short= default= required= + flag=template-id short= default= required= + flag=version short= default= required=true + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud umongodb create-sharded use=create-sharded short=Create a MongoDB sharded cluster + flag=charge-type short= default=Month required= + flag=disk-space-gb short= default=0 required=true + flag=machine-type-id short= default= required=true + flag=mongos-machine-type-id short= default= required= + flag=mongos-node-count short= default=0 required= + flag=name short= default= required=true + flag=node-count short= default=0 required=true + flag=password short= default= required=true + flag=port short= default=27017 required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=shard-count short= default=0 required=true + flag=subnet-id short= default= required= + flag=tag short= default= required= + flag=template-id short= default= required= + flag=version short= default= required=true + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud umongodb delete-replset use=delete-replset short=Delete MongoDB replica set instances + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=skip-stop short= default=false required= + flag=umongodb-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud umongodb delete-sharded use=delete-sharded short=Delete MongoDB sharded cluster instances + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=skip-stop short= default=false required= + flag=umongodb-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud umongodb describe use=describe short=Show details of one MongoDB instance + flag=project-id short= default= required= + flag=region short= default= required= + flag=umongodb-id short= default= required=true + flag=zone short= default= required= +ucloud umongodb list use=list short=List MongoDB instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud umongodb list-machine-specs use=list-machine-specs short=List MongoDB machine specifications + flag=class-type short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud umongodb list-templates use=list-templates short=List MongoDB config templates + flag=cluster-type short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=version short= default= required= +ucloud umongodb list-versions use=list-versions short=List available MongoDB versions + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud umongodb restart use=restart short=Restart MongoDB instances + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=umongodb-id short= default=[] required=true + flag=zone short= default= required= +ucloud umongodb start use=start short=Start MongoDB instances + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=umongodb-id short= default=[] required=true + flag=zone short= default= required= +ucloud umongodb stop use=stop short=Stop MongoDB instances + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=umongodb-id short= default=[] required=true + flag=zone short= default= required= diff --git a/products/umongodb/testdata/completion.golden b/products/umongodb/testdata/completion.golden new file mode 100644 index 0000000000..8e98411a3f --- /dev/null +++ b/products/umongodb/testdata/completion.golden @@ -0,0 +1,54 @@ +ucloud umongodb create-replset charge-type static Dynamic,Month,Trial,Year +ucloud umongodb create-replset machine-type-id dynamic +ucloud umongodb create-replset node-count static 3,5,7 +ucloud umongodb create-replset project-id dynamic +ucloud umongodb create-replset region dynamic +ucloud umongodb create-replset subnet-id dynamic +ucloud umongodb create-replset template-id dynamic +ucloud umongodb create-replset version dynamic +ucloud umongodb create-replset vpc-id dynamic +ucloud umongodb create-replset zone dynamic +ucloud umongodb create-sharded charge-type static Dynamic,Month,Trial,Year +ucloud umongodb create-sharded machine-type-id dynamic +ucloud umongodb create-sharded project-id dynamic +ucloud umongodb create-sharded region dynamic +ucloud umongodb create-sharded subnet-id dynamic +ucloud umongodb create-sharded template-id dynamic +ucloud umongodb create-sharded version dynamic +ucloud umongodb create-sharded vpc-id dynamic +ucloud umongodb create-sharded zone dynamic +ucloud umongodb delete-replset project-id dynamic +ucloud umongodb delete-replset region dynamic +ucloud umongodb delete-replset umongodb-id dynamic +ucloud umongodb delete-replset zone dynamic +ucloud umongodb delete-sharded project-id dynamic +ucloud umongodb delete-sharded region dynamic +ucloud umongodb delete-sharded umongodb-id dynamic +ucloud umongodb delete-sharded zone dynamic +ucloud umongodb describe project-id dynamic +ucloud umongodb describe region dynamic +ucloud umongodb describe umongodb-id dynamic +ucloud umongodb describe zone dynamic +ucloud umongodb list project-id dynamic +ucloud umongodb list region dynamic +ucloud umongodb list zone dynamic +ucloud umongodb list-machine-specs project-id dynamic +ucloud umongodb list-machine-specs region dynamic +ucloud umongodb list-machine-specs zone dynamic +ucloud umongodb list-templates project-id dynamic +ucloud umongodb list-templates region dynamic +ucloud umongodb list-versions project-id dynamic +ucloud umongodb list-versions region dynamic +ucloud umongodb list-versions zone dynamic +ucloud umongodb restart project-id dynamic +ucloud umongodb restart region dynamic +ucloud umongodb restart umongodb-id dynamic +ucloud umongodb restart zone dynamic +ucloud umongodb start project-id dynamic +ucloud umongodb start region dynamic +ucloud umongodb start umongodb-id dynamic +ucloud umongodb start zone dynamic +ucloud umongodb stop project-id dynamic +ucloud umongodb stop region dynamic +ucloud umongodb stop umongodb-id dynamic +ucloud umongodb stop zone dynamic diff --git a/products/upfs/internal/upfs/cmd.go b/products/upfs/internal/upfs/cmd.go new file mode 100644 index 0000000000..cb4c800aee --- /dev/null +++ b/products/upfs/internal/upfs/cmd.go @@ -0,0 +1,20 @@ +package upfs + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `upfs` root command and mounts the subcommands. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "upfs", + Short: "Manage UPFS (UCloud Parallel File Storage) volumes", + Long: "Manage UPFS (UCloud Parallel File Storage) volumes", + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newDelete(ctx)) + return cmd +} diff --git a/products/upfs/internal/upfs/create.go b/products/upfs/internal/upfs/create.go new file mode 100644 index 0000000000..1bc20a62c1 --- /dev/null +++ b/products/upfs/internal/upfs/create.go @@ -0,0 +1,56 @@ +package upfs + +import ( + "fmt" + + "github.com/spf13/cobra" + + upfssdk "github.com/ucloud/ucloud-sdk-go/services/upfs" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud upfs create +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, upfssdk.NewClient) + req := client.NewCreateUPFSVolumeRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a UPFS volume", + Long: "Create a UPFS volume", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + resp, err := client.CreateUPFSVolume(req) + if err != nil { + ctx.HandleError(err) + return + } + + text := fmt.Sprintf("upfs:%v created", resp.VolumeId) + fmt.Fprintln(w, text) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.VolumeId, Action: "create", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.VolumeName = flags.String("name", "", "Required. Name of the UPFS volume to create") + req.Size = flags.Int("size-gb", 500, "Required. Size of the UPFS volume. Unit: GB, must be a multiple of 100, minimum 500") + req.ProtocolType = flags.String("protocol-type", "POSIX", "Optional. Protocol type, currently only supports POSIX") + req.ChargeType = flags.String("charge-type", "Dynamic", "Optional. 'Year', pay yearly; 'Month', pay monthly; 'Dynamic', pay hourly") + req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months") + req.Tag = flags.String("group", "Default", "Optional. Business group") + req.Remark = flags.String("remark", "", "Optional. Remark") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic", "Trial") + command.SetFlagValues(cmd, "protocol-type", "POSIX") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("size-gb") + + return cmd +} diff --git a/products/upfs/internal/upfs/delete.go b/products/upfs/internal/upfs/delete.go new file mode 100644 index 0000000000..bdad553f38 --- /dev/null +++ b/products/upfs/internal/upfs/delete.go @@ -0,0 +1,59 @@ +package upfs + +import ( + "fmt" + + "github.com/spf13/cobra" + + upfssdk "github.com/ucloud/ucloud-sdk-go/services/upfs" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete ucloud upfs delete +func newDelete(ctx *cli.Context) *cobra.Command { + var yes *bool + var volumeIDs *[]string + client := cli.NewServiceClient(ctx, upfssdk.NewClient) + req := client.NewRemoveUPFSVolumeRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete UPFS volume(s)", + Long: "Delete UPFS volume(s)", + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(*yes, "Are you sure to delete UPFS volume(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, id := range *volumeIDs { + id := ctx.PickResourceID(id) + req.VolumeId = &id + _, err := client.RemoveUPFSVolume(req) + if err != nil { + ctx.HandleError(err) + continue + } else { + fmt.Fprintf(w, "upfs[%s] deleted\n", *req.VolumeId) + results = append(results, cli.OpResultRow{ResourceID: *req.VolumeId, Action: "delete", Status: "Deleted"}) + } + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + volumeIDs = flags.StringSlice("volume-id", nil, "Required. The Resource ID of UPFS volumes to delete") + yes = flags.BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + + ctx.BindCommonParams(cmd, req) + + cmd.MarkFlagRequired("volume-id") + + return cmd +} diff --git a/products/upfs/internal/upfs/describe.go b/products/upfs/internal/upfs/describe.go new file mode 100644 index 0000000000..fc9f779672 --- /dev/null +++ b/products/upfs/internal/upfs/describe.go @@ -0,0 +1,82 @@ +package upfs + +import ( + "fmt" + + "github.com/spf13/cobra" + + upfssdk "github.com/ucloud/ucloud-sdk-go/services/upfs" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDescribe ucloud upfs describe +func newDescribe(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, upfssdk.NewClient) + req := client.NewDescribeUPFSVolumeRequest() + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe UPFS volume(s)", + Long: "Describe UPFS volume(s)", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.DescribeUPFSVolume(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []VolumeRow{} + for _, vol := range resp.DataSet { + row := VolumeRow{ + ResourceID: vol.VolumeId, + Name: vol.VolumeName, + Group: vol.Tag, + Size: fmt.Sprintf("%dGB", vol.Size), + ProtocolType: vol.ProtocolType, + MountAddress: vol.MountAddress, + ChargeType: vol.ChargeType, + State: vol.IsExpired, + CreationTime: common.FormatDate(vol.CreateTime), + Expiration: common.FormatDate(vol.ExpiredTime), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.VolumeId = flags.String("volume-id", "", "Optional. Resource ID of the UPFS volume") + req.Limit = flags.Int("limit", 50, "Optional. Limit") + req.Offset = flags.Int("offset", 0, "Optional. Offset") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + return cmd +} + +// describeUpfsByID returns the poller's describe func, closing over ctx so it +// can build an authed upfs client. +func describeUpfsByID(ctx *cli.Context) func(volumeID string, commonBase *request.CommonBase) (interface{}, error) { + return func(volumeID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, upfssdk.NewClient) + req := client.NewDescribeUPFSVolumeRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.VolumeId = &volumeID + limit := 50 + req.Limit = &limit + resp, err := client.DescribeUPFSVolume(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) < 1 { + return nil, nil + } + return &resp.DataSet[0], nil + } +} diff --git a/products/upfs/internal/upfs/rows.go b/products/upfs/internal/upfs/rows.go new file mode 100644 index 0000000000..0895e07812 --- /dev/null +++ b/products/upfs/internal/upfs/rows.go @@ -0,0 +1,15 @@ +package upfs + +// VolumeRow represents a single row in the upfs volume list output. +type VolumeRow struct { + ResourceID string + Name string + Group string + Size string + ProtocolType string + MountAddress string + ChargeType string + State string + CreationTime string + Expiration string +} diff --git a/products/upfs/internal/upfs/status.go b/products/upfs/internal/upfs/status.go new file mode 100644 index 0000000000..d15d9976a3 --- /dev/null +++ b/products/upfs/internal/upfs/status.go @@ -0,0 +1,8 @@ +package upfs + +// UPFS-domain state constants. +const ( + VOLUME_CREATING = "Creating" + VOLUME_AVAILABLE = "Available" + VOLUME_FAILED = "Failed" +) diff --git a/products/upfs/product.go b/products/upfs/product.go new file mode 100644 index 0000000000..36e6ed4dc5 --- /dev/null +++ b/products/upfs/product.go @@ -0,0 +1,21 @@ +package upfs + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalupfs "github.com/ucloud/ucloud-cli/products/upfs/internal/upfs" +) + +type product struct{} + +// New returns the upfs product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "upfs", Commands: []string{"upfs"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalupfs.NewCommand(ctx)} +} diff --git a/products/upfs/product.yaml b/products/upfs/product.yaml new file mode 100644 index 0000000000..c61f9f05e1 --- /dev/null +++ b/products/upfs/product.yaml @@ -0,0 +1,7 @@ +# products/upfs/product.yaml — upfs 产品元数据(归属真源,owner 自治维护) +name: upfs +owners: + - pearlinpan +commands: + - upfs +enabled: true diff --git a/products/upfs/testdata/cmdtree.golden b/products/upfs/testdata/cmdtree.golden new file mode 100644 index 0000000000..0267eaba7f --- /dev/null +++ b/products/upfs/testdata/cmdtree.golden @@ -0,0 +1,25 @@ +ucloud upfs use=upfs short=Manage UPFS (UCloud Parallel File Storage) volumes +ucloud upfs create use=create short=Create a UPFS volume + flag=charge-type short= default=Dynamic required= + flag=group short= default=Default required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=protocol-type short= default=POSIX required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=remark short= default= required= + flag=size-gb short= default=500 required=true + flag=zone short= default= required= +ucloud upfs delete use=delete short=Delete UPFS volume(s) + flag=project-id short= default= required= + flag=region short= default= required= + flag=volume-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud upfs describe use=describe short=Describe UPFS volume(s) + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=volume-id short= default= required= + flag=zone short= default= required= diff --git a/products/upfs/testdata/completion.golden b/products/upfs/testdata/completion.golden new file mode 100644 index 0000000000..6740e8038b --- /dev/null +++ b/products/upfs/testdata/completion.golden @@ -0,0 +1,11 @@ +ucloud upfs create charge-type static Dynamic,Month,Trial,Year +ucloud upfs create project-id dynamic +ucloud upfs create protocol-type static POSIX +ucloud upfs create region dynamic +ucloud upfs create zone dynamic +ucloud upfs delete project-id dynamic +ucloud upfs delete region dynamic +ucloud upfs delete zone dynamic +ucloud upfs describe project-id dynamic +ucloud upfs describe region dynamic +ucloud upfs describe zone dynamic diff --git a/products/uphost/internal/uphost/cmd.go b/products/uphost/internal/uphost/cmd.go new file mode 100644 index 0000000000..719d3ba21a --- /dev/null +++ b/products/uphost/internal/uphost/cmd.go @@ -0,0 +1,21 @@ +package uphost + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `uphost` root command (list-only). +// Mirrors cmd/uphost.go NewCmdUPHost + NewCmdUPHostList. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "uphost", + Short: "List UPHost instances", + Long: `List UPHost instances`, + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + + return cmd +} diff --git a/products/uphost/internal/uphost/list.go b/products/uphost/internal/uphost/list.go new file mode 100644 index 0000000000..c1057d802d --- /dev/null +++ b/products/uphost/internal/uphost/list.go @@ -0,0 +1,66 @@ +package uphost + +import ( + "fmt" + + "github.com/spf13/cobra" + + uphostsdk "github.com/ucloud/ucloud-sdk-go/services/uphost" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList ucloud uphost list +func newList(ctx *cli.Context) *cobra.Command { + ids := []string{} + client := cli.NewServiceClient(ctx, uphostsdk.NewClient) + req := client.NewDescribePHostRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List UPHost instances", + Long: "List UPHost instances", + Run: func(c *cobra.Command, args []string) { + req.PHostId = ids + resp, err := client.DescribePHost(req) + if err != nil { + ctx.HandleError(err) + return + } + list := make([]uphostRow, 0) + for _, ins := range resp.PHostSet { + row := uphostRow{ + ResourceID: ins.PHostId, + Name: ins.Name, + Config: fmt.Sprintf("core:%d memory:%dG", ins.CPUSet.CoreCount, ins.Memory/1024), + Group: ins.Tag, + HostType: ins.PHostType, + Status: ins.PMStatus, + Image: ins.ImageName, + } + for _, ip := range ins.IPSet { + if ip.OperatorName == "Private" { + row.PrivateIP = ip.IPAddr + } else { + row.PublicIP = ip.IPAddr + " " + ip.OperatorName + } + } + for _, disk := range ins.DiskSet { + if disk.Name == "data" { + row.Config += fmt.Sprintf(" data-disk:%dG %s", disk.Space, disk.Type) + } + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + ctx.BindRegion(cmd, req) + ctx.BindZoneEmpty(cmd, req) + ctx.BindProjectID(cmd, req) + ctx.BindOffset(cmd, req) + ctx.BindLimit(cmd, req) + flags.StringSliceVar(&ids, "uphost-id", nil, "Optional. Resource ID of uphost instances. List those specified uphost instances") + + return cmd +} diff --git a/products/uphost/internal/uphost/rows.go b/products/uphost/internal/uphost/rows.go new file mode 100644 index 0000000000..e073e7ab6c --- /dev/null +++ b/products/uphost/internal/uphost/rows.go @@ -0,0 +1,14 @@ +package uphost + +// uphostRow 表格行 +type uphostRow struct { + ResourceID string + Name string + PrivateIP string + PublicIP string + Config string + Image string + HostType string + Status string + Group string +} diff --git a/products/uphost/product.go b/products/uphost/product.go new file mode 100644 index 0000000000..12f3d4c689 --- /dev/null +++ b/products/uphost/product.go @@ -0,0 +1,21 @@ +package uphost + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internaluphost "github.com/ucloud/ucloud-cli/products/uphost/internal/uphost" +) + +type product struct{} + +// New returns the uphost product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "uphost", Commands: []string{"uphost"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internaluphost.NewCommand(ctx)} +} diff --git a/products/uphost/product.yaml b/products/uphost/product.yaml new file mode 100644 index 0000000000..d3998714da --- /dev/null +++ b/products/uphost/product.yaml @@ -0,0 +1,7 @@ +# products/uphost/product.yaml — uphost 产品元数据(归属真源,owner 自治维护) +name: uphost +owners: + - Episkey-G +commands: + - uphost +enabled: true diff --git a/products/uphost/testdata/cmdtree.golden b/products/uphost/testdata/cmdtree.golden new file mode 100644 index 0000000000..ab8669a524 --- /dev/null +++ b/products/uphost/testdata/cmdtree.golden @@ -0,0 +1,8 @@ +ucloud uphost use=uphost short=List UPHost instances +ucloud uphost list use=list short=List UPHost instances + flag=limit short= default=100 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=uphost-id short= default=[] required= + flag=zone short= default= required= diff --git a/products/uphost/testdata/completion.golden b/products/uphost/testdata/completion.golden new file mode 100644 index 0000000000..8985cc8df4 --- /dev/null +++ b/products/uphost/testdata/completion.golden @@ -0,0 +1,3 @@ +ucloud uphost list project-id dynamic +ucloud uphost list region dynamic +ucloud uphost list zone dynamic diff --git a/products/urocketmq/internal/cmd.go b/products/urocketmq/internal/cmd.go new file mode 100644 index 0000000000..16c184b8e6 --- /dev/null +++ b/products/urocketmq/internal/cmd.go @@ -0,0 +1,27 @@ +package internal + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/group" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/message" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/token" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/topic" +) + +// NewCommand builds the top-level urocketmq command. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "urocketmq", + Short: "Manage URocketMQ instances, topics, groups, tokens and messages", + Args: cobra.NoArgs, + } + cmd.AddCommand(service.NewCommand(ctx)) + cmd.AddCommand(topic.NewCommand(ctx)) + cmd.AddCommand(group.NewCommand(ctx)) + cmd.AddCommand(token.NewCommand(ctx)) + cmd.AddCommand(message.NewCommand(ctx)) + return cmd +} diff --git a/products/urocketmq/internal/group/cmd.go b/products/urocketmq/internal/group/cmd.go new file mode 100644 index 0000000000..0c91499b46 --- /dev/null +++ b/products/urocketmq/internal/group/cmd.go @@ -0,0 +1,21 @@ +package group + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `urocketmq group` resource-group command. Action subcommands are appended +// at the end in subsequent batches (order is fixed, golden depends). +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "group", + Short: "Manage URocketMQ consumer groups", + Args: cobra.NoArgs, + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newList(ctx)) + return cmd +} diff --git a/products/urocketmq/internal/group/completion.go b/products/urocketmq/internal/group/completion.go new file mode 100644 index 0000000000..718509579d --- /dev/null +++ b/products/urocketmq/internal/group/completion.go @@ -0,0 +1,33 @@ +package group + +import ( + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" +) + +// GroupList returns group names for the given service. Exported for self-use (delete --group-name completion) +// and reused by sibling list; cross-group one-way imports service, same-group calls directly. +func GroupList(ctx *cli.Context, projectID, region, serviceID string) []string { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewListURocketMQGroupRequest() + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + req.ServiceId = sdk.String(serviceID) + names := make([]string, 0) + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.ListURocketMQGroup(req) + if err != nil { + return nil + } + for _, g := range resp.GroupList { + names = append(names, g.GroupName) + } + if offset+limit >= resp.TotalCount { + break + } + } + return names +} diff --git a/products/urocketmq/internal/group/create.go b/products/urocketmq/internal/group/create.go new file mode 100644 index 0000000000..065be2071c --- /dev/null +++ b/products/urocketmq/internal/group/create.go @@ -0,0 +1,50 @@ +package group + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" +) + +// newCreate ucloud urocketmq group create +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewCreateURocketMQGroupRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a consumer group", + Long: "Create a consumer group", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := client.CreateURocketMQGroup(req) + if err != nil { + return err + } + ctx.EmitResult(cli.OpResultRow{ + ResourceID: resp.GroupId, + Action: "create", + Status: "Created", + }) + return nil + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.Name = cmd.Flags().String("name", "", "Required. Consumer group name, 1-36 characters, supports letters, digits, - and _") + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID") + req.Remark = cmd.Flags().String("remark", "", "Optional. Group remark") + + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("service-id") + + return cmd +} diff --git a/products/urocketmq/internal/group/delete.go b/products/urocketmq/internal/group/delete.go new file mode 100644 index 0000000000..a05bc31cf8 --- /dev/null +++ b/products/urocketmq/internal/group/delete.go @@ -0,0 +1,63 @@ +package group + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" +) + +// newDelete ucloud urocketmq group delete +func newDelete(ctx *cli.Context) *cobra.Command { + var yes bool + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewDeleteURocketMQGroupRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a consumer group", + Long: "Delete a consumer group", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure you want to delete group %q?", *req.GroupName)) + if err != nil { + return err + } + if !ok { + return nil + } + _, err = client.DeleteURocketMQGroup(req) + if err != nil { + return err + } + ctx.EmitResult(cli.OpResultRow{ + ResourceID: *req.GroupName, + Action: "delete", + Status: "Deleted", + }) + return nil + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID") + req.GroupName = cmd.Flags().String("group-name", "", "Required. Consumer group name") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.") + + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "group-name", func() []string { + return GroupList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + cmd.MarkFlagRequired("service-id") + cmd.MarkFlagRequired("group-name") + + return cmd +} diff --git a/products/urocketmq/internal/group/list.go b/products/urocketmq/internal/group/list.go new file mode 100644 index 0000000000..21e61b2fc5 --- /dev/null +++ b/products/urocketmq/internal/group/list.go @@ -0,0 +1,74 @@ +package group + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" +) + +// newList ucloud urocketmq group list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewListURocketMQGroupRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List URocketMQ consumer groups", + Long: "List URocketMQ consumer groups", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.ListURocketMQGroup(req) + if err != nil { + ctx.HandleError(err) + return + } + listGroup(ctx, resp.GroupList) + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID") + req.Limit = cmd.Flags().Int("limit", 50, "Optional. Limit default 50") + req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0") + + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("service-id") + + return cmd +} + +// listGroup renders the group list. json/yaml emits full-field groupRow; table mode uses groupRowDefault. +func listGroup(ctx *cli.Context, groups []urocketmq.GroupBaseInfo) { + list := make([]groupRow, 0, len(groups)) + for _, g := range groups { + list = append(list, groupRow{ + GroupName: g.GroupName, + Id: g.Id, + Remark: g.Remark, + CreateTime: g.CreateTime, + }) + } + + if ctx.Format() != cli.OutputTable { + ctx.PrintList(list) + return + } + + rows := make([]groupRowDefault, 0, len(list)) + for _, r := range list { + rows = append(rows, groupRowDefault{ + GroupName: r.GroupName, + Id: r.Id, + Remark: r.Remark, + CreateTime: common.FormatDate(r.CreateTime), + }) + } + ctx.PrintList(rows) +} diff --git a/products/urocketmq/internal/group/rows.go b/products/urocketmq/internal/group/rows.go new file mode 100644 index 0000000000..87e8f77f79 --- /dev/null +++ b/products/urocketmq/internal/group/rows.go @@ -0,0 +1,17 @@ +package group + +// groupRow is the full-field row (json/yaml mode). +type groupRow struct { + GroupName string + Id string + Remark string + CreateTime int +} + +// groupRowDefault is the default curated columns in table mode: GroupName, Id, Remark, CreateTime. +type groupRowDefault struct { + GroupName string + Id string + Remark string + CreateTime string +} diff --git a/products/urocketmq/internal/message/cmd.go b/products/urocketmq/internal/message/cmd.go new file mode 100644 index 0000000000..229c340790 --- /dev/null +++ b/products/urocketmq/internal/message/cmd.go @@ -0,0 +1,21 @@ +package message + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `urocketmq message` resource-group command. Action subcommands are appended +// at the end in subsequent batches (order is fixed, golden depends). +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "message", + Short: "Query URocketMQ messages", + Args: cobra.NoArgs, + } + cmd.AddCommand(newQueryByID(ctx)) + cmd.AddCommand(newQueryByKey(ctx)) + cmd.AddCommand(newQueryByTopic(ctx)) + return cmd +} diff --git a/products/urocketmq/internal/message/query_by_id.go b/products/urocketmq/internal/message/query_by_id.go new file mode 100644 index 0000000000..95728663b4 --- /dev/null +++ b/products/urocketmq/internal/message/query_by_id.go @@ -0,0 +1,52 @@ +package message + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/topic" +) + +// newQueryByID ucloud urocketmq message query-by-id +func newQueryByID(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewQueryURocketMQMessageByIDRequest() + cmd := &cobra.Command{ + Use: "query-by-id", + Short: "Query a message by ID", + Long: `Query a message by ID, returns the full message detail including body.`, + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.QueryURocketMQMessageByID(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := toMessageDetailRows(resp.MessageList) + printMessageRows(ctx, rows) + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + + req.MsgId = cmd.Flags().String("msg-id", "", "Required. Message ID to query") + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID") + req.TopicName = cmd.Flags().String("topic-name", "", "Required. Topic name") + _ = cmd.MarkFlagRequired("msg-id") + _ = cmd.MarkFlagRequired("service-id") + _ = cmd.MarkFlagRequired("topic-name") + + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "topic-name", func() []string { + return topic.TopicList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + + return cmd +} diff --git a/products/urocketmq/internal/message/query_by_key.go b/products/urocketmq/internal/message/query_by_key.go new file mode 100644 index 0000000000..c36caeeae4 --- /dev/null +++ b/products/urocketmq/internal/message/query_by_key.go @@ -0,0 +1,72 @@ +package message + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/topic" +) + +// newQueryByKey ucloud urocketmq message query-by-key +func newQueryByKey(ctx *cli.Context) *cobra.Command { + var idOnly bool + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewQueryURocketMQMessageByKeyRequest() + cmd := &cobra.Command{ + Use: "query-by-key", + Short: "Query messages by key", + Long: `Query messages by a custom message key under a specific topic.`, + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.QueryURocketMQMessageByKey(req) + if err != nil { + ctx.HandleError(err) + return + } + if idOnly { + listMessageID(ctx, resp.MessageList) + return + } + rows := toMessageBaseInfoRows(resp.MessageList) + printMessageRows(ctx, rows) + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + + req.Key = cmd.Flags().String("key", "", "Required. Message key to query") + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID") + req.TopicName = cmd.Flags().String("topic-name", "", "Required. Topic name") + cmd.Flags().BoolVar(&idOnly, "id-only", false, "Optional. Only display message IDs") + _ = cmd.MarkFlagRequired("key") + _ = cmd.MarkFlagRequired("service-id") + _ = cmd.MarkFlagRequired("topic-name") + + command.SetFlagValues(cmd, "id-only", "true", "false") + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "topic-name", func() []string { + return topic.TopicList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + + return cmd +} + +// listMessageID outputs a message ID list (--id-only mode), writing to ctx.Out() to avoid capture by +// ProgressWriter which would yield empty output in non-TTY mode. +func listMessageID(ctx *cli.Context, infos []urocketmq.MessageBaseInfo) { + ids := make([]string, 0, len(infos)) + for _, info := range infos { + ids = append(ids, info.MsgId) + } + fmt.Fprintln(ctx.Out(), strings.Join(ids, ",")) +} diff --git a/products/urocketmq/internal/message/query_by_topic.go b/products/urocketmq/internal/message/query_by_topic.go new file mode 100644 index 0000000000..57976f0783 --- /dev/null +++ b/products/urocketmq/internal/message/query_by_topic.go @@ -0,0 +1,74 @@ +package message + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + urocketmq "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/topic" +) + +// newQueryByTopic ucloud urocketmq message query-by-topic +func newQueryByTopic(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewQueryURocketMQMessageByTopicRequest() + cmd := &cobra.Command{ + Use: "query-by-topic", + Short: "Query messages by topic and time range", + Long: `Query messages by topic and time range. Time format: {2006-01-02 15:04:05}`, + Run: func(cmd *cobra.Command, args []string) { + beginStr, _ := cmd.Flags().GetString("begin") + endStr, _ := cmd.Flags().GetString("end") + const layout = "2006-01-02 15:04:05" + beginTime, err := time.ParseInLocation(layout, beginStr, time.Local) + if err != nil { + ctx.HandleError(fmt.Errorf("invalid begin time %q, expected format: 2006-01-02 15:04:05", beginStr)) + return + } + endTime, err := time.ParseInLocation(layout, endStr, time.Local) + if err != nil { + ctx.HandleError(fmt.Errorf("invalid end time %q, expected format: 2006-01-02 15:04:05", endStr)) + return + } + beginUnix := int(beginTime.Unix()) + endUnix := int(endTime.Unix()) + req.Begin = &beginUnix + req.End = &endUnix + resp, err := client.QueryURocketMQMessageByTopic(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := toMessageBaseInfoRows(resp.MessageList) + printMessageRows(ctx, rows) + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + + cmd.Flags().String("begin", "", "Required. Begin time (YYYY-MM-DD HH:MM:SS)") + cmd.Flags().String("end", "", "Required. End time (YYYY-MM-DD HH:MM:SS)") + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID") + req.TopicName = cmd.Flags().String("topic-name", "", "Required. Topic name") + _ = cmd.MarkFlagRequired("begin") + _ = cmd.MarkFlagRequired("end") + _ = cmd.MarkFlagRequired("service-id") + _ = cmd.MarkFlagRequired("topic-name") + + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "topic-name", func() []string { + return topic.TopicList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + + return cmd +} diff --git a/products/urocketmq/internal/message/rows.go b/products/urocketmq/internal/message/rows.go new file mode 100644 index 0000000000..c718878062 --- /dev/null +++ b/products/urocketmq/internal/message/rows.go @@ -0,0 +1,82 @@ +package message + +import ( + "github.com/ucloud/ucloud-cli/internal/common" + urocketmq "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// messageRow is the full-field row (json/yaml mode). Mapped from SDK MessageDetail/MessageBaseInfo. +// query-by-key and query-by-topic return MessageBaseInfo (no Body); Body field is empty. +type messageRow struct { + MsgId string + Key string + Tag string + StoreTime string + Topic string + Body string +} + +// messageRowDefault is the default curated columns in table mode. +type messageRowDefault struct { + MsgId string + Key string + Tag string + StoreTime string + Topic string +} + +// formatStoreTime formats a Unix millisecond timestamp as a date-time string. +func formatStoreTime(ms int) string { + if ms == 0 { + return "" + } + return common.FormatDateTime(ms / 1000) +} + +// toMessageDetailRows converts a SDK MessageDetail slice to a messageRow slice. +func toMessageDetailRows(details []urocketmq.MessageDetail) []messageRow { + rows := make([]messageRow, 0, len(details)) + for _, d := range details { + rows = append(rows, messageRow{ + MsgId: d.MsgId, + Key: d.Properties.KEYS, + Tag: d.Properties.TAGS, + StoreTime: formatStoreTime(d.StoreTimestamp), + Topic: d.Topic, + Body: d.MessageBody, + }) + } + return rows +} + +// toMessageBaseInfoRows converts a SDK MessageBaseInfo slice to a messageRow slice. +func toMessageBaseInfoRows(infos []urocketmq.MessageBaseInfo) []messageRow { + rows := make([]messageRow, 0, len(infos)) + for _, info := range infos { + rows = append(rows, messageRow{ + MsgId: info.MsgId, + Key: info.Properties.KEYS, + Tag: info.Properties.TAGS, + StoreTime: formatStoreTime(info.StoreTimestamp), + Topic: info.Topic, + }) + } + return rows +} + +// printMessageRows routes output by ctx.Format(). json/yaml prints full fields, table prints curated columns. +func printMessageRows(ctx *cli.Context, rows []messageRow) { + if ctx.Format() != cli.OutputTable { + ctx.PrintList(rows) + return + } + defaultRows := make([]messageRowDefault, 0, len(rows)) + for _, r := range rows { + defaultRows = append(defaultRows, messageRowDefault{ + MsgId: r.MsgId, Key: r.Key, Tag: r.Tag, StoreTime: r.StoreTime, Topic: r.Topic, + }) + } + ctx.PrintList(defaultRows) +} diff --git a/products/urocketmq/internal/service/cmd.go b/products/urocketmq/internal/service/cmd.go new file mode 100644 index 0000000000..fe9040f6a8 --- /dev/null +++ b/products/urocketmq/internal/service/cmd.go @@ -0,0 +1,25 @@ +package service + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `urocketmq service` resource-group command. Action subcommands are appended +// at the end (order is fixed, golden depends); +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "service", + Short: "Manage URocketMQ service instances", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newGet(ctx)) + cmd.AddCommand(newUpdateName(ctx)) + cmd.AddCommand(newUpdateRemark(ctx)) + cmd.AddCommand(newPrice(ctx)) + return cmd +} diff --git a/products/urocketmq/internal/service/completion.go b/products/urocketmq/internal/service/completion.go new file mode 100644 index 0000000000..4842893193 --- /dev/null +++ b/products/urocketmq/internal/service/completion.go @@ -0,0 +1,76 @@ +package service + +import ( + "fmt" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" +) + +// ServiceList returns ServiceIds for the given project/region. Exported for --service-id completion +// reuse by topic/group/token/message (one-way import of service package to avoid circular deps). +func ServiceList(ctx *cli.Context, projectID, region string) []string { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewListURocketMQServiceRequest() + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + req.Limit = sdk.Int(1000) + req.Offset = sdk.Int(0) + resp, err := client.ListURocketMQService(req) + if err != nil { + return nil + } + ids := make([]string, 0, len(resp.ServiceList)) + for _, s := range resp.ServiceList { + ids = append(ids, s.ServiceId) + } + return ids +} + +// getAllVPCIdNames returns "VPCId/Name" completion candidates (--vpc-id completion). See uhost completion.go. +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil + } + list := make([]string, 0, len(resp.DataSet)) + for _, v := range resp.DataSet { + list = append(list, fmt.Sprintf("%s/%s", v.VPCId, v.Name)) + } + return list +} + +// getAllSubnetIDNames returns "SubnetId/Name" completion candidates (--subnet-id completion). See uhost completion.go. +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + if vpcID != "" { + req.VPCId = sdk.String(cli.PickResourceID(vpcID)) + } + subnets := make([]vpc.SubnetInfo, 0) + for limit, offset := 50, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.DescribeSubnet(req) + if err != nil { + return nil + } + subnets = append(subnets, resp.DataSet...) + if limit+offset >= resp.TotalCount { + break + } + } + list := make([]string, 0, len(subnets)) + for _, s := range subnets { + list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return list +} diff --git a/products/urocketmq/internal/service/create.go b/products/urocketmq/internal/service/create.go new file mode 100644 index 0000000000..17dd796222 --- /dev/null +++ b/products/urocketmq/internal/service/create.go @@ -0,0 +1,119 @@ +package service + +import ( + "fmt" + + "github.com/spf13/cobra" + + urocketmq "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud urocketmq service create +func newCreate(ctx *cli.Context) *cobra.Command { + var async bool + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewCreateURocketMQServiceRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a URocketMQ service instance", + Long: "Create a URocketMQ service instance", + RunE: func(cmd *cobra.Command, args []string) error { + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId)) + if *req.Storage <= 0 || *req.Storage%100 != 0 { + return fmt.Errorf("--storage-gb must be a positive multiple of 100") + } + if sdk.StringValue(req.ChargeType) == "Dynamic" { + req.Quantity = sdk.Int(0) + } + resp, err := client.CreateURocketMQService(req) + if err != nil { + return err + } + + serviceID := resp.ServiceId + prog := ctx.NewProgress() + block := prog.NewBlock() + ctx.EmitResult(cli.OpResultRow{ResourceID: serviceID, Action: "create", Status: "Initializing"}) + + text := fmt.Sprintf("the service[%s] is initializing", serviceID) + if async { + block.Append(text) + } else { + prog.Sspoll(describeServiceByID(ctx), serviceID, text, + []string{SERVICE_AVAILABLE, SERVICE_CREATE_FAILED}, block, &req.CommonBase) + } + return nil + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ChargeType = flags.String("charge-type", "Month", "Required. Charge type. Enum: Year, Month, Dynamic") + req.Edition = flags.String("edition", "Enterprise", "Required. Edition. Unique value: Enterprise") + req.Mode = flags.String("mode", "PrivateNet", "Required. Network mode. Unique value: PrivateNet") + req.Name = flags.String("name", "", "Required. Service name. Regex: ^[a-zA-Z0-9-_]{1,36}$") + req.PublicVersion = flags.String("public-version", "", "Cluster version. Options vary by region, see doc for supported values: https://github.com/UCloudDoc-Team/rocketmq/blob/master/price/index.md, e.g. v4, v5 (each region only support one version)") + req.Storage = flags.Int("storage-gb", 0, "Required. Storage space in GB. Check the doc first to determine available values: https://github.com/UCloudDoc-Team/rocketmq/blob/master/price/index.md") + req.SubnetId = flags.String("subnet-id", "", "Required. Subnet ID. Default to current region's default subnet") + req.Tps = flags.String("tps", "", "Required. Transactions per second. Enum: 10000, 20000, 50000, 100000, 200000. Note: v4 supports 20000, 50000, 100000, 200000; v5 currently supports only 10000, 20000.") + req.VPCId = flags.String("vpc-id", "", "Required. VPC ID. Default to current region's default VPC") + req.FileReservedTime = flags.String("file-reserved-time", "3", "Optional. Message reserved time in days, default 3") + req.Quantity = flags.Int("quantity", 1, "Optional. Purchase duration in months. Month: 1-9(month), 0=until end of current month; Dynamic: ignore; Year: use --quantity as years") + req.Remark = flags.String("remark", "", "Optional. Remark") + req.Tag = flags.String("group", "Default", "Optional. Business group tag") + + flags.BoolVar(&async, "async", false, "Optional. Do not wait for the long-running operation to finish.") + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + + command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic") + command.SetFlagValues(cmd, "edition", "Enterprise") + command.SetFlagValues(cmd, "mode", "PrivateNet") + command.SetFlagValues(cmd, "tps", "10000", "20000", "50000", "100000", "200000") + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, req.GetProjectId(), req.GetRegion()) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, *req.VPCId, req.GetProjectId(), req.GetRegion()) + }) + + cmd.MarkFlagRequired("charge-type") + cmd.MarkFlagRequired("edition") + cmd.MarkFlagRequired("mode") + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("storage-gb") + cmd.MarkFlagRequired("subnet-id") + cmd.MarkFlagRequired("tps") + cmd.MarkFlagRequired("vpc-id") + + return cmd +} + +// describeServiceByID returns the describe function used by Sspoll, polling via GetURocketMQService +func describeServiceByID(ctx *cli.Context) func(string, *request.CommonBase) (interface{}, error) { + return func(id string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewGetURocketMQServiceRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.ServiceId = sdk.String(id) + resp, err := client.GetURocketMQService(req) + if err != nil { + return nil, err + } + if len(resp.ServiceList) < 1 { + return nil, nil + } + return &resp.ServiceList[0], nil + } +} diff --git a/products/urocketmq/internal/service/delete.go b/products/urocketmq/internal/service/delete.go new file mode 100644 index 0000000000..79de2a5cd6 --- /dev/null +++ b/products/urocketmq/internal/service/delete.go @@ -0,0 +1,94 @@ +package service + +import ( + "fmt" + + "github.com/spf13/cobra" + + urocketmq "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud urocketmq service delete +func newDelete(ctx *cli.Context) *cobra.Command { + var async bool + var yes bool + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewDeleteURocketMQServiceRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a URocketMQ service instance", + Long: "Delete a URocketMQ service instance", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + serviceID := *req.ServiceId + ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure you want to delete service %q?", serviceID)) + if err != nil { + return err + } + if !ok { + return nil + } + + resp, err := client.DeleteURocketMQService(req) + if err != nil { + if serr, ok := err.(uerr.ServerError); ok && serr.Code() == 99539 { + return fmt.Errorf("please delete all Topics and Groups under the instance before deleting it") + } + return err + } + + prog := ctx.NewProgress() + block := prog.NewBlock() + ctx.EmitResult(cli.OpResultRow{ResourceID: serviceID, Action: "delete", Status: "Deleting"}) + + _ = resp // delete response contains only Message + + text := fmt.Sprintf("the service[%s] is deleting", serviceID) + if async { + block.Append(text) + } else { + prog.Sspoll(describeDeletedServiceByID(ctx), serviceID, text, + // Poll until service no longer exists (describe returns nil) or deletion fails. + []string{SERVICE_DELETED, SERVICE_DELETE_FAILED}, block, &req.CommonBase) + } + return nil + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ServiceId = flags.String("service-id", "", "Required. Service ID") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for the long-running operation to finish.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.") + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + + command.SetCompletion(cmd, "service-id", func() []string { + return ServiceList(ctx, req.GetProjectId(), req.GetRegion()) + }) + + cmd.MarkFlagRequired("service-id") + + return cmd +} + +func describeDeletedServiceByID(ctx *cli.Context) func(string, *request.CommonBase) (interface{}, error) { + describe := describeServiceByID(ctx) + return func(id string, commonBase *request.CommonBase) (interface{}, error) { + inst, err := describe(id, commonBase) + if err != nil { + return nil, err + } + if inst == nil { + return &urocketmq.ServiceDetail{State: SERVICE_DELETED}, nil + } + return inst, nil + } +} diff --git a/products/urocketmq/internal/service/get.go b/products/urocketmq/internal/service/get.go new file mode 100644 index 0000000000..812e5449df --- /dev/null +++ b/products/urocketmq/internal/service/get.go @@ -0,0 +1,45 @@ +package service + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newGet ucloud urocketmq service get +func newGet(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewGetURocketMQServiceRequest() + cmd := &cobra.Command{ + Use: "get", + Short: "Get details of a URocketMQ service instance", + Long: "Get details of a URocketMQ service instance", + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := client.GetURocketMQService(req) + if err != nil { + return err + } + ctx.PrintList(resp.ServiceList) + return nil + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ServiceId = flags.String("service-id", "", "Required. Service ID") + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + + command.SetCompletion(cmd, "service-id", func() []string { + return ServiceList(ctx, req.GetProjectId(), req.GetRegion()) + }) + + cobra.CheckErr(cmd.MarkFlagRequired("service-id")) + + return cmd +} diff --git a/products/urocketmq/internal/service/list.go b/products/urocketmq/internal/service/list.go new file mode 100644 index 0000000000..c9cac40635 --- /dev/null +++ b/products/urocketmq/internal/service/list.go @@ -0,0 +1,190 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + sdkerror "github.com/ucloud/ucloud-sdk-go/ucloud/error" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList ucloud urocketmq service list +func newList(ctx *cli.Context) *cobra.Command { + var allRegion, idOnly bool + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewListURocketMQServiceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List all URocketMQ instances", + Long: "List all URocketMQ instances", + Run: func(cmd *cobra.Command, args []string) { + services, err := getAllServices(ctx, client, req, allRegion) + if err != nil { + ctx.HandleError(err) + return + } + if idOnly { + listServiceID(ctx, services) + } else { + listService(ctx, services, allRegion) + } + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.Limit = cmd.Flags().Int("limit", 20, "Optional. Limit default 20, max value 1000") + req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0") + cmd.Flags().BoolVar(&allRegion, "all-region", false, "Optional. Accept values: true or false. List URocketMQ instances of all regions when assigned true") + cmd.Flags().BoolVar(&idOnly, "id-only", false, "Optional. Just display resource id of URocketMQ service") + + command.SetFlagValues(cmd, "all-region", "true", "false") + command.SetFlagValues(cmd, "id-only", "true", "false") + + return cmd +} + +// getAllServices handles --all-region cross-region aggregation; single region fetches one page by user limit/offset. +func getAllServices(ctx *cli.Context, client *urocketmq.URocketMQClient, req *urocketmq.ListURocketMQServiceRequest, allRegion bool) ([]urocketmq.ServiceBaseInfo, error) { + if allRegion { + result := make([]urocketmq.ServiceBaseInfo, 0) + regions, err := ctx.AllRegions() + if err != nil { + return nil, err + } + for _, region := range regions { + _req := *req + _req.Region = sdk.String(region) + // --all-region does not paginate, fetches all per region + services, err := fetchServicesPageOff(client, &_req) + // Some accounts lack URocketMQ permissions in the current region; skip per platform convention RetCode 230 + if e, ok := err.(sdkerror.Error); ok && e.Code() == _RetCodeRegionNoPermission { + continue + } + if err != nil { + return nil, err + } + result = append(result, services...) + } + return result, nil + } + + resp, err := client.ListURocketMQService(req) + if err != nil { + return nil, err + } + return resp.ServiceList, nil +} + +// fetchServicesPageOff paginates all URocketMQ instances in the specified region. SDK response has no +// TotalCount, so uses last page item count < pageSize as termination condition. +func fetchServicesPageOff(client *urocketmq.URocketMQClient, req *urocketmq.ListURocketMQServiceRequest) ([]urocketmq.ServiceBaseInfo, error) { + _req := *req + result := make([]urocketmq.ServiceBaseInfo, 0) + for limit, offset := 100, 0; ; offset += limit { + _req.Offset = sdk.Int(offset) + _req.Limit = sdk.Int(limit) + resp, err := client.ListURocketMQService(&_req) + if err != nil { + return nil, err + } + result = append(result, resp.ServiceList...) + if len(resp.ServiceList) < limit { + break + } + } + return result, nil +} + +// listService renders the service list. json/yaml emits full-field serviceRow; table mode uses curated +// columns (serviceRowDefault, serviceRowAllRegion for --all-region with Region appended). +func listService(ctx *cli.Context, services []urocketmq.ServiceBaseInfo, listAllRegion bool) { + list := make([]serviceRow, 0, len(services)) + for _, s := range services { + list = append(list, toServiceRow(s)) + } + + // JSON/YAML mode: emits full-field rows (like uhost, --json always marshals full fields). + if ctx.Format() != cli.OutputTable { + ctx.PrintList(list) + return + } + + if listAllRegion { + rows := make([]serviceRowAllRegion, 0, len(list)) + for _, r := range list { + rows = append(rows, serviceRowAllRegion{ + Name: r.Name, ServiceId: r.ServiceId, State: r.State, + Config: formatServiceConfig(r.Tps, r.Storage), Address: r.Address, + CreateTime: common.FormatDate(r.CreateTime), ExpireTime: common.FormatDate(r.ExpireTime), + Region: r.Region, + }) + } + ctx.PrintList(rows) + return + } + + rows := make([]serviceRowDefault, 0, len(list)) + for _, r := range list { + rows = append(rows, serviceRowDefault{ + Name: r.Name, ServiceId: r.ServiceId, State: r.State, + Config: formatServiceConfig(r.Tps, r.Storage), Address: r.Address, + CreateTime: common.FormatDate(r.CreateTime), ExpireTime: common.FormatDate(r.ExpireTime), + }) + } + ctx.PrintList(rows) +} + +// toServiceRow maps SDK ServiceBaseInfo to a full-field row. +func toServiceRow(s urocketmq.ServiceBaseInfo) serviceRow { + return serviceRow{ + ServiceId: s.ServiceId, + Name: s.Name, + State: s.State, + Tps: s.Tps, + Storage: s.Storage, + TopicLimit: s.TopicLimit, + Address: s.Address, + AddressExtranet: s.AddressExtranet, + VpcId: s.VpcId, + SubnetId: s.SubnetId, + ChargeType: s.ChargeType, + CreateTime: s.CreateTime, + ExpireTime: s.ExpireTime, + Remark: s.Remark, + Tag: s.Tag, + Edition: s.Edition, + Mode: s.Mode, + AutoRenew: s.AutoRenew, + IsExpire: s.IsExpire, + Quantity: s.Quantity, + Region: s.Region, + } +} + +// formatServiceConfig concatenates the Config column in table mode: Tps + Storage. +func formatServiceConfig(tps, storage int) string { + return fmt.Sprintf("tps:%d storage:%dG", tps, storage) +} + +// listServiceID outputs only the ServiceId list to ctx.Out() (not ProgressWriter) for script capture. +// Corresponds to uhost listUhostID. +func listServiceID(ctx *cli.Context, services []urocketmq.ServiceBaseInfo) { + ids := make([]string, 0, len(services)) + for _, s := range services { + ids = append(ids, s.ServiceId) + } + fmt.Fprintln(ctx.Out(), strings.Join(ids, ",")) +} + +// _RetCodeRegionNoPermission is the SDK RetCode when account lacks permission in the current region; +// --all-region path skips that region. Follows uhost (cmd/uhost.go) platform convention. +const _RetCodeRegionNoPermission = 230 diff --git a/products/urocketmq/internal/service/price.go b/products/urocketmq/internal/service/price.go new file mode 100644 index 0000000000..fe42255451 --- /dev/null +++ b/products/urocketmq/internal/service/price.go @@ -0,0 +1,81 @@ +package service + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newPrice ucloud urocketmq service price +func newPrice(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewGetURocketMQServicePriceRequest() + cmd := &cobra.Command{ + Use: "price", + Short: "Get price of URocketMQ service instance", + Long: "Get price of URocketMQ service instance", + RunE: func(cmd *cobra.Command, args []string) error { + if *req.Storage <= 0 || *req.Storage%100 != 0 { + return fmt.Errorf("--storage-gb must be a positive multiple of 100") + } + if sdk.StringValue(req.ChargeType) == "Dynamic" { + req.Quantity = sdk.Int(0) + } + resp, err := client.GetURocketMQServicePrice(req) + if err != nil { + return err + } + list := make([]urocketmq.PriceSet, 0, len(resp.PriceSet)) + for _, p := range resp.PriceSet { + list = append(list, p) + } + if ctx.Format() != cli.OutputTable { + ctx.PrintList(list) + return nil + } + rows := make([]priceRowDefault, 0, len(list)) + for _, r := range list { + rows = append(rows, priceRowDefault{ + ChargeName: r.ChargeName, + ChargeType: r.ChargeType, + Price: r.Price, + }) + } + ctx.PrintList(rows) + return nil + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ChargeType = flags.String("charge-type", "Month", "Required. Charge type. Enum: Year, Month, Dynamic") + req.Edition = flags.String("edition", "Enterprise", "Required. Edition. Unique value: Enterprise") + req.Mode = flags.String("mode", "PrivateNet", "Required. Network mode. Unique value: PrivateNet") + req.PublicVersion = flags.String("public-version", "", "Cluster version. Options vary by region, see doc for supported values: https://github.com/UCloudDoc-Team/rocketmq/blob/master/price/index.md, e.g. v4, v5 (each region only support one version)") + req.Quantity = flags.Int("quantity", 1, "Optional. Purchase duration in months. Month: 1-9(month), 0=until end of current month; Dynamic: ignore; Year: use --quantity as years") + req.Storage = flags.Int("storage-gb", 0, "Required. Storage space in GB. Check the doc first to determine available values: https://github.com/UCloudDoc-Team/rocketmq/blob/master/price/index.md") + req.TPS = flags.Int("tps", 0, "Required. Transactions per second. Enum: 10000, 20000, 50000, 100000, 200000. Note: v4 supports 20000, 50000, 100000, 200000; v5 currently supports only 10000, 20000.") + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + + command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic") + command.SetFlagValues(cmd, "edition", "Enterprise") + command.SetFlagValues(cmd, "mode", "PrivateNet") + command.SetFlagValues(cmd, "tps", "10000", "20000", "50000", "100000", "200000") + + cmd.MarkFlagRequired("charge-type") + cmd.MarkFlagRequired("edition") + cmd.MarkFlagRequired("mode") + cmd.MarkFlagRequired("storage-gb") + cmd.MarkFlagRequired("tps") + + return cmd +} diff --git a/products/urocketmq/internal/service/rows.go b/products/urocketmq/internal/service/rows.go new file mode 100644 index 0000000000..2ae812de31 --- /dev/null +++ b/products/urocketmq/internal/service/rows.go @@ -0,0 +1,52 @@ +package service + +type serviceRow struct { + ServiceId string + Name string + State string + Tps int + Storage int + TopicLimit int + Address string + AddressExtranet string + VpcId string + SubnetId string + ChargeType string + CreateTime int + ExpireTime int + Remark string + Tag string + Edition string + Mode string + AutoRenew string + IsExpire string + Quantity int + Region string +} + +type serviceRowDefault struct { + Name string + ServiceId string + State string + Config string + Address string + CreateTime string + ExpireTime string +} + +type serviceRowAllRegion struct { + Name string + ServiceId string + State string + Config string + Address string + CreateTime string + ExpireTime string + Region string +} + +type priceRowDefault struct { + ChargeName string + ChargeType string + Price float64 +} diff --git a/products/urocketmq/internal/service/status.go b/products/urocketmq/internal/service/status.go new file mode 100644 index 0000000000..1c8248814b --- /dev/null +++ b/products/urocketmq/internal/service/status.go @@ -0,0 +1,21 @@ +package service + +// Service status constants, from ServiceBaseInfo.State / ServiceDetail.State enum values. +// See uhost status.go (HOST_RUNNING / HOST_STOPPED / HOST_FAIL). +const ( + SERVICE_AVAILABLE = "Available" + SERVICE_INITIALIZING = "Initializing" + SERVICE_DELETING = "Deleting" + SERVICE_CREATE_FAILED = "CreateFailed" + SERVICE_CLOSING = "Closing" + SERVICE_CLOSED = "Closed" + SERVICE_CLOSE_FAILED = "CloseFailed" + SERVICE_RECOVERING = "Recovering" + SERVICE_RECOVER_FAILED = "RecoverFailed" + SERVICE_UPGRADING = "Upgrading" + SERVICE_UPGRADE_FAILED = "UpgradeFailed" + SERVICE_DELETE_FAILED = "DeleteFailed" + // SERVICE_DELETED is a pseudo-status used internally by deletion polling: Get returns empty list + // indicating the instance is gone. + SERVICE_DELETED = "Deleted" +) diff --git a/products/urocketmq/internal/service/update_name.go b/products/urocketmq/internal/service/update_name.go new file mode 100644 index 0000000000..6e4204f243 --- /dev/null +++ b/products/urocketmq/internal/service/update_name.go @@ -0,0 +1,48 @@ +package service + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUpdateName ucloud urocketmq service update-name +func newUpdateName(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewUpdateURocketMQServiceNameRequest() + cmd := &cobra.Command{ + Use: "update-name", + Short: "Update URocketMQ service instance name", + Long: "Update URocketMQ service instance name", + RunE: func(cmd *cobra.Command, args []string) error { + serviceID := *req.ServiceId + _, err := client.UpdateURocketMQServiceName(req) + if err != nil { + return err + } + ctx.EmitResult(cli.OpResultRow{ResourceID: serviceID, Action: "update-name", Status: "Updated"}) + return nil + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ServiceId = flags.String("service-id", "", "Required. Service ID") + req.Name = flags.String("name", "", "Required. New service name. Regex: ^[a-zA-Z0-9-_]{1,36}$") + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + + command.SetCompletion(cmd, "service-id", func() []string { + return ServiceList(ctx, req.GetProjectId(), req.GetRegion()) + }) + + cmd.MarkFlagRequired("service-id") + cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/products/urocketmq/internal/service/update_remark.go b/products/urocketmq/internal/service/update_remark.go new file mode 100644 index 0000000000..390f82d4c6 --- /dev/null +++ b/products/urocketmq/internal/service/update_remark.go @@ -0,0 +1,47 @@ +package service + +import ( + "github.com/spf13/cobra" + + urocketmq "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUpdateRemark ucloud urocketmq service update-remark +func newUpdateRemark(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewUpdateURocketMQServiceRemarkRequest() + cmd := &cobra.Command{ + Use: "update-remark", + Short: "Update URocketMQ service instance remark", + Long: "Update URocketMQ service instance remark", + RunE: func(cmd *cobra.Command, args []string) error { + serviceID := *req.ServiceId + _, err := client.UpdateURocketMQServiceRemark(req) + if err != nil { + return err + } + ctx.EmitResult(cli.OpResultRow{ResourceID: serviceID, Action: "update-remark", Status: "Updated"}) + return nil + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.ServiceId = flags.String("service-id", "", "Required. Service ID") + req.Remark = flags.String("remark", "", "Optional. New remark for the service") + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + + command.SetCompletion(cmd, "service-id", func() []string { + return ServiceList(ctx, req.GetProjectId(), req.GetRegion()) + }) + + cmd.MarkFlagRequired("service-id") + + return cmd +} diff --git a/products/urocketmq/internal/token/cmd.go b/products/urocketmq/internal/token/cmd.go new file mode 100644 index 0000000000..14ab6cd15a --- /dev/null +++ b/products/urocketmq/internal/token/cmd.go @@ -0,0 +1,24 @@ +package token + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `urocketmq token` resource-group command. Action subcommand order is fixed as +// create/delete/get/list/update (golden depends, do not reorder arbitrarily). +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "token", + Short: "Manage URocketMQ access tokens", + Long: "Create, delete, get, list and update URocketMQ access tokens for fine-grained topic access control.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newGet(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newUpdate(ctx)) + return cmd +} diff --git a/products/urocketmq/internal/token/completion.go b/products/urocketmq/internal/token/completion.go new file mode 100644 index 0000000000..581e855d65 --- /dev/null +++ b/products/urocketmq/internal/token/completion.go @@ -0,0 +1,34 @@ +package token + +import ( + "github.com/ucloud/ucloud-cli/pkg/cli" + urocketmq "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" +) + +// TokenList returns the TokenId list under the specified Service, for same-group --token-id completion reuse. +// ListURocketMQToken Limit max is 100 (different from service/topic's 1000), +// paginates by 100 to fetch all, see group.GroupList. +func TokenList(ctx *cli.Context, projectID, region, serviceID string) []string { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewListURocketMQTokenRequest() + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + req.ServiceId = sdk.String(serviceID) + ids := make([]string, 0) + for limit, offset := 100, 0; ; offset += limit { + req.Limit = sdk.Int(limit) + req.Offset = sdk.Int(offset) + resp, err := client.ListURocketMQToken(req) + if err != nil { + return nil + } + for _, t := range resp.TokenList { + ids = append(ids, t.TokenId) + } + if offset+limit >= resp.TotalCount { + break + } + } + return ids +} diff --git a/products/urocketmq/internal/token/create.go b/products/urocketmq/internal/token/create.go new file mode 100644 index 0000000000..af450db44f --- /dev/null +++ b/products/urocketmq/internal/token/create.go @@ -0,0 +1,70 @@ +package token + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/topic" +) + +// newCreate ucloud urocketmq token create +func newCreate(ctx *cli.Context) *cobra.Command { + var allowConsumeTopicList []string + var allowProduceTopicList []string + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewCreateURocketMQTokenRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create URocketMQ token", + Long: "Create URocketMQ token", + Run: func(cmd *cobra.Command, args []string) { + if len(allowConsumeTopicList) > 0 { + req.AllowConsumeTopicList = sdk.String(strings.Join(allowConsumeTopicList, ",")) + } + if len(allowProduceTopicList) > 0 { + req.AllowProduceTopicList = sdk.String(strings.Join(allowProduceTopicList, ",")) + } + resp, err := client.CreateURocketMQToken(req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.TokenId, Action: "create", Status: "Success"}) + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.Name = cmd.Flags().String("name", "", "Required. Token name") + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID. See 'ucloud urocketmq service list'") + req.TopicConsumePerm = cmd.Flags().String("topic-consume-perm", "", "Required. Topic consume permission. Accept values: ALL, NONE, PART") + req.TopicProducePerm = cmd.Flags().String("topic-produce-perm", "", "Required. Topic produce permission. Accept values: ALL, NONE, PART") + cmd.Flags().StringSliceVar(&allowConsumeTopicList, "allow-consume-topic-list", nil, "Optional. Allow consume topic name list, multiple values separated by comma") + cmd.Flags().StringSliceVar(&allowProduceTopicList, "allow-produce-topic-list", nil, "Optional. Allow produce topic name list, multiple values separated by comma") + + command.SetFlagValues(cmd, "topic-consume-perm", "ALL", "NONE", "PART") + command.SetFlagValues(cmd, "topic-produce-perm", "ALL", "NONE", "PART") + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "allow-consume-topic-list", func() []string { + return topic.TopicList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + command.SetCompletion(cmd, "allow-produce-topic-list", func() []string { + return topic.TopicList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("service-id") + cmd.MarkFlagRequired("topic-consume-perm") + cmd.MarkFlagRequired("topic-produce-perm") + + return cmd +} diff --git a/products/urocketmq/internal/token/delete.go b/products/urocketmq/internal/token/delete.go new file mode 100644 index 0000000000..6c4a7fa580 --- /dev/null +++ b/products/urocketmq/internal/token/delete.go @@ -0,0 +1,60 @@ +package token + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" +) + +// newDelete ucloud urocketmq token delete +func newDelete(ctx *cli.Context) *cobra.Command { + var yes bool + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewDeleteURocketMQTokenRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete URocketMQ token", + Long: "Delete URocketMQ token. Default token cannot be deleted.", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure you want to delete token %q from service %s?", *req.TokenId, *req.ServiceId)) + if err != nil { + return err + } + if !ok { + return nil + } + + _, err = client.DeleteURocketMQToken(req) + if err != nil { + return err + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.TokenId, Action: "delete", Status: "Deleted"}) + return nil + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID. See 'ucloud urocketmq service list'") + req.TokenId = cmd.Flags().String("token-id", "", "Required. Token ID") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.") + + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "token-id", func() []string { + return TokenList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + cmd.MarkFlagRequired("service-id") + cmd.MarkFlagRequired("token-id") + + return cmd +} diff --git a/products/urocketmq/internal/token/get.go b/products/urocketmq/internal/token/get.go new file mode 100644 index 0000000000..9b156e10c1 --- /dev/null +++ b/products/urocketmq/internal/token/get.go @@ -0,0 +1,96 @@ +package token + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" +) + +// newGet ucloud urocketmq token get +func newGet(ctx *cli.Context) *cobra.Command { + var display bool + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewGetURocketMQTokenRequest() + cmd := &cobra.Command{ + Use: "get", + Short: "Get URocketMQ token details", + Long: "Get URocketMQ token details. AKSK secret key is only shown with --display for security.", + Run: func(cmd *cobra.Command, args []string) { + if display { + req.Display = sdk.String("true") + } else { + req.Display = sdk.String("false") + } + resp, err := client.GetURocketMQToken(req) + if err != nil { + ctx.HandleError(err) + return + } + renderTokenGet(ctx, &resp.Token, display) + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID. See 'ucloud urocketmq service list'") + req.TokenId = cmd.Flags().String("token-id", "", "Required. Token ID") + cmd.Flags().BoolVar(&display, "display", false, "Optional. Display AKSK secret key in plaintext") + + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "token-id", func() []string { + return TokenList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + cmd.MarkFlagRequired("service-id") + cmd.MarkFlagRequired("token-id") + + return cmd +} + +func renderTokenGet(ctx *cli.Context, t *urocketmq.Token, showSecret bool) { + if showSecret { + ctx.PrintList([]tokenRowWithSecret{{ + TokenId: t.TokenId, + Name: t.Name, + TopicConsumePerm: t.TopicConsumePerm, + TopicProducePerm: t.TopicProducePerm, + Type: t.Type, + CreateTime: common.FormatDate(t.CreateTime), + ModifyTime: common.FormatDate(t.ModifyTime), + AccessKey: t.AKSK.AccessKey, + SecretKey: t.AKSK.SecretKey, + }}) + return + } + + if ctx.Format() != cli.OutputTable { + ctx.PrintList([]tokenRow{{ + TokenId: t.TokenId, + Name: t.Name, + TopicConsumePerm: t.TopicConsumePerm, + TopicProducePerm: t.TopicProducePerm, + Type: t.Type, + CreateTime: common.FormatDate(t.CreateTime), + ModifyTime: common.FormatDate(t.ModifyTime), + AccessKey: t.AKSK.AccessKey, + }}) + return + } + + ctx.PrintList([]tokenRowDefault{{ + TokenId: t.TokenId, + Name: t.Name, + TopicConsumePerm: t.TopicConsumePerm, + TopicProducePerm: t.TopicProducePerm, + Type: t.Type, + CreateTime: common.FormatDate(t.CreateTime), + }}) +} diff --git a/products/urocketmq/internal/token/list.go b/products/urocketmq/internal/token/list.go new file mode 100644 index 0000000000..a8d68dbb02 --- /dev/null +++ b/products/urocketmq/internal/token/list.go @@ -0,0 +1,80 @@ +package token + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" +) + +// newList ucloud urocketmq token list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewListURocketMQTokenRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List URocketMQ tokens", + Long: "List URocketMQ tokens", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.ListURocketMQToken(req) + if err != nil { + ctx.HandleError(err) + return + } + listToken(ctx, resp.TokenList) + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID") + req.Limit = cmd.Flags().Int("limit", 20, "Optional. Limit default 20, max value 100") + req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0") + + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("service-id") + + return cmd +} + +// listToken renders the token list. json/yaml emits full-field tokenRow; table mode uses tokenRowDefault. +func listToken(ctx *cli.Context, tokens []urocketmq.TokenDetail) { + list := make([]tokenRow, 0, len(tokens)) + for _, t := range tokens { + list = append(list, tokenRow{ + TokenId: t.TokenId, + Name: t.Name, + TopicConsumePerm: t.TopicConsumePerm, + TopicProducePerm: t.TopicProducePerm, + Type: t.Type, + CreateTime: common.FormatDate(t.CreateTime), + ModifyTime: common.FormatDate(t.ModifyTime), + AccessKey: t.AKSK.AccessKey, + }) + } + + if ctx.Format() != cli.OutputTable { + ctx.PrintList(list) + return + } + + rows := make([]tokenRowDefault, 0, len(list)) + for _, r := range list { + rows = append(rows, tokenRowDefault{ + TokenId: r.TokenId, + Name: r.Name, + TopicConsumePerm: r.TopicConsumePerm, + TopicProducePerm: r.TopicProducePerm, + Type: r.Type, + CreateTime: r.CreateTime, + }) + } + ctx.PrintList(rows) +} diff --git a/products/urocketmq/internal/token/rows.go b/products/urocketmq/internal/token/rows.go new file mode 100644 index 0000000000..77d782ced4 --- /dev/null +++ b/products/urocketmq/internal/token/rows.go @@ -0,0 +1,36 @@ +package token + +// tokenRow is the full-field row (json/yaml mode), without SecretKey (secure default). +type tokenRow struct { + TokenId string + Name string + TopicConsumePerm string + TopicProducePerm string + Type string + CreateTime string + ModifyTime string + AccessKey string +} + +// tokenRowWithSecret is used for get --display, appending SecretKey on top of tokenRow. +type tokenRowWithSecret struct { + TokenId string + Name string + TopicConsumePerm string + TopicProducePerm string + Type string + CreateTime string + ModifyTime string + AccessKey string + SecretKey string +} + +// tokenRowDefault is the curated columns in table mode, containing no AKSK key information. +type tokenRowDefault struct { + TokenId string + Name string + TopicConsumePerm string + TopicProducePerm string + Type string + CreateTime string +} diff --git a/products/urocketmq/internal/token/update.go b/products/urocketmq/internal/token/update.go new file mode 100644 index 0000000000..dabcb5d01a --- /dev/null +++ b/products/urocketmq/internal/token/update.go @@ -0,0 +1,73 @@ +package token + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/topic" +) + +// newUpdate ucloud urocketmq token update +func newUpdate(ctx *cli.Context) *cobra.Command { + var allowConsumeTopicList []string + var allowProduceTopicList []string + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewUpdateURocketMQTokenRequest() + cmd := &cobra.Command{ + Use: "update", + Short: "Update URocketMQ token configuration", + Long: "Update URocketMQ token configuration", + Run: func(cmd *cobra.Command, args []string) { + if len(allowConsumeTopicList) > 0 { + req.AllowConsumeTopicList = sdk.String(strings.Join(allowConsumeTopicList, ",")) + } + if len(allowProduceTopicList) > 0 { + req.AllowProduceTopicList = sdk.String(strings.Join(allowProduceTopicList, ",")) + } + _, err := client.UpdateURocketMQToken(req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.TokenId, Action: "update", Status: "Success"}) + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID. See 'ucloud urocketmq service list'") + req.TokenId = cmd.Flags().String("token-id", "", "Required. Token ID") + req.TopicConsumePerm = cmd.Flags().String("topic-consume-perm", "", "Required. Topic consume permission. Accept values: ALL, NONE, PART") + req.TopicProducePerm = cmd.Flags().String("topic-produce-perm", "", "Required. Topic produce permission. Accept values: ALL, NONE, PART") + cmd.Flags().StringSliceVar(&allowConsumeTopicList, "allow-consume-topic-list", nil, "Optional. Allow consume topic name list, multiple values separated by comma") + cmd.Flags().StringSliceVar(&allowProduceTopicList, "allow-produce-topic-list", nil, "Optional. Allow produce topic name list, multiple values separated by comma") + + command.SetFlagValues(cmd, "topic-consume-perm", "ALL", "NONE", "PART") + command.SetFlagValues(cmd, "topic-produce-perm", "ALL", "NONE", "PART") + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "token-id", func() []string { + return TokenList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + command.SetCompletion(cmd, "allow-consume-topic-list", func() []string { + return topic.TopicList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + command.SetCompletion(cmd, "allow-produce-topic-list", func() []string { + return topic.TopicList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + cmd.MarkFlagRequired("service-id") + cmd.MarkFlagRequired("token-id") + cmd.MarkFlagRequired("topic-consume-perm") + cmd.MarkFlagRequired("topic-produce-perm") + + return cmd +} diff --git a/products/urocketmq/internal/topic/cmd.go b/products/urocketmq/internal/topic/cmd.go new file mode 100644 index 0000000000..2adff2b613 --- /dev/null +++ b/products/urocketmq/internal/topic/cmd.go @@ -0,0 +1,22 @@ +package topic + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `urocketmq topic` resource-group command. Action subcommands are appended +// at the end in subsequent batches (order is fixed, golden depends). +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "topic", + Short: "Manage URocketMQ topics", + Args: cobra.NoArgs, + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newUpdate(ctx)) + return cmd +} diff --git a/products/urocketmq/internal/topic/completion.go b/products/urocketmq/internal/topic/completion.go new file mode 100644 index 0000000000..ec074fd91f --- /dev/null +++ b/products/urocketmq/internal/topic/completion.go @@ -0,0 +1,28 @@ +package topic + +import ( + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" +) + +// TopicList returns the topic name list under the specified service, for --topic-name completion reuse +// by delete/update (exported for use by other packages). +func TopicList(ctx *cli.Context, projectID, region, serviceID string) []string { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewListURocketMQTopicRequest() + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + req.ServiceId = sdk.String(serviceID) + req.Limit = sdk.Int(1000) + req.Offset = sdk.Int(0) + resp, err := client.ListURocketMQTopic(req) + if err != nil { + return nil + } + names := make([]string, 0, len(resp.TopicList)) + for _, t := range resp.TopicList { + names = append(names, t.TopicName) + } + return names +} diff --git a/products/urocketmq/internal/topic/create.go b/products/urocketmq/internal/topic/create.go new file mode 100644 index 0000000000..9cc4a652e0 --- /dev/null +++ b/products/urocketmq/internal/topic/create.go @@ -0,0 +1,49 @@ +package topic + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" +) + +// newCreate ucloud urocketmq topic create +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewCreateURocketMQTopicRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create URocketMQ topic", + Long: "Create URocketMQ topic", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := client.CreateURocketMQTopic(req) + if err != nil { + return err + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.TopicId, Action: "create", Status: "Success"}) + return nil + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.MessageType = cmd.Flags().String("message-type", "", "Required. Message type. Accept values: Normal, PartitionSequence, GlobalSequence, Transaction, Delay") + req.Name = cmd.Flags().String("name", "", "Required. Topic name, supports letters, digits, hyphens and underscores, length 1~36") + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID. see 'ucloud urocketmq service list'") + req.Remark = cmd.Flags().String("remark", "", "Optional. Topic remark, max length 36") + + command.SetFlagValues(cmd, "message-type", "Normal", "PartitionSequence", "GlobalSequence", "Transaction", "Delay") + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("message-type") + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("service-id") + + return cmd +} diff --git a/products/urocketmq/internal/topic/delete.go b/products/urocketmq/internal/topic/delete.go new file mode 100644 index 0000000000..1440b5d01e --- /dev/null +++ b/products/urocketmq/internal/topic/delete.go @@ -0,0 +1,59 @@ +package topic + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" +) + +// newDelete ucloud urocketmq topic delete +func newDelete(ctx *cli.Context) *cobra.Command { + var yes *bool + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewDeleteURocketMQTopicRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete URocketMQ topic", + Long: "Delete URocketMQ topic", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ok, err := ctx.Confirm(*yes, fmt.Sprintf("Are you sure you want to delete topic %q from service %s?", *req.TopicName, *req.ServiceId)) + if err != nil { + return err + } + if !ok { + return nil + } + _, err = client.DeleteURocketMQTopic(req) + if err != nil { + return err + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.TopicName, Action: "delete", Status: "Success"}) + return nil + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID. see 'ucloud urocketmq service list'") + req.TopicName = cmd.Flags().String("topic-name", "", "Required. Topic name") + yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "topic-name", func() []string { + return TopicList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + cmd.MarkFlagRequired("service-id") + cmd.MarkFlagRequired("topic-name") + + return cmd +} diff --git a/products/urocketmq/internal/topic/list.go b/products/urocketmq/internal/topic/list.go new file mode 100644 index 0000000000..803338b515 --- /dev/null +++ b/products/urocketmq/internal/topic/list.go @@ -0,0 +1,80 @@ +package topic + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" +) + +// newList ucloud urocketmq topic list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewListURocketMQTopicRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List URocketMQ topics", + Long: "List URocketMQ topics", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.ListURocketMQTopic(req) + if err != nil { + ctx.HandleError(err) + return + } + listTopic(ctx, resp.TopicList) + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.Limit = cmd.Flags().Int("limit", 20, "Optional. Limit default 20, max value 1000") + req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0") + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID. see 'ucloud urocketmq service list'") + + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + cmd.MarkFlagRequired("service-id") + + return cmd +} + +// listTopic renders the topic list. json/yaml emits full-field topicRow; table mode uses curated columns topicRowDefault. +func listTopic(ctx *cli.Context, topics []urocketmq.TopicInfo) { + list := make([]topicRow, 0, len(topics)) + for _, t := range topics { + list = append(list, toTopicRow(t)) + } + + if ctx.Format() != cli.OutputTable { + ctx.PrintList(list) + return + } + + rows := make([]topicRowDefault, 0, len(list)) + for _, r := range list { + rows = append(rows, topicRowDefault{ + TopicName: r.TopicName, + MessageType: r.MessageType, + Remark: r.Remark, + CreateTime: common.FormatDate(r.CreateTime), + }) + } + ctx.PrintList(rows) +} + +// toTopicRow maps SDK TopicInfo to a full-field row. +func toTopicRow(t urocketmq.TopicInfo) topicRow { + return topicRow{ + TopicId: t.TopicId, + TopicName: t.TopicName, + MessageType: t.MessageType, + Remark: t.Remark, + CreateTime: t.CreateTime, + } +} diff --git a/products/urocketmq/internal/topic/rows.go b/products/urocketmq/internal/topic/rows.go new file mode 100644 index 0000000000..f91a680a62 --- /dev/null +++ b/products/urocketmq/internal/topic/rows.go @@ -0,0 +1,18 @@ +package topic + +// topicRow is the full-field row (json/yaml mode). Fields correspond to SDK TopicInfo. +type topicRow struct { + TopicId string + TopicName string + MessageType string + Remark string + CreateTime int +} + +// topicRowDefault is the default curated columns in table mode: TopicName/MessageType/Remark/CreateTime. +type topicRowDefault struct { + TopicName string + MessageType string + Remark string + CreateTime string +} diff --git a/products/urocketmq/internal/topic/update.go b/products/urocketmq/internal/topic/update.go new file mode 100644 index 0000000000..412a5a4f22 --- /dev/null +++ b/products/urocketmq/internal/topic/update.go @@ -0,0 +1,49 @@ +package topic + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/urocketmq" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" + "github.com/ucloud/ucloud-cli/products/urocketmq/internal/service" +) + +// newUpdate ucloud urocketmq topic update +func newUpdate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, urocketmq.NewClient) + req := client.NewUpdateURocketMQTopicRequest() + cmd := &cobra.Command{ + Use: "update", + Short: "Update URocketMQ topic remark", + Long: "Update URocketMQ topic remark. Currently only supports updating the topic remark.", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + _, err := client.UpdateURocketMQTopic(req) + if err != nil { + return err + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.TopicName, Action: "update", Status: "Success"}) + return nil + }, + } + cmd.Flags().SortFlags = false + + ctx.BindProjectID(cmd, req) + ctx.BindRegion(cmd, req) + req.ServiceId = cmd.Flags().String("service-id", "", "Required. Service ID. see 'ucloud urocketmq service list'") + req.TopicName = cmd.Flags().String("topic-name", "", "Required. Topic name") + req.Remark = cmd.Flags().String("remark", "", "Optional. Topic remark") + + command.SetCompletion(cmd, "service-id", func() []string { + return service.ServiceList(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "topic-name", func() []string { + return TopicList(ctx, *req.ProjectId, *req.Region, *req.ServiceId) + }) + cmd.MarkFlagRequired("service-id") + cmd.MarkFlagRequired("topic-name") + + return cmd +} diff --git a/products/urocketmq/product.go b/products/urocketmq/product.go new file mode 100644 index 0000000000..cf896e1573 --- /dev/null +++ b/products/urocketmq/product.go @@ -0,0 +1,21 @@ +package urocketmq + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internal "github.com/ucloud/ucloud-cli/products/urocketmq/internal" +) + +type product struct{} + +// New returns the urocketmq product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "urocketmq", Commands: []string{"urocketmq"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internal.NewCommand(ctx)} +} diff --git a/products/urocketmq/product.yaml b/products/urocketmq/product.yaml new file mode 100644 index 0000000000..76ed48b734 --- /dev/null +++ b/products/urocketmq/product.yaml @@ -0,0 +1,6 @@ +name: urocketmq +owners: + - lanfunoe +commands: + - urocketmq +enabled: true diff --git a/products/urocketmq/testdata/cmdtree.golden b/products/urocketmq/testdata/cmdtree.golden new file mode 100644 index 0000000000..0685cd2b99 --- /dev/null +++ b/products/urocketmq/testdata/cmdtree.golden @@ -0,0 +1,159 @@ +ucloud urocketmq use=urocketmq short=Manage URocketMQ instances, topics, groups, tokens and messages +ucloud urocketmq group use=group short=Manage URocketMQ consumer groups +ucloud urocketmq group create use=create short=Create a consumer group + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=service-id short= default= required=true +ucloud urocketmq group delete use=delete short=Delete a consumer group + flag=group-name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true + flag=yes short=y default=false required= +ucloud urocketmq group list use=list short=List URocketMQ consumer groups + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true +ucloud urocketmq message use=message short=Query URocketMQ messages +ucloud urocketmq message query-by-id use=query-by-id short=Query a message by ID + flag=msg-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true + flag=topic-name short= default= required=true +ucloud urocketmq message query-by-key use=query-by-key short=Query messages by key + flag=id-only short= default=false required= + flag=key short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true + flag=topic-name short= default= required=true +ucloud urocketmq message query-by-topic use=query-by-topic short=Query messages by topic and time range + flag=begin short= default= required=true + flag=end short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true + flag=topic-name short= default= required=true +ucloud urocketmq service use=service short=Manage URocketMQ service instances +ucloud urocketmq service create use=create short=Create a URocketMQ service instance + flag=async short= default=false required= + flag=charge-type short= default=Month required=true + flag=edition short= default=Enterprise required=true + flag=file-reserved-time short= default=3 required= + flag=group short= default=Default required= + flag=mode short= default=PrivateNet required=true + flag=name short= default= required=true + flag=project-id short= default= required= + flag=public-version short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=remark short= default= required= + flag=storage-gb short= default=0 required=true + flag=subnet-id short= default= required=true + flag=tps short= default= required=true + flag=vpc-id short= default= required=true +ucloud urocketmq service delete use=delete short=Delete a URocketMQ service instance + flag=async short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true + flag=yes short=y default=false required= +ucloud urocketmq service get use=get short=Get details of a URocketMQ service instance + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true +ucloud urocketmq service list use=list short=List all URocketMQ instances + flag=all-region short= default=false required= + flag=id-only short= default=false required= + flag=limit short= default=20 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= +ucloud urocketmq service price use=price short=Get price of URocketMQ service instance + flag=charge-type short= default=Month required=true + flag=edition short= default=Enterprise required=true + flag=mode short= default=PrivateNet required=true + flag=project-id short= default= required= + flag=public-version short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=storage-gb short= default=0 required=true + flag=tps short= default=0 required=true +ucloud urocketmq service update-name use=update-name short=Update URocketMQ service instance name + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true +ucloud urocketmq service update-remark use=update-remark short=Update URocketMQ service instance remark + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=service-id short= default= required=true +ucloud urocketmq token use=token short=Manage URocketMQ access tokens +ucloud urocketmq token create use=create short=Create URocketMQ token + flag=allow-consume-topic-list short= default=[] required= + flag=allow-produce-topic-list short= default=[] required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true + flag=topic-consume-perm short= default= required=true + flag=topic-produce-perm short= default= required=true +ucloud urocketmq token delete use=delete short=Delete URocketMQ token + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true + flag=token-id short= default= required=true + flag=yes short=y default=false required= +ucloud urocketmq token get use=get short=Get URocketMQ token details + flag=display short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true + flag=token-id short= default= required=true +ucloud urocketmq token list use=list short=List URocketMQ tokens + flag=limit short= default=20 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true +ucloud urocketmq token update use=update short=Update URocketMQ token configuration + flag=allow-consume-topic-list short= default=[] required= + flag=allow-produce-topic-list short= default=[] required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true + flag=token-id short= default= required=true + flag=topic-consume-perm short= default= required=true + flag=topic-produce-perm short= default= required=true +ucloud urocketmq topic use=topic short=Manage URocketMQ topics +ucloud urocketmq topic create use=create short=Create URocketMQ topic + flag=message-type short= default= required=true + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=service-id short= default= required=true +ucloud urocketmq topic delete use=delete short=Delete URocketMQ topic + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true + flag=topic-name short= default= required=true + flag=yes short=y default=false required= +ucloud urocketmq topic list use=list short=List URocketMQ topics + flag=limit short= default=20 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required=true +ucloud urocketmq topic update use=update short=Update URocketMQ topic remark + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=service-id short= default= required=true + flag=topic-name short= default= required=true diff --git a/products/urocketmq/testdata/completion.golden b/products/urocketmq/testdata/completion.golden new file mode 100644 index 0000000000..76732fc573 --- /dev/null +++ b/products/urocketmq/testdata/completion.golden @@ -0,0 +1,94 @@ +ucloud urocketmq group create project-id dynamic +ucloud urocketmq group create region dynamic +ucloud urocketmq group create service-id dynamic +ucloud urocketmq group delete group-name dynamic +ucloud urocketmq group delete project-id dynamic +ucloud urocketmq group delete region dynamic +ucloud urocketmq group delete service-id dynamic +ucloud urocketmq group list project-id dynamic +ucloud urocketmq group list region dynamic +ucloud urocketmq group list service-id dynamic +ucloud urocketmq message query-by-id project-id dynamic +ucloud urocketmq message query-by-id region dynamic +ucloud urocketmq message query-by-id service-id dynamic +ucloud urocketmq message query-by-id topic-name dynamic +ucloud urocketmq message query-by-key id-only static false,true +ucloud urocketmq message query-by-key project-id dynamic +ucloud urocketmq message query-by-key region dynamic +ucloud urocketmq message query-by-key service-id dynamic +ucloud urocketmq message query-by-key topic-name dynamic +ucloud urocketmq message query-by-topic project-id dynamic +ucloud urocketmq message query-by-topic region dynamic +ucloud urocketmq message query-by-topic service-id dynamic +ucloud urocketmq message query-by-topic topic-name dynamic +ucloud urocketmq service create charge-type static Dynamic,Month,Year +ucloud urocketmq service create edition static Enterprise +ucloud urocketmq service create mode static PrivateNet +ucloud urocketmq service create project-id dynamic +ucloud urocketmq service create region dynamic +ucloud urocketmq service create subnet-id dynamic +ucloud urocketmq service create tps static 10000,100000,20000,200000,50000 +ucloud urocketmq service create vpc-id dynamic +ucloud urocketmq service delete project-id dynamic +ucloud urocketmq service delete region dynamic +ucloud urocketmq service delete service-id dynamic +ucloud urocketmq service get project-id dynamic +ucloud urocketmq service get region dynamic +ucloud urocketmq service get service-id dynamic +ucloud urocketmq service list all-region static false,true +ucloud urocketmq service list id-only static false,true +ucloud urocketmq service list project-id dynamic +ucloud urocketmq service list region dynamic +ucloud urocketmq service price charge-type static Dynamic,Month,Year +ucloud urocketmq service price edition static Enterprise +ucloud urocketmq service price mode static PrivateNet +ucloud urocketmq service price project-id dynamic +ucloud urocketmq service price region dynamic +ucloud urocketmq service price tps static 10000,100000,20000,200000,50000 +ucloud urocketmq service update-name project-id dynamic +ucloud urocketmq service update-name region dynamic +ucloud urocketmq service update-name service-id dynamic +ucloud urocketmq service update-remark project-id dynamic +ucloud urocketmq service update-remark region dynamic +ucloud urocketmq service update-remark service-id dynamic +ucloud urocketmq token create allow-consume-topic-list dynamic +ucloud urocketmq token create allow-produce-topic-list dynamic +ucloud urocketmq token create project-id dynamic +ucloud urocketmq token create region dynamic +ucloud urocketmq token create service-id dynamic +ucloud urocketmq token create topic-consume-perm static ALL,NONE,PART +ucloud urocketmq token create topic-produce-perm static ALL,NONE,PART +ucloud urocketmq token delete project-id dynamic +ucloud urocketmq token delete region dynamic +ucloud urocketmq token delete service-id dynamic +ucloud urocketmq token delete token-id dynamic +ucloud urocketmq token get project-id dynamic +ucloud urocketmq token get region dynamic +ucloud urocketmq token get service-id dynamic +ucloud urocketmq token get token-id dynamic +ucloud urocketmq token list project-id dynamic +ucloud urocketmq token list region dynamic +ucloud urocketmq token list service-id dynamic +ucloud urocketmq token update allow-consume-topic-list dynamic +ucloud urocketmq token update allow-produce-topic-list dynamic +ucloud urocketmq token update project-id dynamic +ucloud urocketmq token update region dynamic +ucloud urocketmq token update service-id dynamic +ucloud urocketmq token update token-id dynamic +ucloud urocketmq token update topic-consume-perm static ALL,NONE,PART +ucloud urocketmq token update topic-produce-perm static ALL,NONE,PART +ucloud urocketmq topic create message-type static Delay,GlobalSequence,Normal,PartitionSequence,Transaction +ucloud urocketmq topic create project-id dynamic +ucloud urocketmq topic create region dynamic +ucloud urocketmq topic create service-id dynamic +ucloud urocketmq topic delete project-id dynamic +ucloud urocketmq topic delete region dynamic +ucloud urocketmq topic delete service-id dynamic +ucloud urocketmq topic delete topic-name dynamic +ucloud urocketmq topic list project-id dynamic +ucloud urocketmq topic list region dynamic +ucloud urocketmq topic list service-id dynamic +ucloud urocketmq topic update project-id dynamic +ucloud urocketmq topic update region dynamic +ucloud urocketmq topic update service-id dynamic +ucloud urocketmq topic update topic-name dynamic diff --git a/products/usnap/internal/usnap/cmd.go b/products/usnap/internal/usnap/cmd.go new file mode 100644 index 0000000000..4fa43cd8f7 --- /dev/null +++ b/products/usnap/internal/usnap/cmd.go @@ -0,0 +1,20 @@ +package usnap + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `usnap` root command and mounts the subcommands. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "usnap", + Short: "Manage USnap (UCloud Disk Snapshot Service)", + Long: "Manage USnap (UCloud Disk Snapshot Service)", + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newDelete(ctx)) + return cmd +} diff --git a/products/usnap/internal/usnap/create.go b/products/usnap/internal/usnap/create.go new file mode 100644 index 0000000000..50e6e3fb87 --- /dev/null +++ b/products/usnap/internal/usnap/create.go @@ -0,0 +1,61 @@ +package usnap + +import ( + "fmt" + + "github.com/spf13/cobra" + + usnapsdk "github.com/ucloud/ucloud-sdk-go/services/usnap" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud usnap create +func newCreate(ctx *cli.Context) *cobra.Command { + var async *bool + client := cli.NewServiceClient(ctx, usnapsdk.NewClient) + req := client.NewCreateSnapshotServiceRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a USnap snapshot service for a disk", + Long: "Create a USnap snapshot service for a disk", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + resp, err := client.CreateSnapshotService(req) + if err != nil { + ctx.HandleError(err) + return + } + + text := fmt.Sprintf("usnap:%v is creating", resp.SnapshotServiceId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUsnapByID(ctx)).Spoll(resp.SnapshotServiceId, text, []string{SERVICE_AVAILABLE, SERVICE_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.SnapshotServiceId, Action: "create", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.VDiskId = flags.String("vdisk-id", "", "Required. Resource ID of the disk to create snapshot service for") + req.BackupMode = flags.String("backup-mode", "", "Optional. Backup mode") + req.Day = flags.Int("backup-day", 0, "Optional. Backup day range") + req.Hour = flags.Int("backup-hour", 0, "Optional. Backup hour") + req.Journal = flags.Int("journal", 0, "Optional. Journal retention count") + req.ChargeType = flags.String("charge-type", "Dynamic", "Optional. 'Year', pay yearly; 'Month', pay monthly; 'Dynamic', pay hourly") + req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months") + req.Tag = flags.String("group", "Default", "Optional. Business group") + async = flags.Bool("async", false, "Optional. Do not wait for the long-running operation to finish.") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic", "Trial") + + cmd.MarkFlagRequired("vdisk-id") + + return cmd +} diff --git a/products/usnap/internal/usnap/delete.go b/products/usnap/internal/usnap/delete.go new file mode 100644 index 0000000000..2d5d4a37a7 --- /dev/null +++ b/products/usnap/internal/usnap/delete.go @@ -0,0 +1,54 @@ +package usnap + +import ( + "fmt" + + "github.com/spf13/cobra" + + usnapsdk "github.com/ucloud/ucloud-sdk-go/services/usnap" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete ucloud usnap delete +func newDelete(ctx *cli.Context) *cobra.Command { + var yes *bool + client := cli.NewServiceClient(ctx, usnapsdk.NewClient) + req := client.NewDeleteSnapshotServiceRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete USnap snapshot service(s)", + Long: "Delete USnap snapshot service(s)", + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(*yes, "Are you sure to delete USnap snapshot service(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + + _, err = client.DeleteSnapshotService(req) + if err != nil { + ctx.HandleError(err) + } else { + fmt.Fprintf(w, "usnap[%s] deleted\n", *req.VDiskId) + results = append(results, cli.OpResultRow{ResourceID: *req.VDiskId, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.VDiskId = flags.String("vdisk-id", "", "Required. Resource ID of the disk whose snapshot service to delete") + yes = flags.BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.") + + ctx.BindCommonParams(cmd, req) + + cmd.MarkFlagRequired("vdisk-id") + + return cmd +} diff --git a/products/usnap/internal/usnap/describe.go b/products/usnap/internal/usnap/describe.go new file mode 100644 index 0000000000..548318b020 --- /dev/null +++ b/products/usnap/internal/usnap/describe.go @@ -0,0 +1,86 @@ +package usnap + +import ( + "fmt" + + "github.com/spf13/cobra" + + usnapsdk "github.com/ucloud/ucloud-sdk-go/services/usnap" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDescribe ucloud usnap describe +func newDescribe(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, usnapsdk.NewClient) + req := client.NewDescribeSnapshotServiceRequest() + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe USnap snapshot service(s)", + Long: "Describe USnap snapshot service(s)", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.DescribeSnapshotService(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []SnapshotServiceRow{} + for _, svc := range resp.DataSet { + row := SnapshotServiceRow{ + ResourceID: svc.ServiceId, + VDiskID: svc.VDiskId, + VDiskName: svc.VDiskName, + VDiskSize: fmt.Sprintf("%dGB", svc.VDiskSize), + VDiskType: svc.VDiskType, + Group: svc.Tag, + ChargeType: svc.ChargeType, + AutoRenew: svc.AutoRenew, + Status: svc.Status, + Zone: svc.Zone, + CreationTime: common.FormatDate(svc.CreateTime), + Expiration: common.FormatDate(svc.ExpiredTime), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.SnapshotServiceId = flags.String("service-id", "", "Optional. Resource ID of the snapshot service") + req.VDiskId = flags.String("vdisk-id", "", "Optional. Resource ID of the disk") + req.SnapshotId = flags.String("snapshot-id", "", "Optional. Resource ID of the snapshot") + req.Limit = flags.Int("limit", 50, "Optional. Limit") + req.Offset = flags.Int("offset", 0, "Optional. Offset") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + return cmd +} + +// describeUsnapByID returns the poller's describe func, closing over ctx so it +// can build an authed usnap client. +func describeUsnapByID(ctx *cli.Context) func(serviceID string, commonBase *request.CommonBase) (interface{}, error) { + return func(serviceID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, usnapsdk.NewClient) + req := client.NewDescribeSnapshotServiceRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.SnapshotServiceId = &serviceID + limit := 50 + req.Limit = &limit + resp, err := client.DescribeSnapshotService(req) + if err != nil { + return nil, err + } + if len(resp.DataSet) < 1 { + return nil, nil + } + return &resp.DataSet[0], nil + } +} diff --git a/products/usnap/internal/usnap/rows.go b/products/usnap/internal/usnap/rows.go new file mode 100644 index 0000000000..e0feb84dda --- /dev/null +++ b/products/usnap/internal/usnap/rows.go @@ -0,0 +1,17 @@ +package usnap + +// SnapshotServiceRow represents a single row in the usnap snapshot service list output. +type SnapshotServiceRow struct { + ResourceID string + VDiskID string + VDiskName string + VDiskSize string + VDiskType string + Group string + ChargeType string + AutoRenew string + Status string + Zone string + CreationTime string + Expiration string +} diff --git a/products/usnap/internal/usnap/status.go b/products/usnap/internal/usnap/status.go new file mode 100644 index 0000000000..700658e9df --- /dev/null +++ b/products/usnap/internal/usnap/status.go @@ -0,0 +1,8 @@ +package usnap + +// USnap-domain state constants. +const ( + SERVICE_CREATING = "Creating" + SERVICE_AVAILABLE = "Available" + SERVICE_FAILED = "Failed" +) diff --git a/products/usnap/product.go b/products/usnap/product.go new file mode 100644 index 0000000000..89f5a30dd3 --- /dev/null +++ b/products/usnap/product.go @@ -0,0 +1,21 @@ +package usnap + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalusnap "github.com/ucloud/ucloud-cli/products/usnap/internal/usnap" +) + +type product struct{} + +// New returns the usnap product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "usnap", Commands: []string{"usnap"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalusnap.NewCommand(ctx)} +} diff --git a/products/usnap/product.yaml b/products/usnap/product.yaml new file mode 100644 index 0000000000..91e9528116 --- /dev/null +++ b/products/usnap/product.yaml @@ -0,0 +1,7 @@ +# products/usnap/product.yaml — usnap 产品元数据(归属真源,owner 自治维护) +name: usnap +owners: + - pearlinpan +commands: + - usnap +enabled: true diff --git a/products/usnap/testdata/cmdtree.golden b/products/usnap/testdata/cmdtree.golden new file mode 100644 index 0000000000..e426ffa0eb --- /dev/null +++ b/products/usnap/testdata/cmdtree.golden @@ -0,0 +1,29 @@ +ucloud usnap use=usnap short=Manage USnap (UCloud Disk Snapshot Service) +ucloud usnap create use=create short=Create a USnap snapshot service for a disk + flag=async short= default=false required= + flag=backup-day short= default=0 required= + flag=backup-hour short= default=0 required= + flag=backup-mode short= default= required= + flag=charge-type short= default=Dynamic required= + flag=group short= default=Default required= + flag=journal short= default=0 required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=vdisk-id short= default= required=true + flag=zone short= default= required= +ucloud usnap delete use=delete short=Delete USnap snapshot service(s) + flag=project-id short= default= required= + flag=region short= default= required= + flag=vdisk-id short= default= required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud usnap describe use=describe short=Describe USnap snapshot service(s) + flag=limit short= default=50 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=service-id short= default= required= + flag=snapshot-id short= default= required= + flag=vdisk-id short= default= required= + flag=zone short= default= required= diff --git a/products/usnap/testdata/completion.golden b/products/usnap/testdata/completion.golden new file mode 100644 index 0000000000..81b490fe24 --- /dev/null +++ b/products/usnap/testdata/completion.golden @@ -0,0 +1,10 @@ +ucloud usnap create charge-type static Dynamic,Month,Trial,Year +ucloud usnap create project-id dynamic +ucloud usnap create region dynamic +ucloud usnap create zone dynamic +ucloud usnap delete project-id dynamic +ucloud usnap delete region dynamic +ucloud usnap delete zone dynamic +ucloud usnap describe project-id dynamic +ucloud usnap describe region dynamic +ucloud usnap describe zone dynamic diff --git a/products/utidb/internal/tidb/api.go b/products/utidb/internal/tidb/api.go new file mode 100644 index 0000000000..f7a316ceba --- /dev/null +++ b/products/utidb/internal/tidb/api.go @@ -0,0 +1,75 @@ +package tidb + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// invokeAPI calls a TiDB API action via GenericRequest. +// +// Why GenericInvoke instead of typed NewXxxRequest() field assignment (§5): +// the SDK FormEncoder emits string slices as NodeTypes.0, but TiDB APIs expect +// flat NodeTypes0; nested NodeConfig / Labels / SecGroupInfo must be nested +// maps so the encoder emits NodeConfig.0.Field. Typed requests cannot express +// both encodings correctly for this product. +func invokeAPI(ctx *cli.Context, action string, params map[string]interface{}) (map[string]interface{}, error) { + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewGenericRequest() + allParams := map[string]interface{}{ + "Action": action, + } + for k, v := range params { + allParams[k] = v + } + if err := req.SetPayload(allParams); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, err + } + return resp.GetPayload(), nil +} + +func mergeCommonParams(region, zone, projectID string, params map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(params)+3) + for k, v := range params { + out[k] = v + } + if region != "" { + out["Region"] = region + } + if zone != "" { + out["Zone"] = zone + } + if projectID != "" { + out["ProjectId"] = projectID + } + return out +} + +func flattenIndexedStrings(params map[string]interface{}, name string, values []string) { + for i, v := range values { + params[fmt.Sprintf("%s%d", name, i)] = v + } +} + +func getTiDBClusterUhostSpecs(ctx *cli.Context, region, zone, projectID string, nodeTypes []string) ([]tidb.UhostSpecs, error) { + params := mergeCommonParams(region, zone, projectID, map[string]interface{}{}) + flattenIndexedStrings(params, "NodeTypes", formatNodeTypes(nodeTypes)) + payload, err := invokeAPI(ctx, "GetTiDBClusterUhostSpecs", params) + if err != nil { + return nil, err + } + return parseUhostSpecsFromPayload(payload), nil +} + +func getTiDBClusterPayload(ctx *cli.Context, region, zone, projectID, id string) (map[string]interface{}, error) { + params := mergeCommonParams(region, zone, projectID, map[string]interface{}{ + "Id": id, + }) + return invokeAPI(ctx, "GetTiDBClusterService", params) +} diff --git a/products/utidb/internal/tidb/backup.go b/products/utidb/internal/tidb/backup.go new file mode 100644 index 0000000000..24b7b86896 --- /dev/null +++ b/products/utidb/internal/tidb/backup.go @@ -0,0 +1,58 @@ +package tidb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBackup ucloud utidb backup +func newBackup(ctx *cli.Context) *cobra.Command { + var id, backupFilter, backupTs string + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewStartTiDBClusterBackupRequest() + + cmd := &cobra.Command{ + Use: "backup", + Short: "Start a backup of a UTiDB instance", + Long: "Start a backup of a UTiDB instance", + Run: func(c *cobra.Command, args []string) { + req.Id = sdk.String(ctx.PickResourceID(id)) + if backupFilter != "" { + req.BackupFilter = sdk.String(backupFilter) + } + if backupTs != "" { + req.BackupTs = sdk.String(backupTs) + } + resp, err := client.StartTiDBClusterBackup(req) + if err != nil { + handleAPIError(ctx, err) + return + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.BackupId, Action: "backup", Status: stateBackingUp}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + flags.StringVar(&backupFilter, "backup-filter", "", "Optional. Backup filter rule") + flags.StringVar(&backupTs, "backup-ts", "", "Optional. Backup timestamp") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/cmd.go b/products/utidb/internal/tidb/cmd.go new file mode 100644 index 0000000000..77d7e05f7c --- /dev/null +++ b/products/utidb/internal/tidb/cmd.go @@ -0,0 +1,27 @@ +package tidb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `utidb` root command and mounts all verbs. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "utidb", + Short: "Manipulate UTiDB instances on UCloud platform", + Long: helpUTiDBRoot, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newBackup(ctx)) + cmd.AddCommand(newListBackup(ctx)) + cmd.AddCommand(newScaleNode(ctx)) + cmd.AddCommand(newResizeDisk(ctx)) + cmd.AddCommand(newModifySpec(ctx)) + cmd.AddCommand(newListSpecs(ctx)) + return cmd +} diff --git a/products/utidb/internal/tidb/completion.go b/products/utidb/internal/tidb/completion.go new file mode 100644 index 0000000000..5cf1686695 --- /dev/null +++ b/products/utidb/internal/tidb/completion.go @@ -0,0 +1,93 @@ +package tidb + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// listResourceIDs returns UTiDB instance IDs formatted as id/name. +func listResourceIDs(ctx *cli.Context, states []string, region, zone, projectID string) []string { + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewListTiDBClusterServiceRequest() + if region != "" { + req.Region = sdk.String(region) + } + if zone != "" { + req.Zone = sdk.String(zone) + } + if projectID != "" { + req.ProjectId = sdk.String(projectID) + } + resp, err := client.ListTiDBClusterService(req) + if err != nil { + return nil + } + var list []string + for _, d := range resp.Data { + if states != nil { + found := false + for _, s := range states { + if s == d.State { + found = true + break + } + } + if !found { + continue + } + } + list = append(list, fmt.Sprintf("%s/%s", d.Id, d.Name)) + } + return list +} + +// listNodeTypes returns all available node types by querying specs. +func listNodeTypes(ctx *cli.Context, region, zone string) []string { + seedTypes := []string{"tidb", "tikv", "pd", "tiflash"} + specs, err := getTiDBClusterUhostSpecs(ctx, region, zone, "", seedTypes) + if err != nil { + return nil + } + seen := make(map[string]bool) + var list []string + for _, s := range specs { + if !seen[s.NodeType] { + seen[s.NodeType] = true + list = append(list, s.NodeType) + } + } + return list +} + +// listConfigIDs returns uhost config IDs for the given node type. +func listConfigIDs(ctx *cli.Context, region, zone, nodeType string) []string { + if nodeType == "" { + return nil + } + specs, err := getTiDBClusterUhostSpecs(ctx, region, zone, "", []string{nodeType}) + if err != nil { + return nil + } + var list []string + for _, s := range specs { + list = append(list, fmt.Sprintf("%s/%s", s.ConfigId, s.ConfigName)) + } + return list +} + +// listServerIDs returns server IDs of the given UTiDB instance for scale-in completion. +// Format: /@ +func listServerIDs(ctx *cli.Context, region, zone, projectID, id string) []string { + if id == "" { + return nil + } + payload, err := getTiDBClusterPayload(ctx, region, zone, projectID, id) + if err != nil { + return nil + } + return extractServerIDs(payload) +} diff --git a/products/utidb/internal/tidb/create.go b/products/utidb/internal/tidb/create.go new file mode 100644 index 0000000000..19f44cc7ae --- /dev/null +++ b/products/utidb/internal/tidb/create.go @@ -0,0 +1,260 @@ +package tidb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseCreateNodeConfig parses a CLI node-config string into the SDK type. +// Format: ConfigId=xxx,DiskSize=N,NodeCount=N,ServerType=tidb +func parseCreateNodeConfig(s string) (tidb.CreateTiDBClusterServiceParamNodeConfig, error) { + var cfg tidb.CreateTiDBClusterServiceParamNodeConfig + parts := strings.Split(s, ",") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + return cfg, fmt.Errorf("invalid node-config segment %q, expected key=value", part) + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "ConfigId": + cfg.ConfigId = sdk.String(val) + case "DiskSize": + n, err := strconv.Atoi(val) + if err != nil { + return cfg, fmt.Errorf("invalid DiskSize %q: %w", val, err) + } + cfg.DiskSize = sdk.Int(n) + case "NodeCount": + n, err := strconv.Atoi(val) + if err != nil { + return cfg, fmt.Errorf("invalid NodeCount %q: %w", val, err) + } + cfg.NodeCount = sdk.Int(n) + case "ServerType": + cfg.ServerType = sdk.String(val) + default: + return cfg, fmt.Errorf("unknown node-config key %q", key) + } + } + if cfg.ConfigId == nil || cfg.DiskSize == nil || cfg.NodeCount == nil || cfg.ServerType == nil { + return cfg, fmt.Errorf("node-config must include ConfigId, DiskSize, NodeCount and ServerType") + } + if err := validateServerType(*cfg.ServerType); err != nil { + return cfg, err + } + return cfg, nil +} + +func parseCreateLabels(ss []string) []tidb.CreateTiDBClusterServiceParamLabels { + var out []tidb.CreateTiDBClusterServiceParamLabels + for _, s := range ss { + parts := strings.SplitN(s, "=", 2) + if len(parts) == 2 { + out = append(out, tidb.CreateTiDBClusterServiceParamLabels{ + Key: sdk.String(strings.TrimSpace(parts[0])), + Value: sdk.String(strings.TrimSpace(parts[1])), + }) + } + } + return out +} + +func parseCreateSecGroupInfo(ss []string) ([]tidb.CreateTiDBClusterServiceParamSecGroupInfo, error) { + var out []tidb.CreateTiDBClusterServiceParamSecGroupInfo + for _, s := range ss { + var item tidb.CreateTiDBClusterServiceParamSecGroupInfo + parts := strings.Split(s, ",") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "SecGroupId": + item.SecGroupId = sdk.String(val) + case "Priority": + n, err := strconv.Atoi(val) + if err != nil { + return nil, fmt.Errorf("invalid Priority %q: %w", val, err) + } + item.Priority = sdk.Int(n) + } + } + out = append(out, item) + } + return out, nil +} + +// newCreate ucloud utidb create +func newCreate(ctx *cli.Context) *cobra.Command { + var name, password, chargeType, dtType, pubUlbID, vpcID, subnetID string + var dbVersion, ip, port, coupon, promotionID, templateID string + var quantity float64 + var activityID, ruleID int + var alertStrategyIDs []int + var labels, secGroupInfo []string + var nodeConfigs []string + var async bool + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewCreateTiDBClusterServiceRequest() + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a UTiDB instance", + Long: helpCreateLong, + Run: func(c *cobra.Command, args []string) { + var configs []tidb.CreateTiDBClusterServiceParamNodeConfig + for _, s := range nodeConfigs { + cfg, err := parseCreateNodeConfig(s) + if err != nil { + ctx.HandleError(err) + return + } + configs = append(configs, cfg) + } + + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Name": name, + "Password": password, + "ChargeType": chargeType, + "DTType": dtType, + "VPCId": vpcID, + "SubnetId": subnetID, + "Quantity": quantity, + }) + if pubUlbID != "" { + params["PubUlbId"] = pubUlbID + } + if dbVersion != "" { + params["DbVersion"] = dbVersion + } + if ip != "" { + params["Ip"] = ip + } + if port != "" { + params["Port"] = port + } + if promotionID != "" { + params["PromotionId"] = promotionID + } + if templateID != "" { + params["TemplateId"] = templateID + } + if activityID != 0 { + params["ActivityId"] = activityID + } + if ruleID != 0 { + params["RuleId"] = ruleID + } + if coupon != "" { + params["Coupon"] = coupon + } + if len(alertStrategyIDs) > 0 { + params["AlertStrategyIds"] = alertStrategyIDs + } + if len(labels) > 0 { + labelMaps := make([]map[string]interface{}, 0, len(labels)) + for _, l := range parseCreateLabels(labels) { + labelMaps = append(labelMaps, labelToMap(l)) + } + params["Labels"] = labelMaps + } + if len(secGroupInfo) > 0 { + infos, err := parseCreateSecGroupInfo(secGroupInfo) + if err != nil { + ctx.HandleError(err) + return + } + secMaps := make([]map[string]interface{}, 0, len(infos)) + for _, info := range infos { + secMaps = append(secMaps, secGroupToMap(info)) + } + params["SecGroupInfo"] = secMaps + } + nodeConfigMaps := make([]map[string]interface{}, 0, len(configs)) + for _, cfg := range configs { + nodeConfigMaps = append(nodeConfigMaps, createNodeConfigToMap(cfg)) + } + params["NodeConfig"] = nodeConfigMaps + + payload, err := invokeAPI(ctx, "CreateTiDBClusterService", params) + if err != nil { + handleAPIError(ctx, err) + return + } + data, _ := payload["Data"].(map[string]interface{}) + clusterID := stringVal(data["Id"]) + if clusterID == "" { + ctx.HandleError(fmt.Errorf("empty cluster ID in response")) + return + } + + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "utidb[%s] is creating\n", clusterID) + } else { + text := fmt.Sprintf("utidb[%s] is creating", clusterID) + spollCreate(ctx, w, req.GetRegion(), req.GetZone(), req.GetProjectId(), clusterID, text) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: clusterID, Action: "create", Status: "Creating"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&name, "name", "", "Required. Instance name") + flags.StringVar(&password, "password", "", "Required. Admin password") + flags.StringVar(&chargeType, "charge-type", "", "Required. Charge type: Month, Year, Dynamic, Trial") + flags.StringVar(&dtType, "dt-type", "", "Required. Disaster tolerance: 10 (same AZ), 20 (cross AZ)") + flags.StringVar(&pubUlbID, "pub-ulb-id", "", "Optional. Public ULB ID") + flags.StringVar(&vpcID, "vpc-id", "", "Required. VPC ID") + flags.StringVar(&subnetID, "subnet-id", "", "Required. Subnet ID") + flags.Float64Var(&quantity, "quantity", 1, "Required. Purchase duration") + flags.StringArrayVar(&nodeConfigs, "node-config", nil, "Required. Per node type: ConfigId=xxx,DiskSize=N,NodeCount=N,ServerType=tidb|tikv|pd|tiflash") + + flags.StringVar(&dbVersion, "db-version", "", "Optional. Database version, e.g. v8.5.1, v8.5.6") + flags.StringVar(&ip, "ip", "", "Optional. Specified IP address") + flags.StringVar(&port, "port", "", "Optional. Specified port") + flags.StringVar(&coupon, "coupon", "", "Optional. Coupon ID") + flags.StringVar(&promotionID, "promotion-id", "", "Optional. Promotion ID") + flags.StringVar(&templateID, "template-id", "", "Optional. Parameter template ID") + flags.IntVar(&activityID, "activity-id", 0, "Optional. Activity ID") + flags.IntVar(&ruleID, "rule-id", 0, "Optional. Rule ID") + flags.IntSliceVar(&alertStrategyIDs, "alert-strategy-ids", nil, "Optional. Alert strategy IDs") + flags.StringSliceVar(&labels, "labels", nil, "Optional. Resource labels, format: key=value, repeatable") + flags.StringArrayVar(&secGroupInfo, "sec-group-info", nil, "Optional. Security group info, format: SecGroupId=xxx,Priority=N, repeatable") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for creation to finish") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("charge-type") + cmd.MarkFlagRequired("dt-type") + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("subnet-id") + cmd.MarkFlagRequired("quantity") + cmd.MarkFlagRequired("node-config") + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic", "Trial") + command.SetFlagValues(cmd, "dt-type", "10", "20") + + return cmd +} diff --git a/products/utidb/internal/tidb/delete.go b/products/utidb/internal/tidb/delete.go new file mode 100644 index 0000000000..20c284b9b7 --- /dev/null +++ b/products/utidb/internal/tidb/delete.go @@ -0,0 +1,80 @@ +package tidb + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud utidb delete +func newDelete(ctx *cli.Context) *cobra.Command { + var id string + var deleteBackup bool + var yes, async bool + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewDeleteTiDBClusterServiceRequest() + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a UTiDB instance", + Long: "Delete a UTiDB instance", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure to delete UTiDB instance %s?", id)) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + + pickedID := ctx.PickResourceID(id) + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Id": pickedID, + }) + if deleteBackup { + params["DeleteBackup"] = true + } + + _, err = invokeAPI(ctx, "DeleteTiDBClusterService", params) + if err != nil { + handleAPIError(ctx, err) + return + } + + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "utidb[%s] is deleting\n", pickedID) + } else { + text := fmt.Sprintf("utidb[%s] is deleting", pickedID) + ctx.PollerTo(w, describeByID(ctx, req.GetRegion(), req.GetZone(), req.GetProjectId())).Spoll(pickedID, text, []string{stateDeleted, stateDeleteFail}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: pickedID, Action: "delete", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance to delete") + flags.BoolVar(&deleteBackup, "delete-backup", false, "Optional. Also delete backup data") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for deletion to finish") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/describe.go b/products/utidb/internal/tidb/describe.go new file mode 100644 index 0000000000..e62aa1ed66 --- /dev/null +++ b/products/utidb/internal/tidb/describe.go @@ -0,0 +1,66 @@ +package tidb + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDescribe ucloud utidb describe +func newDescribe(ctx *cli.Context) *cobra.Command { + var id string + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewGetTiDBClusterServiceRequest() + cmd := &cobra.Command{ + Use: "describe", + Short: "Show details of a UTiDB instance", + Long: "Show details of a UTiDB instance", + Run: func(c *cobra.Command, args []string) { + req.Id = sdk.String(ctx.PickResourceID(id)) + resp, err := client.GetTiDBClusterService(req) + if err != nil { + handleAPIError(ctx, err) + return + } + d := resp.Data + rows := []cli.DescribeRow{ + {Attribute: "ID", Content: d.Id}, + {Attribute: "Name", Content: d.Name}, + {Attribute: "State", Content: d.State}, + {Attribute: "Port", Content: fmt.Sprintf("%d", d.Port)}, + {Attribute: "IP", Content: d.Ip}, + {Attribute: "VPCId", Content: d.VPCId}, + {Attribute: "SubnetId", Content: d.SubnetId}, + {Attribute: "Version", Content: d.Version}, + {Attribute: "CreateTime", Content: fmt.Sprintf("%d", d.CreateTime)}, + {Attribute: "DTType", Content: fmt.Sprintf("%d", d.DTType)}, + {Attribute: "AutoBackup", Content: d.AutoBackup}, + {Attribute: "BinlogState", Content: d.BinlogState}, + {Attribute: "TiFlashState", Content: d.TiFlashState}, + {Attribute: "DashboardUrl", Content: d.DashboardUrl}, + {Attribute: "GrafanaUrl", Content: d.GrafanaUrl}, + } + ctx.PrintList(rows) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/encode.go b/products/utidb/internal/tidb/encode.go new file mode 100644 index 0000000000..bf54c3b39c --- /dev/null +++ b/products/utidb/internal/tidb/encode.go @@ -0,0 +1,109 @@ +package tidb + +import ( + "strings" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" +) + +func formatNodeType(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + switch s { + case "tidb": + return "Tidb" + case "tikv": + return "Tikv" + case "pd": + return "Pd" + case "tiflash": + return "Tiflash" + default: + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] + } +} + +func formatNodeTypes(types []string) []string { + out := make([]string, len(types)) + for i, t := range types { + out[i] = formatNodeType(t) + } + return out +} + +func createNodeConfigToMap(cfg tidb.CreateTiDBClusterServiceParamNodeConfig) map[string]interface{} { + m := map[string]interface{}{} + if cfg.ConfigId != nil { + m["ConfigId"] = *cfg.ConfigId + } + if cfg.DiskSize != nil { + m["DiskSize"] = *cfg.DiskSize + } + if cfg.NodeCount != nil { + m["NodeCount"] = *cfg.NodeCount + } + if cfg.ServerType != nil { + m["ServerType"] = formatNodeType(*cfg.ServerType) + } + return m +} + +func labelToMap(l tidb.CreateTiDBClusterServiceParamLabels) map[string]interface{} { + m := map[string]interface{}{} + if l.Key != nil { + m["Key"] = *l.Key + } + if l.Value != nil { + m["Value"] = *l.Value + } + return m +} + +func secGroupToMap(s tidb.CreateTiDBClusterServiceParamSecGroupInfo) map[string]interface{} { + m := map[string]interface{}{} + if s.SecGroupId != nil { + m["SecGroupId"] = *s.SecGroupId + } + if s.Priority != nil { + m["Priority"] = *s.Priority + } + return m +} + +func scaleNodeConfigToMap(cfg tidb.ModifyTiDBClusterNodeParamNodeConfig) map[string]interface{} { + m := map[string]interface{}{} + if cfg.ConfigId != nil { + m["ConfigId"] = *cfg.ConfigId + } + if cfg.NodeCount != nil { + m["NodeCount"] = *cfg.NodeCount + } + if cfg.ServerType != nil { + m["ServerType"] = formatNodeType(*cfg.ServerType) + } + return m +} + +func resizeDiskNodeConfigToMap(cfg tidb.ModifyTiDBClusterUhostDiskParamNodeConfig) map[string]interface{} { + m := map[string]interface{}{} + if cfg.DiskSize != nil { + m["DiskSize"] = *cfg.DiskSize + } + if cfg.ServerType != nil { + m["ServerType"] = formatNodeType(*cfg.ServerType) + } + return m +} + +func modifySpecNodeConfigToMap(cfg tidb.ModifyTiDBClusterUhostSpecsParamNodeConfig) map[string]interface{} { + m := map[string]interface{}{} + if cfg.ConfigId != nil { + m["ConfigId"] = *cfg.ConfigId + } + if cfg.ServerType != nil { + m["ServerType"] = formatNodeType(*cfg.ServerType) + } + return m +} diff --git a/products/utidb/internal/tidb/errors.go b/products/utidb/internal/tidb/errors.go new file mode 100644 index 0000000000..607b3f23bc --- /dev/null +++ b/products/utidb/internal/tidb/errors.go @@ -0,0 +1,40 @@ +package tidb + +import ( + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// retCodeHints maps TiDB API RetCode to user-facing hints when Message is empty +// or too terse. Keep product-specific codes here rather than global CLI error handling. +var retCodeHints = map[int]string{ + 202555: "backup databases is empty (备份数据库为空库)", +} + +func retCodeHint(code int) string { + return retCodeHints[code] +} + +// enrichAPIError returns an error with a clearer Message for known TiDB RetCodes. +func enrichAPIError(err error) error { + uErr, ok := err.(uerr.Error) + if !ok || uErr.Code() == 0 { + return err + } + hint := retCodeHint(uErr.Code()) + if hint == "" { + return err + } + msg := uErr.Message() + if msg == "" { + msg = hint + } else { + msg = msg + "; " + hint + } + return uerr.NewServerCodeError(uErr.Code(), msg) +} + +func handleAPIError(ctx *cli.Context, err error) { + ctx.HandleError(enrichAPIError(err)) +} diff --git a/products/utidb/internal/tidb/errors_test.go b/products/utidb/internal/tidb/errors_test.go new file mode 100644 index 0000000000..184f57a1f3 --- /dev/null +++ b/products/utidb/internal/tidb/errors_test.go @@ -0,0 +1,34 @@ +package tidb + +import ( + "strings" + "testing" + + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" +) + +func TestEnrichAPIError_202555(t *testing.T) { + err := uerr.NewServerCodeError(202555, "") + got := enrichAPIError(err) + uErr, ok := got.(uerr.Error) + if !ok { + t.Fatalf("want uerr.Error, got %T", got) + } + if uErr.Code() != 202555 { + t.Fatalf("code = %d, want 202555", uErr.Code()) + } + if !strings.Contains(uErr.Message(), "backup databases is empty") { + t.Fatalf("message = %q, want empty-db hint", uErr.Message()) + } + if !strings.Contains(uErr.Message(), "备份数据库为空库") { + t.Fatalf("message = %q, want Chinese hint", uErr.Message()) + } +} + +func TestEnrichAPIError_unknownCode(t *testing.T) { + err := uerr.NewServerCodeError(999999, "original") + got := enrichAPIError(err) + if got != err { + t.Fatal("unknown code should be unchanged") + } +} diff --git a/products/utidb/internal/tidb/help.go b/products/utidb/internal/tidb/help.go new file mode 100644 index 0000000000..7c0a6a7029 --- /dev/null +++ b/products/utidb/internal/tidb/help.go @@ -0,0 +1,46 @@ +package tidb + +const ( + helpUTiDBRoot = `Manage UTiDB (TiDB) cluster instances on UCloud. + +Common enums: + ServerType (node type): tidb, tikv, pd, tiflash (case-insensitive; sent to API as Tidb/Tikv/Pd/Tiflash) + ScaleType: SCALEOUT (expand), SCALEIN (shrink; scale-node/resize-disk only) + ChargeType: Month, Year, Dynamic, Trial + DTType: 10 (same AZ), 20 (cross AZ) + DbVersion: e.g. v8.5.1, v8.5.6 (use list-specs to see available specs per region)` + + helpCreateLong = `Create a UTiDB instance. + +Repeat --node-config for each node type. Example: + --node-config 'ConfigId=tidb_2c_4g,DiskSize=100,NodeCount=3,ServerType=tidb' + --node-config 'ConfigId=tikv_4c_16g,DiskSize=200,NodeCount=3,ServerType=tikv' + --node-config 'ConfigId=pd_2c_4g,DiskSize=50,NodeCount=3,ServerType=pd' + +Use 'utidb list-specs --node-types tidb,tikv,pd' to discover ConfigId values.` + + helpScaleNodeLong = `Scale nodes of a UTiDB instance. + +ScaleType: + SCALEOUT Expand nodes; NodeCount is the target total count after scaling. + SCALEIN Shrink nodes; NodeCount is the target total count after scaling. + Requires --server-id of the node to remove (use tab completion or GetTiDBClusterService). + +Example SCALEOUT: --scale-type SCALEOUT --node-config 'ConfigId=tikv_4c_16g,NodeCount=4,ServerType=tikv' +Example SCALEIN: --scale-type SCALEIN --server-id --node-config 'ConfigId=tikv_4c_16g,NodeCount=3,ServerType=tikv'` + + helpResizeDiskLong = `Resize disk of a UTiDB instance. + +ScaleType: SCALEOUT or SCALEIN (disk expansion or shrink per node type). +Example: --scale-type SCALEOUT --node-config 'DiskSize=300,ServerType=tikv'` + + helpModifySpecLong = `Modify uhost specs of a UTiDB instance. + +Example: --node-config 'ConfigId=tidb_4c_8g,ServerType=tidb' +Use 'utidb list-specs' to discover ConfigId values per node type.` + + helpListSpecsLong = `List available uhost specs for UTiDB node types. + +Node types: tidb, tikv, pd, tiflash (comma-separated). +Example: --node-types tidb,tikv,pd` +) diff --git a/products/utidb/internal/tidb/list.go b/products/utidb/internal/tidb/list.go new file mode 100644 index 0000000000..1c7b37d7d2 --- /dev/null +++ b/products/utidb/internal/tidb/list.go @@ -0,0 +1,52 @@ +package tidb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList ucloud utidb list. +// ListTiDBClusterService Limit/Offset are *string in the SDK, so +// ctx.BindCommonParams (BindLimit expects *int) cannot be used here. +func newList(ctx *cli.Context) *cobra.Command { + var limit, offset string + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewListTiDBClusterServiceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List UTiDB instances", + Long: "List UTiDB instances", + Run: func(c *cobra.Command, args []string) { + if limit != "" { + req.Limit = sdk.String(limit) + } + if offset != "" { + req.Offset = sdk.String(offset) + } + resp, err := client.ListTiDBClusterService(req) + if err != nil { + handleAPIError(ctx, err) + return + } + rows := []instanceRow{} + for _, d := range resp.Data { + rows = append(rows, newInstanceRowFromData(d)) + } + ctx.PrintList(rows) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.StringVar(&limit, "limit", "", "Optional. The maximum number of resources per page") + flags.StringVar(&offset, "offset", "", "Optional. The index of resource which start to list") + + return cmd +} diff --git a/products/utidb/internal/tidb/list_backup.go b/products/utidb/internal/tidb/list_backup.go new file mode 100644 index 0000000000..c69805d5b2 --- /dev/null +++ b/products/utidb/internal/tidb/list_backup.go @@ -0,0 +1,56 @@ +package tidb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListBackup ucloud utidb list-backup +func newListBackup(ctx *cli.Context) *cobra.Command { + var id string + var limit, offset int + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewListTiDBClusterBackupRequest() + + cmd := &cobra.Command{ + Use: "list-backup", + Short: "List backups of a UTiDB instance", + Long: "List backups of a UTiDB instance", + Run: func(c *cobra.Command, args []string) { + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Id": ctx.PickResourceID(id), + "Limit": limit, + "Offset": offset, + }) + payload, err := invokeAPI(ctx, "ListTiDBClusterBackup", params) + if err != nil { + handleAPIError(ctx, err) + return + } + ctx.PrintList(parseBackupRowsFromPayload(payload)) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + flags.IntVar(&limit, "limit", 30, "Optional. The maximum number of resources per page") + flags.IntVar(&offset, "offset", 0, "Optional. The index of resource which start to list") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/list_specs.go b/products/utidb/internal/tidb/list_specs.go new file mode 100644 index 0000000000..75a45cc540 --- /dev/null +++ b/products/utidb/internal/tidb/list_specs.go @@ -0,0 +1,58 @@ +package tidb + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListSpecs ucloud utidb list-specs +func newListSpecs(ctx *cli.Context) *cobra.Command { + var nodeTypes string + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewGetTiDBClusterUhostSpecsRequest() + + cmd := &cobra.Command{ + Use: "list-specs", + Short: "List available uhost specs", + Long: helpListSpecsLong, + Run: func(c *cobra.Command, args []string) { + types := strings.Split(nodeTypes, ",") + for i := range types { + types[i] = strings.TrimSpace(types[i]) + } + specs, err := getTiDBClusterUhostSpecs(ctx, req.GetRegion(), req.GetZone(), req.GetProjectId(), types) + if err != nil { + handleAPIError(ctx, err) + return + } + rows := []specRow{} + for _, s := range specs { + rows = append(rows, newSpecRowFromData(s)) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&nodeTypes, "node-types", "", "Required. Comma-separated node types: tidb, tikv, pd, tiflash") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("node-types") + command.SetCompletion(cmd, "node-types", func() []string { + return listNodeTypes(ctx, req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/modify_spec.go b/products/utidb/internal/tidb/modify_spec.go new file mode 100644 index 0000000000..359f7b3a9e --- /dev/null +++ b/products/utidb/internal/tidb/modify_spec.go @@ -0,0 +1,111 @@ +package tidb + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseModifySpecNodeConfig parses the CLI node-config string for modify-spec. +// Format: ConfigId=xxx,ServerType=tidb +func parseModifySpecNodeConfig(s string) (tidb.ModifyTiDBClusterUhostSpecsParamNodeConfig, error) { + var cfg tidb.ModifyTiDBClusterUhostSpecsParamNodeConfig + parts := strings.Split(s, ",") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + return cfg, fmt.Errorf("invalid node-config segment %q, expected key=value", part) + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "ConfigId": + cfg.ConfigId = sdk.String(val) + case "ServerType": + cfg.ServerType = sdk.String(val) + default: + return cfg, fmt.Errorf("unknown node-config key %q", key) + } + } + if cfg.ConfigId == nil || cfg.ServerType == nil { + return cfg, fmt.Errorf("node-config must include ConfigId and ServerType") + } + if err := validateServerType(*cfg.ServerType); err != nil { + return cfg, err + } + return cfg, nil +} + +// newModifySpec ucloud utidb modify-spec +func newModifySpec(ctx *cli.Context) *cobra.Command { + var id, nodeConfig string + var startTime int + var async bool + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewModifyTiDBClusterUhostSpecsRequest() + + cmd := &cobra.Command{ + Use: "modify-spec", + Short: "Modify uhost specs of a UTiDB instance", + Long: helpModifySpecLong, + Run: func(c *cobra.Command, args []string) { + cfg, err := parseModifySpecNodeConfig(nodeConfig) + if err != nil { + ctx.HandleError(err) + return + } + + pickedID := ctx.PickResourceID(id) + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Id": pickedID, + }) + params["NodeConfig"] = modifySpecNodeConfigToMap(cfg) + if startTime != 0 { + params["StartTime"] = startTime + } + + _, err = invokeAPI(ctx, "ModifyTiDBClusterUhostSpecs", params) + if err != nil { + handleAPIError(ctx, err) + return + } + + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "utidb[%s] is modifying spec\n", pickedID) + } else { + text := fmt.Sprintf("utidb[%s] is modifying spec", pickedID) + spollUpgrade(ctx, w, req.GetRegion(), req.GetZone(), req.GetProjectId(), pickedID, text) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: pickedID, Action: "modify-spec", Status: "Modifying"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + flags.StringVar(&nodeConfig, "node-config", "", "Required. ConfigId=xxx,ServerType=tidb|tikv|pd|tiflash") + flags.IntVar(&startTime, "start-time", 0, "Optional. Task start time") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for modify-spec to finish") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + cmd.MarkFlagRequired("node-config") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/parse.go b/products/utidb/internal/tidb/parse.go new file mode 100644 index 0000000000..2f5dd971d7 --- /dev/null +++ b/products/utidb/internal/tidb/parse.go @@ -0,0 +1,107 @@ +package tidb + +import ( + "fmt" + "strings" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" +) + +func parseUhostSpecsFromPayload(payload map[string]interface{}) []tidb.UhostSpecs { + data, ok := payload["Data"].([]interface{}) + if !ok { + return nil + } + specs := make([]tidb.UhostSpecs, 0, len(data)) + for _, item := range data { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + specs = append(specs, tidb.UhostSpecs{ + ConfigId: stringVal(m["ConfigId"]), + ConfigName: stringVal(m["ConfigName"]), + NodeType: stringVal(m["NodeType"]), + CoreNum: intVal(m["CoreNum"]), + Memory: intVal(m["Memory"]), + MinDiskCapacity: intVal(m["MinDiskCapacity"]), + MaxDiskCapacity: intVal(m["MaxDiskCapacity"]), + DiskStep: intVal(m["DiskStep"]), + }) + } + return specs +} + +func parseBackupRowsFromPayload(payload map[string]interface{}) []backupRow { + data, ok := payload["Data"].([]interface{}) + if !ok { + return nil + } + rows := make([]backupRow, 0, len(data)) + for _, item := range data { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, backupRow{ + BackupID: stringVal(m["BackupId"]), + BackupType: stringVal(m["BackupType"]), + State: stringVal(m["State"]), + BackupSize: intVal(m["BackupSize"]), + BackupStartTime: intVal(m["BackupStartTime"]), + BackupEndTime: intVal(m["BackupEndTime"]), + }) + } + return rows +} + +var clusterServerKeys = []string{"TiDBServers", "TiKVServers", "PDServers", "TiFlashServers"} + +func extractServerIDs(payload map[string]interface{}) []string { + data, _ := payload["Data"].(map[string]interface{}) + if data == nil { + return nil + } + cluster, _ := data["TiDBCluster"].(map[string]interface{}) + if cluster == nil { + cluster = data + } + var ids []string + for _, key := range clusterServerKeys { + servers, _ := cluster[key].([]interface{}) + for _, item := range servers { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + serverID := stringVal(m["ServerId"]) + if serverID == "" { + continue + } + host := stringVal(m["HostIp"]) + nodeType := strings.TrimSuffix(key, "Servers") + if host != "" { + ids = append(ids, fmt.Sprintf("%s/%s@%s", serverID, strings.ToLower(nodeType), host)) + } else { + ids = append(ids, fmt.Sprintf("%s/%s", serverID, strings.ToLower(nodeType))) + } + } + } + return ids +} + +func stringVal(v interface{}) string { + s, _ := v.(string) + return s +} + +func intVal(v interface{}) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + default: + return 0 + } +} diff --git a/products/utidb/internal/tidb/poll.go b/products/utidb/internal/tidb/poll.go new file mode 100644 index 0000000000..1c67c6357b --- /dev/null +++ b/products/utidb/internal/tidb/poll.go @@ -0,0 +1,54 @@ +package tidb + +import ( + "io" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func createPollTargets() []string { + return []string{stateAvailable, stateRunning, stateCreateFail} +} + +func upgradePollTargets() []string { + return []string{stateAvailable, stateRunning, stateUpgradeFail} +} + +func spollCreate(ctx *cli.Context, w io.Writer, region, zone, projectID, id, text string) { + ctx.PollerTo(w, describeByID(ctx, region, zone, projectID)). + Spoll(id, text, createPollTargets()) +} + +func spollUpgrade(ctx *cli.Context, w io.Writer, region, zone, projectID, id, text string) { + ctx.PollerTo(w, describeByID(ctx, region, zone, projectID)). + Spoll(id, text, upgradePollTargets()) +} + +// describeByID returns a poller function that reads a UTiDB instance by ID. +// The returned data is a pointer to UTiDBServiceData so the poller can read its +// State field via reflection. +func describeByID(ctx *cli.Context, region, zone, projectID string) func(string, *request.CommonBase) (interface{}, error) { + return func(id string, _ *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewGetTiDBClusterServiceRequest() + if region != "" { + req.Region = sdk.String(region) + } + if zone != "" { + req.Zone = sdk.String(zone) + } + if projectID != "" { + req.ProjectId = sdk.String(projectID) + } + req.Id = sdk.String(id) + resp, err := client.GetTiDBClusterService(req) + if err != nil { + return nil, err + } + return &resp.Data, nil + } +} diff --git a/products/utidb/internal/tidb/resize_disk.go b/products/utidb/internal/tidb/resize_disk.go new file mode 100644 index 0000000000..3175bb0d72 --- /dev/null +++ b/products/utidb/internal/tidb/resize_disk.go @@ -0,0 +1,120 @@ +package tidb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseResizeDiskNodeConfig parses the CLI node-config string for resize-disk. +// Format: DiskSize=N,ServerType=tidb +func parseResizeDiskNodeConfig(s string) (tidb.ModifyTiDBClusterUhostDiskParamNodeConfig, error) { + var cfg tidb.ModifyTiDBClusterUhostDiskParamNodeConfig + parts := strings.Split(s, ",") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + return cfg, fmt.Errorf("invalid node-config segment %q, expected key=value", part) + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "DiskSize": + n, err := strconv.Atoi(val) + if err != nil { + return cfg, fmt.Errorf("invalid DiskSize %q: %w", val, err) + } + cfg.DiskSize = sdk.Int(n) + case "ServerType": + cfg.ServerType = sdk.String(val) + default: + return cfg, fmt.Errorf("unknown node-config key %q", key) + } + } + if cfg.DiskSize == nil || cfg.ServerType == nil { + return cfg, fmt.Errorf("node-config must include DiskSize and ServerType") + } + if err := validateServerType(*cfg.ServerType); err != nil { + return cfg, err + } + return cfg, nil +} + +// newResizeDisk ucloud utidb resize-disk +func newResizeDisk(ctx *cli.Context) *cobra.Command { + var id, scaleType, nodeConfig string + var startTime int + var async bool + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewModifyTiDBClusterUhostDiskRequest() + + cmd := &cobra.Command{ + Use: "resize-disk", + Short: "Resize disk of a UTiDB instance", + Long: helpResizeDiskLong, + Run: func(c *cobra.Command, args []string) { + cfg, err := parseResizeDiskNodeConfig(nodeConfig) + if err != nil { + ctx.HandleError(err) + return + } + + pickedID := ctx.PickResourceID(id) + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Id": pickedID, + "ScaleType": scaleType, + }) + params["NodeConfig"] = resizeDiskNodeConfigToMap(cfg) + if startTime != 0 { + params["StartTime"] = startTime + } + + _, err = invokeAPI(ctx, "ModifyTiDBClusterUhostDisk", params) + if err != nil { + handleAPIError(ctx, err) + return + } + + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "utidb[%s] is resizing disk\n", pickedID) + } else { + text := fmt.Sprintf("utidb[%s] is resizing disk", pickedID) + spollUpgrade(ctx, w, req.GetRegion(), req.GetZone(), req.GetProjectId(), pickedID, text) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: pickedID, Action: "resize-disk", Status: "Resizing"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + flags.StringVar(&scaleType, "scale-type", "", "Required. SCALEOUT (expand disk) or SCALEIN (shrink disk)") + flags.StringVar(&nodeConfig, "node-config", "", "Required. DiskSize=N,ServerType=tidb|tikv|pd|tiflash") + flags.IntVar(&startTime, "start-time", 0, "Optional. Task start time") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for resize to finish") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + cmd.MarkFlagRequired("scale-type") + cmd.MarkFlagRequired("node-config") + command.SetFlagValues(cmd, "scale-type", "SCALEOUT", "SCALEIN") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/rows.go b/products/utidb/internal/tidb/rows.go new file mode 100644 index 0000000000..83161275a8 --- /dev/null +++ b/products/utidb/internal/tidb/rows.go @@ -0,0 +1,69 @@ +package tidb + +import ( + "github.com/ucloud/ucloud-sdk-go/services/tidb" +) + +// instanceRow is the table row for a UTiDB instance. +type instanceRow struct { + ID string + Name string + State string + DTType int + Port int + IP string + Version string + CreateTime int + VPCID string + SubnetID string +} + +func newInstanceRowFromData(d tidb.UTiDBServiceData) instanceRow { + return instanceRow{ + ID: d.Id, + Name: d.Name, + State: d.State, + DTType: d.DTType, + Port: d.Port, + IP: d.Ip, + Version: d.Version, + CreateTime: d.CreateTime, + VPCID: d.VPCId, + SubnetID: d.SubnetId, + } +} + +// backupRow is the table row for a UTiDB backup. +type backupRow struct { + BackupID string + BackupType string + State string + BackupSize int + BackupStartTime int + BackupEndTime int +} + +// specRow is the table row for a UTiDB uhost spec. +type specRow struct { + ConfigID string + ConfigName string + NodeType string + CoreNum int + Memory int + MinDiskCapacity int + MaxDiskCapacity int + DiskStep int +} + +func newSpecRowFromData(d tidb.UhostSpecs) specRow { + return specRow{ + ConfigID: d.ConfigId, + ConfigName: d.ConfigName, + NodeType: d.NodeType, + CoreNum: d.CoreNum, + Memory: d.Memory, + MinDiskCapacity: d.MinDiskCapacity, + MaxDiskCapacity: d.MaxDiskCapacity, + DiskStep: d.DiskStep, + } +} diff --git a/products/utidb/internal/tidb/scale_node.go b/products/utidb/internal/tidb/scale_node.go new file mode 100644 index 0000000000..6a81a215c5 --- /dev/null +++ b/products/utidb/internal/tidb/scale_node.go @@ -0,0 +1,134 @@ +package tidb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseScaleNodeConfig parses the CLI node-config string for scale-node. +// Format: ConfigId=xxx,NodeCount=N,ServerType=tidb +func parseScaleNodeConfig(s string) (tidb.ModifyTiDBClusterNodeParamNodeConfig, error) { + var cfg tidb.ModifyTiDBClusterNodeParamNodeConfig + parts := strings.Split(s, ",") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + return cfg, fmt.Errorf("invalid node-config segment %q, expected key=value", part) + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "ConfigId": + cfg.ConfigId = sdk.String(val) + case "NodeCount": + n, err := strconv.Atoi(val) + if err != nil { + return cfg, fmt.Errorf("invalid NodeCount %q: %w", val, err) + } + cfg.NodeCount = sdk.Int(n) + case "ServerType": + cfg.ServerType = sdk.String(val) + default: + return cfg, fmt.Errorf("unknown node-config key %q", key) + } + } + if cfg.ConfigId == nil || cfg.NodeCount == nil || cfg.ServerType == nil { + return cfg, fmt.Errorf("node-config must include ConfigId, NodeCount and ServerType") + } + if err := validateServerType(*cfg.ServerType); err != nil { + return cfg, err + } + return cfg, nil +} + +// newScaleNode ucloud utidb scale-node +func newScaleNode(ctx *cli.Context) *cobra.Command { + var id, scaleType, nodeConfig, serverID string + var startTime int + var async bool + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewModifyTiDBClusterNodeRequest() + + cmd := &cobra.Command{ + Use: "scale-node", + Short: "Scale nodes of a UTiDB instance (SCALEOUT/SCALEIN)", + Long: helpScaleNodeLong, + Run: func(c *cobra.Command, args []string) { + if scaleType == "SCALEIN" && serverID == "" { + ctx.HandleError(fmt.Errorf("server-id is required when scale-type is SCALEIN")) + return + } + + cfg, err := parseScaleNodeConfig(nodeConfig) + if err != nil { + ctx.HandleError(err) + return + } + + pickedID := ctx.PickResourceID(id) + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Id": pickedID, + "ScaleType": scaleType, + }) + params["NodeConfig"] = scaleNodeConfigToMap(cfg) + if serverID != "" { + params["ServerId"] = serverID + } + if startTime != 0 { + params["StartTime"] = startTime + } + + _, err = invokeAPI(ctx, "ModifyTiDBClusterNode", params) + if err != nil { + handleAPIError(ctx, err) + return + } + + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "utidb[%s] is scaling nodes\n", pickedID) + } else { + text := fmt.Sprintf("utidb[%s] is scaling nodes", pickedID) + spollUpgrade(ctx, w, req.GetRegion(), req.GetZone(), req.GetProjectId(), pickedID, text) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: pickedID, Action: "scale-node", Status: "Scaling"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + flags.StringVar(&scaleType, "scale-type", "", "Required. SCALEOUT (expand) or SCALEIN (shrink; requires --server-id)") + flags.StringVar(&nodeConfig, "node-config", "", "Required. ConfigId=xxx,NodeCount=N,ServerType=tidb|tikv|pd|tiflash (target count after scale)") + flags.StringVar(&serverID, "server-id", "", "Required for SCALEIN. Server ID of the node to remove (tab completion lists cluster nodes)") + flags.IntVar(&startTime, "start-time", 0, "Optional. Task start time") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for scaling to finish") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + cmd.MarkFlagRequired("scale-type") + cmd.MarkFlagRequired("node-config") + command.SetFlagValues(cmd, "scale-type", "SCALEOUT", "SCALEIN") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + command.SetCompletion(cmd, "server-id", func() []string { + return listServerIDs(ctx, req.GetRegion(), req.GetZone(), req.GetProjectId(), ctx.PickResourceID(id)) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/status.go b/products/utidb/internal/tidb/status.go new file mode 100644 index 0000000000..d06c4eeb7e --- /dev/null +++ b/products/utidb/internal/tidb/status.go @@ -0,0 +1,14 @@ +package tidb + +// UTiDB domain state constants used by pollers / EmitResult. +// Terminal states validated on prod (cn-bj2): Available after create/scale/resize; +// delete removes the instance (poll may hit Deleted or describe error depending on timing). +const ( + stateAvailable = "Available" + stateRunning = "Running" + stateDeleted = "Deleted" + stateCreateFail = "CreateFailed" + stateDeleteFail = "DeleteFailed" + stateBackingUp = "BackingUp" + stateUpgradeFail = "UpgradeFailed" +) diff --git a/products/utidb/internal/tidb/validation.go b/products/utidb/internal/tidb/validation.go new file mode 100644 index 0000000000..8358610493 --- /dev/null +++ b/products/utidb/internal/tidb/validation.go @@ -0,0 +1,23 @@ +package tidb + +import ( + "fmt" + "strings" +) + +// serverTypeValues lists accepted ServerType values (CLI input, case-insensitive). +var serverTypeValues = []string{"tidb", "tikv", "pd", "tiflash"} + +func helpServerTypes() string { + return strings.Join(serverTypeValues, ", ") +} + +func validateServerType(serverType string) error { + st := strings.ToLower(strings.TrimSpace(serverType)) + for _, v := range serverTypeValues { + if st == v { + return nil + } + } + return fmt.Errorf("invalid ServerType %q: must be one of %s", serverType, helpServerTypes()) +} diff --git a/products/utidb/internal/tidb/validation_test.go b/products/utidb/internal/tidb/validation_test.go new file mode 100644 index 0000000000..c00f71287a --- /dev/null +++ b/products/utidb/internal/tidb/validation_test.go @@ -0,0 +1,14 @@ +package tidb + +import "testing" + +func TestValidateServerType(t *testing.T) { + for _, st := range []string{"tidb", "TiKV", "PD", "tiflash"} { + if err := validateServerType(st); err != nil { + t.Fatalf("ServerType %q: %v", st, err) + } + } + if err := validateServerType("mysql"); err == nil { + t.Fatal("want error for unknown ServerType") + } +} diff --git a/products/utidb/product.go b/products/utidb/product.go new file mode 100644 index 0000000000..4b99c65052 --- /dev/null +++ b/products/utidb/product.go @@ -0,0 +1,21 @@ +package utidb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/products/utidb/internal/tidb" +) + +type product struct{} + +// New returns the utidb product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "utidb", Commands: []string{"utidb"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{tidb.NewCommand(ctx)} +} diff --git a/products/utidb/product.yaml b/products/utidb/product.yaml new file mode 100644 index 0000000000..97fc057c8f --- /dev/null +++ b/products/utidb/product.yaml @@ -0,0 +1,8 @@ +# products/utidb/product.yaml — UTiDB 产品元数据 +name: utidb +owners: + - xingxingso + - jinfz12 +commands: + - utidb +enabled: true diff --git a/products/utidb/testdata/cmdtree.golden b/products/utidb/testdata/cmdtree.golden new file mode 100644 index 0000000000..0223a1bb21 --- /dev/null +++ b/products/utidb/testdata/cmdtree.golden @@ -0,0 +1,91 @@ +ucloud utidb use=utidb short=Manipulate UTiDB instances on UCloud platform +ucloud utidb backup use=backup short=Start a backup of a UTiDB instance + flag=backup-filter short= default= required= + flag=backup-ts short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= +ucloud utidb create use=create short=Create a UTiDB instance + flag=activity-id short= default=0 required= + flag=alert-strategy-ids short= default=[] required= + flag=async short= default=false required= + flag=charge-type short= default= required=true + flag=coupon short= default= required= + flag=db-version short= default= required= + flag=dt-type short= default= required=true + flag=ip short= default= required= + flag=labels short= default=[] required= + flag=name short= default= required=true + flag=node-config short= default=[] required=true + flag=password short= default= required=true + flag=port short= default= required= + flag=project-id short= default= required= + flag=promotion-id short= default= required= + flag=pub-ulb-id short= default= required= + flag=quantity short= default=1 required=true + flag=region short= default= required= + flag=rule-id short= default=0 required= + flag=sec-group-info short= default=[] required= + flag=subnet-id short= default= required=true + flag=template-id short= default= required= + flag=vpc-id short= default= required=true + flag=zone short= default= required= +ucloud utidb delete use=delete short=Delete a UTiDB instance + flag=async short= default=false required= + flag=delete-backup short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=utidb-id short= default= required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud utidb describe use=describe short=Show details of a UTiDB instance + flag=project-id short= default= required= + flag=region short= default= required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= +ucloud utidb list use=list short=List UTiDB instances + flag=limit short= default= required= + flag=offset short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud utidb list-backup use=list-backup short=List backups of a UTiDB instance + flag=limit short= default=30 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= +ucloud utidb list-specs use=list-specs short=List available uhost specs + flag=node-types short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud utidb modify-spec use=modify-spec short=Modify uhost specs of a UTiDB instance + flag=async short= default=false required= + flag=node-config short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=start-time short= default=0 required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= +ucloud utidb resize-disk use=resize-disk short=Resize disk of a UTiDB instance + flag=async short= default=false required= + flag=node-config short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=scale-type short= default= required=true + flag=start-time short= default=0 required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= +ucloud utidb scale-node use=scale-node short=Scale nodes of a UTiDB instance (SCALEOUT/SCALEIN) + flag=async short= default=false required= + flag=node-config short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=scale-type short= default= required=true + flag=server-id short= default= required= + flag=start-time short= default=0 required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= diff --git a/products/utidb/testdata/completion.golden b/products/utidb/testdata/completion.golden new file mode 100644 index 0000000000..7ab47024b4 --- /dev/null +++ b/products/utidb/testdata/completion.golden @@ -0,0 +1,43 @@ +ucloud utidb backup project-id dynamic +ucloud utidb backup region dynamic +ucloud utidb backup utidb-id dynamic +ucloud utidb backup zone dynamic +ucloud utidb create charge-type static Dynamic,Month,Trial,Year +ucloud utidb create dt-type static 10,20 +ucloud utidb create project-id dynamic +ucloud utidb create region dynamic +ucloud utidb create zone dynamic +ucloud utidb delete project-id dynamic +ucloud utidb delete region dynamic +ucloud utidb delete utidb-id dynamic +ucloud utidb delete zone dynamic +ucloud utidb describe project-id dynamic +ucloud utidb describe region dynamic +ucloud utidb describe utidb-id dynamic +ucloud utidb describe zone dynamic +ucloud utidb list project-id dynamic +ucloud utidb list region dynamic +ucloud utidb list zone dynamic +ucloud utidb list-backup project-id dynamic +ucloud utidb list-backup region dynamic +ucloud utidb list-backup utidb-id dynamic +ucloud utidb list-backup zone dynamic +ucloud utidb list-specs node-types dynamic +ucloud utidb list-specs project-id dynamic +ucloud utidb list-specs region dynamic +ucloud utidb list-specs zone dynamic +ucloud utidb modify-spec project-id dynamic +ucloud utidb modify-spec region dynamic +ucloud utidb modify-spec utidb-id dynamic +ucloud utidb modify-spec zone dynamic +ucloud utidb resize-disk project-id dynamic +ucloud utidb resize-disk region dynamic +ucloud utidb resize-disk scale-type static SCALEIN,SCALEOUT +ucloud utidb resize-disk utidb-id dynamic +ucloud utidb resize-disk zone dynamic +ucloud utidb scale-node project-id dynamic +ucloud utidb scale-node region dynamic +ucloud utidb scale-node scale-type static SCALEIN,SCALEOUT +ucloud utidb scale-node server-id static +ucloud utidb scale-node utidb-id dynamic +ucloud utidb scale-node zone dynamic diff --git a/products/vpc/internal/vpc/cmd.go b/products/vpc/internal/vpc/cmd.go new file mode 100644 index 0000000000..8ecb5722fd --- /dev/null +++ b/products/vpc/internal/vpc/cmd.go @@ -0,0 +1,24 @@ +package vpc + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand returns the ucloud vpc command tree. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "vpc", + Short: "List and manipulate VPC instances", + Long: "List and manipulate VPC instances", + Args: cobra.NoArgs, + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newCreatePeer(ctx)) + cmd.AddCommand(newListPeer(ctx)) + cmd.AddCommand(newDeletePeer(ctx)) + return cmd +} diff --git a/products/vpc/internal/vpc/completion.go b/products/vpc/internal/vpc/completion.go new file mode 100644 index 0000000000..7fb6ddfc23 --- /dev/null +++ b/products/vpc/internal/vpc/completion.go @@ -0,0 +1,33 @@ +package vpc + +import ( + "fmt" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func getAllVPCIns(ctx *cli.Context, project, region string) ([]vpcsdk.VPCInfo, error) { + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + vpcInsList, err := getAllVPCIns(ctx, project, region) + list := []string{} + if err != nil { + return nil + } + for _, vpc := range vpcInsList { + list = append(list, fmt.Sprintf("%s/%s", vpc.VPCId, vpc.Name)) + } + return list +} diff --git a/products/vpc/internal/vpc/create.go b/products/vpc/internal/vpc/create.go new file mode 100644 index 0000000000..cb14f0435c --- /dev/null +++ b/products/vpc/internal/vpc/create.go @@ -0,0 +1,51 @@ +package vpc + +import ( + "fmt" + + "github.com/spf13/cobra" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newCreate returns ucloud vpc create. +func newCreate(ctx *cli.Context) *cobra.Command { + var segments *[]string + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewCreateVPCRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create vpc network", + Long: "Create vpc network", + Example: "ucloud vpc create --name xxx --segment 192.168.0.0/16", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + req.Network = *segments + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + resp, err := client.CreateVPC(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "vpc[%s] created\n", resp.VPCId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.VPCId, Action: "create", Status: "Created"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + req.Name = flags.String("name", "", "Required. Name of the vpc network.") + segments = flags.StringSlice("segment", nil, "Required. The segment for private network.") + req.Tag = flags.String("group", "", "Optional. Business group.") + req.Remark = flags.String("remark", "", "Optional. The description of the vpc.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("segment") + + return cmd +} diff --git a/products/vpc/internal/vpc/delete.go b/products/vpc/internal/vpc/delete.go new file mode 100644 index 0000000000..0b77092b7e --- /dev/null +++ b/products/vpc/internal/vpc/delete.go @@ -0,0 +1,56 @@ +package vpc + +import ( + "fmt" + + "github.com/spf13/cobra" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete returns ucloud vpc delete. +func newDelete(ctx *cli.Context) *cobra.Command { + idNames := []string{} + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDeleteVPCRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete vpc network", + Long: "Delete vpc network", + Example: "ucloud vpc delete --vpc-id uvnet-xxx", + Run: func(cmd *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + req.VPCId = sdk.String(id) + _, err := client.DeleteVPC(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "vpc[%s] deleted\n", idname) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + cmd.Flags().SortFlags = false + + cmd.Flags().StringSliceVar(&idNames, "vpc-id", nil, "Required. Resource ID of the vpc network to delete") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("vpc-id") + + return cmd +} diff --git a/products/vpc/internal/vpc/list.go b/products/vpc/internal/vpc/list.go new file mode 100644 index 0000000000..6c9795e19d --- /dev/null +++ b/products/vpc/internal/vpc/list.go @@ -0,0 +1,61 @@ +package vpc + +import ( + "strings" + + "github.com/spf13/cobra" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList returns ucloud vpc list. +func newList(ctx *cli.Context) *cobra.Command { + vpcIDs := []string{} + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDescribeVPCRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List vpc", + Long: "List vpc", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + req.VPCIds = nil + for _, id := range vpcIDs { + req.VPCIds = append(req.VPCIds, ctx.PickResourceID(id)) + } + resp, err := client.DescribeVPC(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []Row{} + for _, vpc := range resp.DataSet { + row := Row{} + row.VPCName = vpc.Name + row.ResourceID = vpc.VPCId + row.Group = vpc.Tag + row.NetworkSegment = strings.Join(vpc.Network, ",") + row.SubnetCount = vpc.SubnetCount + row.CreationTime = common.FormatDate(vpc.CreateTime) + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + req.Tag = flags.String("group", "", "Optional. Group") + flags.StringSliceVar(&vpcIDs, "vpc-id", []string{}, "Optional. Multiple values separated by commas") + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/vpc/internal/vpc/peer_create.go b/products/vpc/internal/vpc/peer_create.go new file mode 100644 index 0000000000..f27517dab2 --- /dev/null +++ b/products/vpc/internal/vpc/peer_create.go @@ -0,0 +1,63 @@ +package vpc + +import ( + "fmt" + + "github.com/spf13/cobra" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreatePeer returns ucloud vpc create-intercome. +func newCreatePeer(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewCreateVPCIntercomRequest() + cmd := &cobra.Command{ + Use: "create-intercome", + Short: "Create intercome with other vpc", + Long: "Create intercome with other vpc", + Example: "ucloud vpc create-intercome --vpc-id xx --dst-vpc-id xx --dst-region xx", + Run: func(cmd *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.DstProjectId = sdk.String(ctx.PickResourceID(*req.DstProjectId)) + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.DstVPCId = sdk.String(ctx.PickResourceID(*req.DstVPCId)) + _, err := client.CreateVPCIntercom(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "intercome [%s<-->%s] establish", *req.VPCId, *req.DstVPCId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.VPCId, Action: "create-intercome", Status: "Created"}) + }, + } + + cmd.Flags().SortFlags = false + + req.VPCId = cmd.Flags().String("vpc-id", "", "Required. The source vpc you want to establish the intercome") + req.DstVPCId = cmd.Flags().String("dst-vpc-id", "", "Required. The target vpc you want to establish the intercome") + req.DstRegion = cmd.Flags().String("dst-region", ctx.DefaultRegion(), "Required. If the intercome established across different regions") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optioanl. The region of source vpc which will establish the intercome") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. The project id of the source vpc") + req.DstProjectId = cmd.Flags().String("dst-project-id", ctx.DefaultProjectID(), "Optional. The project id of the source vpc") + + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("dst-vpc-id") + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "dst-vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.DstProjectId, *req.DstRegion) + }) + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "dst-region", ctx.RegionList) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "dst-project-id", ctx.ProjectList) + + return cmd +} diff --git a/products/vpc/internal/vpc/peer_delete.go b/products/vpc/internal/vpc/peer_delete.go new file mode 100644 index 0000000000..13561d3a8a --- /dev/null +++ b/products/vpc/internal/vpc/peer_delete.go @@ -0,0 +1,55 @@ +package vpc + +import ( + "fmt" + + "github.com/spf13/cobra" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDeletePeer returns ucloud vpc delete-intercome. +func newDeletePeer(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDeleteVPCIntercomRequest() + cmd := &cobra.Command{ + Use: "delete-intercome", + Short: "delete the vpc intercome", + Long: "delete the vpc intercome", + Example: "ucloud vpc delete-intercome --vpc-id xxx --dst-vpc-id xxx", + Run: func(cmd *cobra.Command, args []string) { + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.DstVPCId = sdk.String(ctx.PickResourceID(*req.DstVPCId)) + _, err := client.DeleteVPCIntercom(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "intercome [%s<-->%s] deleted\n", *req.VPCId, *req.DstVPCId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.VPCId, Action: "delete-intercome", Status: "Deleted"}) + }, + } + + cmd.Flags().SortFlags = false + + req.VPCId = cmd.Flags().String("vpc-id", "", "Required. Resource ID of source VPC to disconnect with destination VPC") + req.DstVPCId = cmd.Flags().String("dst-vpc-id", "", "Required. Resource ID of destination VPC to disconnect with source VPC") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. The project id of source vpc") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. The region of source vpc to disconnect") + req.DstRegion = cmd.Flags().String("dst-region", "", "Optional. The region of dest vpc to disconnect") + + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("dst-vpc-id") + cmd.MarkFlagRequired("dst-region") + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "dst-region", ctx.RegionList) + + return cmd +} diff --git a/products/vpc/internal/vpc/peer_list.go b/products/vpc/internal/vpc/peer_list.go new file mode 100644 index 0000000000..9cdbeab1da --- /dev/null +++ b/products/vpc/internal/vpc/peer_list.go @@ -0,0 +1,58 @@ +package vpc + +import ( + "strings" + + "github.com/spf13/cobra" + + vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListPeer returns ucloud vpc list-intercome. +func newListPeer(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, vpcsdk.NewClient) + req := client.NewDescribeVPCIntercomRequest() + cmd := &cobra.Command{ + Use: "list-intercome", + Short: "list intercome ", + Long: "list intercome", + Example: "ucloud vpc list-intercome --vpc-id xx", + Run: func(cmd *cobra.Command, args []string) { + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + resp, err := client.DescribeVPCIntercom(req) + if err != nil { + ctx.HandleError(err) + return + } + list := make([]IntercomRow, 0) + for _, vpcIntercom := range resp.DataSet { + row := IntercomRow{} + row.ProjectID = vpcIntercom.ProjectId + row.Segments = strings.Join(vpcIntercom.Network, ",") + row.DstRegion = vpcIntercom.DstRegion + row.VPCName = vpcIntercom.Name + row.ResourceID = vpcIntercom.VPCId + row.Group = vpcIntercom.Tag + list = append(list, row) + } + ctx.PrintList(list) + }, + } + req.VPCId = cmd.Flags().String("vpc-id", "", "Required. The vpc id which you wnat to describe the information") + req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. The project id of source vpc") + req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional, The region of source vpc") + + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + + cmd.MarkFlagRequired("vpc-id") + + return cmd +} diff --git a/products/vpc/internal/vpc/rows.go b/products/vpc/internal/vpc/rows.go new file mode 100644 index 0000000000..96973e8eb7 --- /dev/null +++ b/products/vpc/internal/vpc/rows.go @@ -0,0 +1,19 @@ +package vpc + +type Row struct { + VPCName string + ResourceID string + Group string + NetworkSegment string + SubnetCount int + CreationTime string +} + +type IntercomRow struct { + VPCName string + ResourceID string + Segments string + ProjectID string + DstRegion string + Group string +} diff --git a/products/vpc/product.go b/products/vpc/product.go new file mode 100644 index 0000000000..e7fda062fb --- /dev/null +++ b/products/vpc/product.go @@ -0,0 +1,20 @@ +package vpc + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalvpc "github.com/ucloud/ucloud-cli/products/vpc/internal/vpc" +) + +type product struct{} + +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "vpc", Commands: []string{"vpc"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalvpc.NewCommand(ctx)} +} diff --git a/products/vpc/product.yaml b/products/vpc/product.yaml new file mode 100644 index 0000000000..831c06f2d1 --- /dev/null +++ b/products/vpc/product.yaml @@ -0,0 +1,6 @@ +name: vpc +owners: + - Episkey-G +commands: + - vpc +enabled: true diff --git a/products/vpc/testdata/cmdtree.golden b/products/vpc/testdata/cmdtree.golden new file mode 100644 index 0000000000..bb4333254d --- /dev/null +++ b/products/vpc/testdata/cmdtree.golden @@ -0,0 +1,34 @@ +ucloud vpc use=vpc short=List and manipulate VPC instances +ucloud vpc create use=create short=Create vpc network + flag=group short= default= required= + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=segment short= default=[] required=true +ucloud vpc create-intercome use=create-intercome short=Create intercome with other vpc + flag=dst-project-id short= default= required= + flag=dst-region short= default= required= + flag=dst-vpc-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=vpc-id short= default= required=true +ucloud vpc delete use=delete short=Delete vpc network + flag=project-id short= default= required= + flag=region short= default= required= + flag=vpc-id short= default=[] required=true +ucloud vpc delete-intercome use=delete-intercome short=delete the vpc intercome + flag=dst-region short= default= required=true + flag=dst-vpc-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=vpc-id short= default= required=true +ucloud vpc list use=list short=List vpc + flag=group short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=vpc-id short= default=[] required= +ucloud vpc list-intercome use=list-intercome short=list intercome + flag=project-id short= default= required= + flag=region short= default= required= + flag=vpc-id short= default= required=true diff --git a/products/vpc/testdata/completion.golden b/products/vpc/testdata/completion.golden new file mode 100644 index 0000000000..b9d2341b8d --- /dev/null +++ b/products/vpc/testdata/completion.golden @@ -0,0 +1,19 @@ +ucloud vpc create project-id dynamic +ucloud vpc create region dynamic +ucloud vpc create-intercome dst-project-id dynamic +ucloud vpc create-intercome dst-region dynamic +ucloud vpc create-intercome dst-vpc-id dynamic +ucloud vpc create-intercome project-id dynamic +ucloud vpc create-intercome region dynamic +ucloud vpc create-intercome vpc-id dynamic +ucloud vpc delete project-id dynamic +ucloud vpc delete region dynamic +ucloud vpc delete vpc-id dynamic +ucloud vpc delete-intercome dst-region dynamic +ucloud vpc delete-intercome vpc-id dynamic +ucloud vpc list project-id dynamic +ucloud vpc list region dynamic +ucloud vpc list vpc-id dynamic +ucloud vpc list-intercome project-id dynamic +ucloud vpc list-intercome region dynamic +ucloud vpc list-intercome vpc-id dynamic diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000000..c6db60694d --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,9 @@ +# Gauge - metadata dir +.gauge + +# Gauge - log files dir +logs + +# Gauge - reports generated by reporting plugins +reports + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000000..5176ce5588 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,9 @@ +# UCloud CLI Examples & BDD Test Case + +BDD test cases for CLI. + +## QuickStart + +1. `brew install gauge` +2. `gauge install go` +3. `gauge run specs/` diff --git a/tests/env/default/default.properties b/tests/env/default/default.properties new file mode 100644 index 0000000000..00b43aee18 --- /dev/null +++ b/tests/env/default/default.properties @@ -0,0 +1,28 @@ +# default.properties +# properties set here will be available to the test execution as environment variables + +# sample_key = sample_value + +# The path to the gauge reports directory. Should be either relative to the project directory or an absolute path +gauge_reports_dir = reports + +# Set as false if gauge reports should not be overwritten on each execution. A new time-stamped directory will be created on each execution. +overwrite_reports = true + +# Set to false to disable screenshots on failure in reports. +screenshot_on_failure = true + +# The path to the gauge logs directory. Should be either relative to the project directory or an absolute path +logs_directory = logs + +# Set to true to use multithreading for parallel execution +enable_multithreading = false + +# The path the gauge specifications directory. Takes a comma separated list of specification files/directories. +gauge_specs_dir = specs + +# The default delimiter used read csv files. +csv_delimiter = , + +# Allows steps to be written in multiline +allow_multiline_step = false diff --git a/tests/go.mod b/tests/go.mod new file mode 100644 index 0000000000..286e4bfa14 --- /dev/null +++ b/tests/go.mod @@ -0,0 +1,5 @@ +module tests + +go 1.16 + +require github.com/getgauge-contrib/gauge-go v0.2.0 diff --git a/tests/go.sum b/tests/go.sum new file mode 100644 index 0000000000..e192097a9c --- /dev/null +++ b/tests/go.sum @@ -0,0 +1,43 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dmotylev/goproperties v0.0.0-20140630191356-7cbffbaada47 h1:sP2APvSdZpfBiousrppBZNOvu+TE79Myq4kkmmrtSuI= +github.com/dmotylev/goproperties v0.0.0-20140630191356-7cbffbaada47/go.mod h1:f2V6964+f0p8Asqy8mIK5cKyyVc6MP9PFzGVNRcnYJQ= +github.com/getgauge-contrib/gauge-go v0.2.0 h1:UkqEXm+APHC2aswqC9cG9qwEuXNanId/hsOuGiiMx08= +github.com/getgauge-contrib/gauge-go v0.2.0/go.mod h1:6/Aagmhzq0I878fDXqIkrnzvo3aaTuJ5upVhOQTGL48= +github.com/getgauge/common v0.0.0-20160906120419-fce5f398028f h1:VoshOiFVQ0XJMeRh7uYkZc9gkBiIJzztt9ZR+bCB4kY= +github.com/getgauge/common v0.0.0-20160906120419-fce5f398028f/go.mod h1:tHtGp+rvfECQYRDCNQX46uQTO6qHpdDrikX9ZkBjkSA= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/manifest.json b/tests/manifest.json new file mode 100644 index 0000000000..738d20e871 --- /dev/null +++ b/tests/manifest.json @@ -0,0 +1,6 @@ +{ + "Language": "go", + "Plugins": [ + "html-report" + ] +} diff --git a/tests/oauth_cli_matrix.sh b/tests/oauth_cli_matrix.sh new file mode 100755 index 0000000000..5c18747a92 --- /dev/null +++ b/tests/oauth_cli_matrix.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# tests/oauth_cli_matrix.sh — CLI 环境矩阵(D8):非 TTY / 无浏览器 / stdin pipe / init↔login 共存 / profile 切换 +# 用法:bash tests/oauth_cli_matrix.sh +# 黑盒驱动构建产物,用独立 HOME 沙箱,不触碰真实 ~/.ucloud +set -u + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BIN="$ROOT/out/ucloud-matrix-test" + +# 先用真实 HOME 构建(asdf/版本管理器的 go shim 依赖 $HOME 解析工具链),再切沙箱 HOME +go build -mod=vendor -o "$BIN" "$ROOT/main.go" || { echo "build failed"; exit 1; } + +SANDBOX="$(mktemp -d)" +export HOME="$SANDBOX" +PASS=0; FAIL=0 + +check() { # check + local desc="$1" want="$2" got="$3" out="$4" needle="$5" + if [ "$got" = "$want" ] && echo "$out" | grep -q "$needle"; then + PASS=$((PASS+1)); echo "[OK] $desc" + else + FAIL=$((FAIL+1)); echo "[FAIL] $desc (exit=$got want=$want; output: $out)" + fi +} + +# 1. 非 TTY login fail-fast:stderr + 非零退出 +ERR=$(echo "" | "$BIN" auth login 2>&1 >/dev/null); RC=$? +check "non-tty login fail-fast to stderr" 1 "$RC" "$ERR" "interactive terminal" + +# 2. stdin pipe 跑业务命令(无任何配置):aksk 路径既有提示零回归(注意:历史行为 exit 0,保持) +OUT=$("$BIN" region 2>&1 "$HOME/.ucloud/config.json" <<'EOF' +[{"project_id":"org-x","region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"profile":"default","active":true,"max_retry_times":3}] +EOF +cat > "$HOME/.ucloud/credential.json" <<'EOF' +[{"public_key":"","private_key":"","cookie":"","csrf_token":"","profile":"default","auth_mode":"oauth"}] +EOF +# 注意:非 TTY 用 pipe 模拟而非 &1 >/dev/null); RC=$? +check "oauth missing token: stderr + nonzero + AK/SK pointer (non-tty)" 1 "$RC" "$ERR" "AK/SK" + +# 4. logout 在未登录 profile 上幂等 +cat > "$HOME/.ucloud/credential.json" <<'EOF' +[{"public_key":"pub","private_key":"pri","cookie":"","csrf_token":"","profile":"default"}] +EOF +OUT=$("$BIN" auth logout 2>&1); RC=$? +check "logout on non-oauth profile is a no-op" 0 "$RC" "$OUT" "not logged in" + +# 5. profile 切换:oauth profile + aksk profile 并存,--profile 选中 aksk 的不受 oauth 影响 +cat > "$HOME/.ucloud/config.json" <<'EOF' +[{"project_id":"org-x","region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"profile":"oa","active":true,"max_retry_times":3}, + {"project_id":"org-y","region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"profile":"ak","active":false,"max_retry_times":3}] +EOF +cat > "$HOME/.ucloud/credential.json" <<'EOF' +[{"public_key":"","private_key":"","cookie":"","csrf_token":"","profile":"oa","auth_mode":"oauth"}, + {"public_key":"pub","private_key":"pri","cookie":"","csrf_token":"","profile":"ak"}] +EOF +OUT=$("$BIN" config list 2>&1); RC=$? +check "config list shows AuthMode column" 0 "$RC" "$OUT" "AuthMode" +ERR=$(echo "" | "$BIN" region --profile oa 2>&1 >/dev/null); RC=$? +check "profile switch: oauth profile without token fails nonzero" 1 "$RC" "$ERR" "Profile 'oa'" + +# 6. --no-browser flag 存在(help 可见;真实流程属手动 E2E) +OUT=$("$BIN" auth login --help 2>&1); RC=$? +check "login --help mentions --no-browser" 0 "$RC" "$OUT" "no-browser" + +# 7. init 在 oauth profile(已存 AK/SK)上确认 y:auth_mode/token 必须清除并落盘 +# base_url/oauth_base_url 指向不可达地址,printHello/refresh 立刻失败,不出外网;只断言盘上状态 +cat > "$HOME/.ucloud/config.json" <<'EOF' +[{"project_id":"org-x","region":"cn-bj2","zone":"cn-bj2-04","base_url":"http://127.0.0.1:1/","oauth_base_url":"http://127.0.0.1:1/","timeout_sec":15,"profile":"default","active":true,"max_retry_times":0}] +EOF +cat > "$HOME/.ucloud/credential.json" <<'EOF' +[{"public_key":"pub","private_key":"pri","cookie":"","csrf_token":"","profile":"default","auth_mode":"oauth","access_token":"at","refresh_token":"rt","expires_at":123}] +EOF +printf 'y\n' | "$BIN" init >/dev/null 2>&1 +if ! grep -q '"auth_mode"' "$HOME/.ucloud/credential.json" && grep -q '"public_key": *"pub"' "$HOME/.ucloud/credential.json"; then + PASS=$((PASS+1)); echo "[OK] init on oauth profile with AK/SK: confirm y clears auth_mode on disk" +else + FAIL=$((FAIL+1)); echo "[FAIL] init on oauth profile with AK/SK: confirm y clears auth_mode on disk (credential: $(cat "$HOME/.ucloud/credential.json"))" +fi + +echo "" +echo "matrix result: $PASS passed, $FAIL failed" +rm -rf "$SANDBOX" "$BIN" +[ "$FAIL" -eq 0 ] diff --git a/tests/specs/pathx.spec b/tests/specs/pathx.spec new file mode 100644 index 0000000000..a376e94209 --- /dev/null +++ b/tests/specs/pathx.spec @@ -0,0 +1,23 @@ +# UCloud PathX Example Test + + +## Create PathX instance with port + +* Extract "id" by regexp("ID is: ([^\s]+)"): "ucloud pathx create --bandwidth 1 --area-code CAN --charge-type Dynamic --quantity 1 --accel Global --origin-domain www.ucloud.cn --port 8000-8001 --origin-port 8000-8001 --protocol TCP" +* Execute command: "ucloud pathx list" +* Execute command with "id": "ucloud pathx list --id $id" +* Execute command with "id": "ucloud pathx list --id $id --detail" +* Execute command: "ucloud pathx price list --bandwidth 10 --area-code BKK" +* Execute command: "ucloud pathx area list" +* Execute command: "ucloud pathx area list --origin-domain www.ucloud.cn" +* Execute command: "ucloud pathx area list --origin-domain www.ucloud.cn --no-accel" +* Execute command: "ucloud pathx area list --origin-domain www.ucloud.cn --accel Global" +* Execute command with "id": "ucloud pathx delete -y --id $id" + +## Create PathX instance without port + +* Extract "id" by regexp("ID is: ([^\s]+)"): "ucloud pathx create --bandwidth 1 --area-code BKK --charge-type Dynamic --quantity 1 --accel AP --origin-domain www.ucloud.cn" +* Execute command with "id": "ucloud pathx modify --bandwidth 2 --id $id" +* Execute command with "id": "ucloud pathx modify --origin-domain pathx.ucloud.cn --id $id" +* Execute command with "id": "ucloud pathx modify --name PathX产品测试 --remark 测试 --id $id" +* Execute command with "id": "ucloud pathx delete -y --id $id" diff --git a/tests/stepimpl/stepimpl.go b/tests/stepimpl/stepimpl.go new file mode 100644 index 0000000000..3e109e56ba --- /dev/null +++ b/tests/stepimpl/stepimpl.go @@ -0,0 +1,47 @@ +package stepImpl + +import ( + "fmt" + "os/exec" + "regexp" + "strings" + + "github.com/getgauge-contrib/gauge-go/gauge" + "github.com/getgauge-contrib/gauge-go/testsuit" +) + +var _ = gauge.Step(`Execute command: `, func(command string) { + _ = execCmd(command) +}) + +var _ = gauge.Step(`Extract by regexp(): `, func(variable, pattern, command string) { + out := execCmd(command) + matched := regexp.MustCompile(pattern).FindStringSubmatch(string(out)) + if len(matched) < 2 { + testsuit.T.Fail(fmt.Errorf("no matched for %s: %s", pattern, string(out))) + } + gauge.GetScenarioStore()[variable] = matched[1] +}) + +var _ = gauge.Step(`Execute command with : `, func(variable, command string) { + _ = execCmd(strings.ReplaceAll(command, "$"+variable, fmt.Sprint(gauge.GetScenarioStore()[variable]))) +}) + +func execCmd(command string) []byte { + cmd := newCmd(command) + out, err := cmd.CombinedOutput() + gauge.WriteMessage(string(out)) + if err != nil { + testsuit.T.Fail(fmt.Errorf("cmd.Run() failed with %s\n", err)) + } + return out +} + +func newCmd(command string) *exec.Cmd { + tokens := strings.Split(command, " ") + binary, err := exec.LookPath(tokens[0]) + if err != nil { + testsuit.T.Fail(fmt.Errorf("can not found binary: %s", binary)) + } + return exec.Command(binary, tokens[1:]...) +} diff --git a/ux/document.go b/ux/document.go deleted file mode 100644 index 98b3060cbe..0000000000 --- a/ux/document.go +++ /dev/null @@ -1,183 +0,0 @@ -package ux - -import ( - "fmt" - "io" - "os" - "sync" - "time" - - "github.com/ucloud/ucloud-cli/ansi" -) - -var width, rows, _ = terminalSize() - -//Document 当前进程在打印的内容 -type document struct { - blocks []*Block - mux sync.RWMutex - framesPerSecond int - once sync.Once - out io.Writer - ticker *time.Ticker - disable bool -} - -func (d *document) reset() { - size := 0 - d.mux.RLock() - for _, block := range d.blocks { - size += block.printLineNum - } - d.mux.RUnlock() - if size != 0 { - fmt.Printf(ansi.CursorLeft + ansi.CursorPrevLine(size) + ansi.EraseDown) - } -} - -func (d *document) Disable() { - d.disable = true -} - -func (d *document) SetWriter(out io.Writer) { - d.out = out -} - -func (d *document) Content() []string { - var lines []string - for _, block := range d.blocks { - for _, line := range <-block.getLines { - lines = append(lines, line) - } - } - return lines -} - -func (d *document) Render() { - if d.disable { - return - } - d.once.Do(func() { - go func() { - for range d.ticker.C { - d.reset() - d.mux.RLock() - for _, block := range d.blocks { - block.printLineNum = 0 - for _, line := range <-block.getLines { - fmt.Fprintln(d.out, line) - if width != 0 { - lineNum := len(line)/width + 1 - block.printLineNum += lineNum - } else { - block.printLineNum++ - } - } - fmt.Fprintf(d.out, "\n") - block.printLineNum++ - } - d.mux.RUnlock() - } - }() - }) -} - -func (d *document) Append(b *Block) { - d.Render() - d.mux.Lock() - defer d.mux.Unlock() - d.blocks = append(d.blocks, b) -} - -func newDocument(out io.Writer) *document { - doc := &document{ - out: out, - framesPerSecond: 20, - } - doc.ticker = time.NewTicker(time.Second / time.Duration(doc.framesPerSecond)) - return doc -} - -//Doc global document -var Doc = newDocument(os.Stdout) - -//Block in document, including a spinner and some text -type Block struct { - spinner *Spin - spinnerIndex int - printLineNum int //已打印到屏幕上的行数 - lines []string - updateLine chan updateBlockLine - getLines chan []string -} - -//Update lines in Block -func (b *Block) Update(text string, index int) { - b.updateLine <- updateBlockLine{text, index} -} - -//Append text to Block -func (b *Block) Append(text string) { - b.updateLine <- updateBlockLine{text, -1} -} - -//SetSpin set spin for block -func (b *Block) SetSpin(s *Spin) error { - if b.spinner != nil { - return fmt.Errorf("block has spinner already") - } - b.spinner = s - b.spinnerIndex = len(<-b.getLines) - strsCh := b.spinner.renderToString() - go func() { - for text := range strsCh { - if len(<-b.getLines) == 0 { - b.Append(text) - } else { - b.Update(text, b.spinnerIndex) - } - } - }() - return nil -} - -type updateBlockLine struct { - line string - index int -} - -//NewSpinBlock create a new Block with spinner -func NewSpinBlock(s *Spin) *Block { - block := NewBlock() - if s != nil { - block.SetSpin(s) - } - return block -} - -//NewBlock create a new Block without spinner. block.Stable closed -func NewBlock() *Block { - block := &Block{ - lines: []string{}, - updateLine: make(chan updateBlockLine, 0), - getLines: make(chan []string, 0), - } - - go func() { - for { - select { - case updateLine := <-block.updateLine: - index, line := updateLine.index, updateLine.line - if index < 0 { - block.lines = append(block.lines, line) - } else { - block.lines[index] = line - } - - case block.getLines <- block.lines: - } - } - }() - - return block -} diff --git a/ux/prompt.go b/ux/prompt.go deleted file mode 100644 index 045d38384b..0000000000 --- a/ux/prompt.go +++ /dev/null @@ -1,26 +0,0 @@ -package ux - -import ( - "fmt" - "strings" -) - -// Prompt confirm -func Prompt(text string) (bool, error) { - if !strings.HasSuffix(text, "(y/n):") { - text += " (y/n):" - } - fmt.Printf(text) - var agreeClose string - _, err := fmt.Scanf("%s\n", &agreeClose) - if err != nil { - return false, err - } - agreeClose = strings.Trim(agreeClose, " ") - agreeClose = strings.ToLower(agreeClose) - - if agreeClose == "y" || agreeClose == "yes" { - return true, nil - } - return false, nil -} diff --git a/ux/spinner.go b/ux/spinner.go deleted file mode 100644 index 711943b284..0000000000 --- a/ux/spinner.go +++ /dev/null @@ -1,138 +0,0 @@ -//Inspaired by https://github.com/oclif/cli-ux - -package ux - -import ( - "fmt" - "io" - "os" - "runtime" - "time" - - "github.com/ucloud/ucloud-cli/ansi" -) - -const windows = "windows" - -// Spinner type -type Spinner struct { - out io.Writer - frames []rune - framesPerSecond int - DoingText string - DoneText string - TimeoutText string - ticker *time.Ticker - output string -} - -// Start start render -func (s *Spinner) Start(doingText string) { - if doingText != "" { - s.DoingText = doingText - } - s.ticker = time.NewTicker(time.Second / time.Duration(s.framesPerSecond)) - s.render() -} - -// Stop stop render -func (s *Spinner) Stop() { - s.ticker.Stop() - s.reset() - output := fmt.Sprintf("%s...%s\n", s.DoingText, s.DoneText) - fmt.Fprintf(s.out, output) -} - -// Timeout stop render -func (s *Spinner) Timeout() { - s.ticker.Stop() - s.reset() - output := fmt.Sprintf("%s...%s\n", s.DoingText, s.TimeoutText) - fmt.Fprintf(s.out, output) -} - -// Fail stop render -func (s *Spinner) Fail(err error) { - s.ticker.Stop() - s.reset() - output := fmt.Sprintf("%s...fail: %v\n", s.DoingText, err) - fmt.Fprintf(s.out, output) -} - -func (s *Spinner) reset() { - if s.output == "" { - return - } - fmt.Printf(ansi.CursorLeft + ansi.CursorUp(1) + ansi.EraseDown) - s.output = "" -} - -func (s *Spinner) render() { - nextFrame := s.newFrameFactory() - go func() { - send := false - for range s.ticker.C { - if runtime.GOOS == windows { - if !send { - fmt.Printf("%s...\n", s.DoingText) - send = true - } - continue - } - frame := nextFrame() - s.reset() - s.output = fmt.Sprintf("%s...%c\n", s.DoingText, frame) - fmt.Printf(s.output) - } - }() -} - -func (s *Spinner) newFrameFactory() func() rune { - index := 0 - size := len(s.frames) - return func() rune { - char := s.frames[index%size] - index++ - return char - } -} - -var spinnerFrames = []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} - -// DotSpinner dot spinner -var DotSpinner = NewDotSpinner(os.Stdout) - -//NewDotSpinner get new DotSpinner instance -func NewDotSpinner(out io.Writer) *Spinner { - return &Spinner{ - out: out, - frames: spinnerFrames, - framesPerSecond: 12, - DoingText: "running", - DoneText: "done", - TimeoutText: "timeout", - } -} - -//Refresh 刷新显示文本 -type Refresh struct { - out io.Writer - reset bool -} - -//Do 刷新显示 -func (r *Refresh) Do(text string) { - if r.reset { - fmt.Fprintf(r.out, ansi.CursorLeft+ansi.CursorUp(1)+ansi.EraseDown) - } else { - r.reset = true - } - fmt.Fprintln(r.out, text) -} - -//NewRefresh create a new Refresh instance -func NewRefresh() *Refresh { - return &Refresh{ - out: os.Stdout, - } -} diff --git a/ux/spinnerv2.go b/ux/spinnerv2.go deleted file mode 100644 index 1118759307..0000000000 --- a/ux/spinnerv2.go +++ /dev/null @@ -1,123 +0,0 @@ -//Inspaired by https://github.com/oclif/cli-ux - -package ux - -import ( - "fmt" - "io" - "runtime" - "sync" - "time" - - "github.com/ucloud/ucloud-cli/ansi" -) - -// Spin type -type Spin struct { - out io.Writer - frames []rune - framesPerSecond int - DoingText string - DoneText string - TimeoutText string - ticker *time.Ticker - output string - textChan chan string - wg sync.WaitGroup -} - -// Stop stop render -func (s *Spin) Stop() { - s.ticker.Stop() - s.reset() - output := fmt.Sprintf("%s...%s", s.DoingText, s.DoneText) - s.textChan <- output - //等待最后一帧渲染 - <-time.After(time.Millisecond * 100) - close(s.textChan) -} - -// Timeout stop render -func (s *Spin) Timeout() { - s.ticker.Stop() - s.reset() - output := fmt.Sprintf("%s...%s", s.DoingText, s.TimeoutText) - s.textChan <- output - //等待最后一帧渲染 - <-time.After(time.Millisecond * 100) - close(s.textChan) -} - -func (s *Spin) reset() { - if s.output == "" { - return - } - fmt.Printf(ansi.CursorLeft + ansi.CursorUp(1) + ansi.EraseDown) - s.output = "" -} - -func (s *Spin) renderToString() chan string { - nextFrame := s.newFrameFactory() - go func() { - send := false - for range s.ticker.C { - if runtime.GOOS == windows { - if !send { - s.textChan <- fmt.Sprintf("%s...", s.DoingText) - send = true - } - continue - } - frame := nextFrame() - s.textChan <- fmt.Sprintf("%s...%c", s.DoingText, frame) - } - }() - return s.textChan -} - -func (s *Spin) renderToScreen() { - nextFrame := s.newFrameFactory() - go func() { - send := false - for range s.ticker.C { - if runtime.GOOS == windows { - if !send { - fmt.Printf("%s...\n", s.DoingText) - send = true - } - continue - } - frame := nextFrame() - s.reset() - s.output = fmt.Sprintf("%s...%c\n", s.DoingText, frame) - fmt.Printf(s.output) - } - }() -} - -func (s *Spin) newFrameFactory() func() rune { - index := 0 - size := len(s.frames) - return func() rune { - char := s.frames[index%size] - index++ - return char - } -} - -var spinFrames = []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} - -//NewDotSpin get new DotSpinner instance -func NewDotSpin(out io.Writer, doingText string) *Spin { - s := &Spin{ - out: out, - frames: spinnerFrames, - framesPerSecond: 12, - DoingText: doingText, - DoneText: "done", - TimeoutText: "timeout", - textChan: make(chan string), - } - s.ticker = time.NewTicker(time.Second / time.Duration(s.framesPerSecond)) - return s -} diff --git a/ux/terminal_win.go b/ux/terminal_win.go deleted file mode 100644 index f692151825..0000000000 --- a/ux/terminal_win.go +++ /dev/null @@ -1,76 +0,0 @@ -// +build windows - -package ux - -import ( - "os" - "syscall" - "unsafe" -) - -var tty = os.Stdin - -var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - - // GetConsoleScreenBufferInfo retrieves information about the - // specified console screen buffer. - // http://msdn.microsoft.com/en-us/library/windows/desktop/ms683171(v=vs.85).aspx - procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") - - // GetConsoleMode retrieves the current input mode of a console's - // input buffer or the current output mode of a console screen buffer. - // https://msdn.microsoft.com/en-us/library/windows/desktop/ms683167(v=vs.85).aspx - getConsoleMode = kernel32.NewProc("GetConsoleMode") - - // SetConsoleMode sets the input mode of a console's input buffer - // or the output mode of a console screen buffer. - // https://msdn.microsoft.com/en-us/library/windows/desktop/ms686033(v=vs.85).aspx - setConsoleMode = kernel32.NewProc("SetConsoleMode") - - // SetConsoleCursorPosition sets the cursor position in the - // specified console screen buffer. - // https://msdn.microsoft.com/en-us/library/windows/desktop/ms686025(v=vs.85).aspx - setConsoleCursorPosition = kernel32.NewProc("SetConsoleCursorPosition") -) - -type ( - // Defines the coordinates of the upper left and lower right corners - // of a rectangle. - // See - // http://msdn.microsoft.com/en-us/library/windows/desktop/ms686311(v=vs.85).aspx - smallRect struct { - Left, Top, Right, Bottom int16 - } - - // Defines the coordinates of a character cell in a console screen - // buffer. The origin of the coordinate system (0,0) is at the top, left cell - // of the buffer. - // See - // http://msdn.microsoft.com/en-us/library/windows/desktop/ms682119(v=vs.85).aspx - coordinates struct { - X, Y int16 - } - - word int16 - - // Contains information about a console screen buffer. - // http://msdn.microsoft.com/en-us/library/windows/desktop/ms682093(v=vs.85).aspx - consoleScreenBufferInfo struct { - dwSize coordinates - dwCursorPosition coordinates - wAttributes word - srWindow smallRect - dwMaximumWindowSize coordinates - } -) - -// terminalSize returns width ans rows of the terminal. -func terminalSize() (int, int, error) { - var info consoleScreenBufferInfo - _, _, e := syscall.Syscall(procGetConsoleScreenBufferInfo.Addr(), 2, uintptr(syscall.Stdout), uintptr(unsafe.Pointer(&info)), 0) - if e != 0 { - return 0, 0, error(e) - } - return int(info.dwSize.X) - 1, int(info.dwSize.Y) - 1, nil -} diff --git a/ux/terminal_x.go b/ux/terminal_x.go deleted file mode 100644 index 74a2a76d1e..0000000000 --- a/ux/terminal_x.go +++ /dev/null @@ -1,49 +0,0 @@ -// +build linux darwin freebsd netbsd openbsd solaris dragonfly - -package ux - -import ( - "errors" - "os" - "sync" - - "golang.org/x/sys/unix" -) - -var ( - echoLockMutex sync.Mutex - origTermStatePtr *unix.Termios - tty *os.File - istty bool -) - -func init() { - echoLockMutex.Lock() - defer echoLockMutex.Unlock() - - var err error - tty, err = os.Open("/dev/tty") - istty = true - if err != nil { - tty = os.Stdin - istty = false - } -} - -// terminalSize returns width and rows of the terminal. -func terminalSize() (int, int, error) { - if !istty { - return 0, 0, errors.New("Not Supported") - } - echoLockMutex.Lock() - defer echoLockMutex.Unlock() - - fd := int(tty.Fd()) - - ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ) - if err != nil { - return 0, 0, err - } - - return int(ws.Col), int(ws.Row), nil -} diff --git a/vendor/github.com/cpuguy83/go-md2man/LICENSE.md b/vendor/github.com/cpuguy83/go-md2man/LICENSE.md deleted file mode 100644 index 1cade6cef6..0000000000 --- a/vendor/github.com/cpuguy83/go-md2man/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2014 Brian Goff - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/vendor/github.com/cpuguy83/go-md2man/md2man/md2man.go b/vendor/github.com/cpuguy83/go-md2man/md2man/md2man.go deleted file mode 100644 index af62279a61..0000000000 --- a/vendor/github.com/cpuguy83/go-md2man/md2man/md2man.go +++ /dev/null @@ -1,20 +0,0 @@ -package md2man - -import ( - "github.com/russross/blackfriday" -) - -// Render converts a markdown document into a roff formatted document. -func Render(doc []byte) []byte { - renderer := RoffRenderer(0) - extensions := 0 - extensions |= blackfriday.EXTENSION_NO_INTRA_EMPHASIS - extensions |= blackfriday.EXTENSION_TABLES - extensions |= blackfriday.EXTENSION_FENCED_CODE - extensions |= blackfriday.EXTENSION_AUTOLINK - extensions |= blackfriday.EXTENSION_SPACE_HEADERS - extensions |= blackfriday.EXTENSION_FOOTNOTES - extensions |= blackfriday.EXTENSION_TITLEBLOCK - - return blackfriday.Markdown(doc, renderer, extensions) -} diff --git a/vendor/github.com/cpuguy83/go-md2man/md2man/roff.go b/vendor/github.com/cpuguy83/go-md2man/md2man/roff.go deleted file mode 100644 index 8c29ec6873..0000000000 --- a/vendor/github.com/cpuguy83/go-md2man/md2man/roff.go +++ /dev/null @@ -1,285 +0,0 @@ -package md2man - -import ( - "bytes" - "fmt" - "html" - "strings" - - "github.com/russross/blackfriday" -) - -type roffRenderer struct { - ListCounters []int -} - -// RoffRenderer creates a new blackfriday Renderer for generating roff documents -// from markdown -func RoffRenderer(flags int) blackfriday.Renderer { - return &roffRenderer{} -} - -func (r *roffRenderer) GetFlags() int { - return 0 -} - -func (r *roffRenderer) TitleBlock(out *bytes.Buffer, text []byte) { - out.WriteString(".TH ") - - splitText := bytes.Split(text, []byte("\n")) - for i, line := range splitText { - line = bytes.TrimPrefix(line, []byte("% ")) - if i == 0 { - line = bytes.Replace(line, []byte("("), []byte("\" \""), 1) - line = bytes.Replace(line, []byte(")"), []byte("\" \""), 1) - } - line = append([]byte("\""), line...) - line = append(line, []byte("\" ")...) - out.Write(line) - } - out.WriteString("\n") - - // disable hyphenation - out.WriteString(".nh\n") - // disable justification (adjust text to left margin only) - out.WriteString(".ad l\n") -} - -func (r *roffRenderer) BlockCode(out *bytes.Buffer, text []byte, lang string) { - out.WriteString("\n.PP\n.RS\n\n.nf\n") - escapeSpecialChars(out, text) - out.WriteString("\n.fi\n.RE\n") -} - -func (r *roffRenderer) BlockQuote(out *bytes.Buffer, text []byte) { - out.WriteString("\n.PP\n.RS\n") - out.Write(text) - out.WriteString("\n.RE\n") -} - -func (r *roffRenderer) BlockHtml(out *bytes.Buffer, text []byte) { // nolint: golint - out.Write(text) -} - -func (r *roffRenderer) Header(out *bytes.Buffer, text func() bool, level int, id string) { - marker := out.Len() - - switch { - case marker == 0: - // This is the doc header - out.WriteString(".TH ") - case level == 1: - out.WriteString("\n\n.SH ") - case level == 2: - out.WriteString("\n.SH ") - default: - out.WriteString("\n.SS ") - } - - if !text() { - out.Truncate(marker) - return - } -} - -func (r *roffRenderer) HRule(out *bytes.Buffer) { - out.WriteString("\n.ti 0\n\\l'\\n(.lu'\n") -} - -func (r *roffRenderer) List(out *bytes.Buffer, text func() bool, flags int) { - marker := out.Len() - r.ListCounters = append(r.ListCounters, 1) - out.WriteString("\n.RS\n") - if !text() { - out.Truncate(marker) - return - } - r.ListCounters = r.ListCounters[:len(r.ListCounters)-1] - out.WriteString("\n.RE\n") -} - -func (r *roffRenderer) ListItem(out *bytes.Buffer, text []byte, flags int) { - if flags&blackfriday.LIST_TYPE_ORDERED != 0 { - out.WriteString(fmt.Sprintf(".IP \"%3d.\" 5\n", r.ListCounters[len(r.ListCounters)-1])) - r.ListCounters[len(r.ListCounters)-1]++ - } else { - out.WriteString(".IP \\(bu 2\n") - } - out.Write(text) - out.WriteString("\n") -} - -func (r *roffRenderer) Paragraph(out *bytes.Buffer, text func() bool) { - marker := out.Len() - out.WriteString("\n.PP\n") - if !text() { - out.Truncate(marker) - return - } - if marker != 0 { - out.WriteString("\n") - } -} - -func (r *roffRenderer) Table(out *bytes.Buffer, header []byte, body []byte, columnData []int) { - out.WriteString("\n.TS\nallbox;\n") - - maxDelims := 0 - lines := strings.Split(strings.TrimRight(string(header), "\n")+"\n"+strings.TrimRight(string(body), "\n"), "\n") - for _, w := range lines { - curDelims := strings.Count(w, "\t") - if curDelims > maxDelims { - maxDelims = curDelims - } - } - out.Write([]byte(strings.Repeat("l ", maxDelims+1) + "\n")) - out.Write([]byte(strings.Repeat("l ", maxDelims+1) + ".\n")) - out.Write(header) - if len(header) > 0 { - out.Write([]byte("\n")) - } - - out.Write(body) - out.WriteString("\n.TE\n") -} - -func (r *roffRenderer) TableRow(out *bytes.Buffer, text []byte) { - if out.Len() > 0 { - out.WriteString("\n") - } - out.Write(text) -} - -func (r *roffRenderer) TableHeaderCell(out *bytes.Buffer, text []byte, align int) { - if out.Len() > 0 { - out.WriteString("\t") - } - if len(text) == 0 { - text = []byte{' '} - } - out.Write([]byte("\\fB\\fC" + string(text) + "\\fR")) -} - -func (r *roffRenderer) TableCell(out *bytes.Buffer, text []byte, align int) { - if out.Len() > 0 { - out.WriteString("\t") - } - if len(text) > 30 { - text = append([]byte("T{\n"), text...) - text = append(text, []byte("\nT}")...) - } - if len(text) == 0 { - text = []byte{' '} - } - out.Write(text) -} - -func (r *roffRenderer) Footnotes(out *bytes.Buffer, text func() bool) { - -} - -func (r *roffRenderer) FootnoteItem(out *bytes.Buffer, name, text []byte, flags int) { - -} - -func (r *roffRenderer) AutoLink(out *bytes.Buffer, link []byte, kind int) { - out.WriteString("\n\\[la]") - out.Write(link) - out.WriteString("\\[ra]") -} - -func (r *roffRenderer) CodeSpan(out *bytes.Buffer, text []byte) { - out.WriteString("\\fB\\fC") - escapeSpecialChars(out, text) - out.WriteString("\\fR") -} - -func (r *roffRenderer) DoubleEmphasis(out *bytes.Buffer, text []byte) { - out.WriteString("\\fB") - out.Write(text) - out.WriteString("\\fP") -} - -func (r *roffRenderer) Emphasis(out *bytes.Buffer, text []byte) { - out.WriteString("\\fI") - out.Write(text) - out.WriteString("\\fP") -} - -func (r *roffRenderer) Image(out *bytes.Buffer, link []byte, title []byte, alt []byte) { -} - -func (r *roffRenderer) LineBreak(out *bytes.Buffer) { - out.WriteString("\n.br\n") -} - -func (r *roffRenderer) Link(out *bytes.Buffer, link []byte, title []byte, content []byte) { - out.Write(content) - r.AutoLink(out, link, 0) -} - -func (r *roffRenderer) RawHtmlTag(out *bytes.Buffer, tag []byte) { // nolint: golint - out.Write(tag) -} - -func (r *roffRenderer) TripleEmphasis(out *bytes.Buffer, text []byte) { - out.WriteString("\\s+2") - out.Write(text) - out.WriteString("\\s-2") -} - -func (r *roffRenderer) StrikeThrough(out *bytes.Buffer, text []byte) { -} - -func (r *roffRenderer) FootnoteRef(out *bytes.Buffer, ref []byte, id int) { - -} - -func (r *roffRenderer) Entity(out *bytes.Buffer, entity []byte) { - out.WriteString(html.UnescapeString(string(entity))) -} - -func (r *roffRenderer) NormalText(out *bytes.Buffer, text []byte) { - escapeSpecialChars(out, text) -} - -func (r *roffRenderer) DocumentHeader(out *bytes.Buffer) { -} - -func (r *roffRenderer) DocumentFooter(out *bytes.Buffer) { -} - -func needsBackslash(c byte) bool { - for _, r := range []byte("-_&\\~") { - if c == r { - return true - } - } - return false -} - -func escapeSpecialChars(out *bytes.Buffer, text []byte) { - for i := 0; i < len(text); i++ { - // escape initial apostrophe or period - if len(text) >= 1 && (text[0] == '\'' || text[0] == '.') { - out.WriteString("\\&") - } - - // directly copy normal characters - org := i - - for i < len(text) && !needsBackslash(text[i]) { - i++ - } - if i > org { - out.Write(text[org:i]) - } - - // escape a character - if i >= len(text) { - break - } - out.WriteByte('\\') - out.WriteByte(text[i]) - } -} diff --git a/vendor/github.com/inconshreveable/mousetrap/LICENSE b/vendor/github.com/inconshreveable/mousetrap/LICENSE deleted file mode 100644 index 5f0d1fb6a7..0000000000 --- a/vendor/github.com/inconshreveable/mousetrap/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2014 Alan Shreve - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/vendor/github.com/inconshreveable/mousetrap/README.md b/vendor/github.com/inconshreveable/mousetrap/README.md deleted file mode 100644 index 7a950d1774..0000000000 --- a/vendor/github.com/inconshreveable/mousetrap/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# mousetrap - -mousetrap is a tiny library that answers a single question. - -On a Windows machine, was the process invoked by someone double clicking on -the executable file while browsing in explorer? - -### Motivation - -Windows developers unfamiliar with command line tools will often "double-click" -the executable for a tool. Because most CLI tools print the help and then exit -when invoked without arguments, this is often very frustrating for those users. - -mousetrap provides a way to detect these invocations so that you can provide -more helpful behavior and instructions on how to run the CLI tool. To see what -this looks like, both from an organizational and a technical perspective, see -https://inconshreveable.com/09-09-2014/sweat-the-small-stuff/ - -### The interface - -The library exposes a single interface: - - func StartedByExplorer() (bool) diff --git a/vendor/github.com/inconshreveable/mousetrap/trap_others.go b/vendor/github.com/inconshreveable/mousetrap/trap_others.go deleted file mode 100644 index 9d2d8a4bab..0000000000 --- a/vendor/github.com/inconshreveable/mousetrap/trap_others.go +++ /dev/null @@ -1,15 +0,0 @@ -// +build !windows - -package mousetrap - -// StartedByExplorer returns true if the program was invoked by the user -// double-clicking on the executable from explorer.exe -// -// It is conservative and returns false if any of the internal calls fail. -// It does not guarantee that the program was run from a terminal. It only can tell you -// whether it was launched from explorer.exe -// -// On non-Windows platforms, it always returns false. -func StartedByExplorer() bool { - return false -} diff --git a/vendor/github.com/inconshreveable/mousetrap/trap_windows.go b/vendor/github.com/inconshreveable/mousetrap/trap_windows.go deleted file mode 100644 index 336142a5e3..0000000000 --- a/vendor/github.com/inconshreveable/mousetrap/trap_windows.go +++ /dev/null @@ -1,98 +0,0 @@ -// +build windows -// +build !go1.4 - -package mousetrap - -import ( - "fmt" - "os" - "syscall" - "unsafe" -) - -const ( - // defined by the Win32 API - th32cs_snapprocess uintptr = 0x2 -) - -var ( - kernel = syscall.MustLoadDLL("kernel32.dll") - CreateToolhelp32Snapshot = kernel.MustFindProc("CreateToolhelp32Snapshot") - Process32First = kernel.MustFindProc("Process32FirstW") - Process32Next = kernel.MustFindProc("Process32NextW") -) - -// ProcessEntry32 structure defined by the Win32 API -type processEntry32 struct { - dwSize uint32 - cntUsage uint32 - th32ProcessID uint32 - th32DefaultHeapID int - th32ModuleID uint32 - cntThreads uint32 - th32ParentProcessID uint32 - pcPriClassBase int32 - dwFlags uint32 - szExeFile [syscall.MAX_PATH]uint16 -} - -func getProcessEntry(pid int) (pe *processEntry32, err error) { - snapshot, _, e1 := CreateToolhelp32Snapshot.Call(th32cs_snapprocess, uintptr(0)) - if snapshot == uintptr(syscall.InvalidHandle) { - err = fmt.Errorf("CreateToolhelp32Snapshot: %v", e1) - return - } - defer syscall.CloseHandle(syscall.Handle(snapshot)) - - var processEntry processEntry32 - processEntry.dwSize = uint32(unsafe.Sizeof(processEntry)) - ok, _, e1 := Process32First.Call(snapshot, uintptr(unsafe.Pointer(&processEntry))) - if ok == 0 { - err = fmt.Errorf("Process32First: %v", e1) - return - } - - for { - if processEntry.th32ProcessID == uint32(pid) { - pe = &processEntry - return - } - - ok, _, e1 = Process32Next.Call(snapshot, uintptr(unsafe.Pointer(&processEntry))) - if ok == 0 { - err = fmt.Errorf("Process32Next: %v", e1) - return - } - } -} - -func getppid() (pid int, err error) { - pe, err := getProcessEntry(os.Getpid()) - if err != nil { - return - } - - pid = int(pe.th32ParentProcessID) - return -} - -// StartedByExplorer returns true if the program was invoked by the user double-clicking -// on the executable from explorer.exe -// -// It is conservative and returns false if any of the internal calls fail. -// It does not guarantee that the program was run from a terminal. It only can tell you -// whether it was launched from explorer.exe -func StartedByExplorer() bool { - ppid, err := getppid() - if err != nil { - return false - } - - pe, err := getProcessEntry(ppid) - if err != nil { - return false - } - - name := syscall.UTF16ToString(pe.szExeFile[:]) - return name == "explorer.exe" -} diff --git a/vendor/github.com/inconshreveable/mousetrap/trap_windows_1.4.go b/vendor/github.com/inconshreveable/mousetrap/trap_windows_1.4.go deleted file mode 100644 index 9a28e57c3c..0000000000 --- a/vendor/github.com/inconshreveable/mousetrap/trap_windows_1.4.go +++ /dev/null @@ -1,46 +0,0 @@ -// +build windows -// +build go1.4 - -package mousetrap - -import ( - "os" - "syscall" - "unsafe" -) - -func getProcessEntry(pid int) (*syscall.ProcessEntry32, error) { - snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0) - if err != nil { - return nil, err - } - defer syscall.CloseHandle(snapshot) - var procEntry syscall.ProcessEntry32 - procEntry.Size = uint32(unsafe.Sizeof(procEntry)) - if err = syscall.Process32First(snapshot, &procEntry); err != nil { - return nil, err - } - for { - if procEntry.ProcessID == uint32(pid) { - return &procEntry, nil - } - err = syscall.Process32Next(snapshot, &procEntry) - if err != nil { - return nil, err - } - } -} - -// StartedByExplorer returns true if the program was invoked by the user double-clicking -// on the executable from explorer.exe -// -// It is conservative and returns false if any of the internal calls fail. -// It does not guarantee that the program was run from a terminal. It only can tell you -// whether it was launched from explorer.exe -func StartedByExplorer() bool { - pe, err := getProcessEntry(os.Getppid()) - if err != nil { - return false - } - return "explorer.exe" == syscall.UTF16ToString(pe.ExeFile[:]) -} diff --git a/vendor/github.com/konsorten/go-windows-terminal-sequences/LICENSE b/vendor/github.com/konsorten/go-windows-terminal-sequences/LICENSE deleted file mode 100644 index 14127cd831..0000000000 --- a/vendor/github.com/konsorten/go-windows-terminal-sequences/LICENSE +++ /dev/null @@ -1,9 +0,0 @@ -(The MIT License) - -Copyright (c) 2017 marvin + konsorten GmbH (open-source@konsorten.de) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/konsorten/go-windows-terminal-sequences/README.md b/vendor/github.com/konsorten/go-windows-terminal-sequences/README.md deleted file mode 100644 index 949b77e304..0000000000 --- a/vendor/github.com/konsorten/go-windows-terminal-sequences/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Windows Terminal Sequences - -This library allow for enabling Windows terminal color support for Go. - -See [Console Virtual Terminal Sequences](https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences) for details. - -## Usage - -```go -import ( - "syscall" - - sequences "github.com/konsorten/go-windows-terminal-sequences" -) - -func main() { - sequences.EnableVirtualTerminalProcessing(syscall.Stdout, true) -} - -``` - -## Authors - -The tool is sponsored by the [marvin + konsorten GmbH](http://www.konsorten.de). - -We thank all the authors who provided code to this library: - -* Felix Kollmann - -## License - -(The MIT License) - -Copyright (c) 2018 marvin + konsorten GmbH (open-source@konsorten.de) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/konsorten/go-windows-terminal-sequences/go.mod b/vendor/github.com/konsorten/go-windows-terminal-sequences/go.mod deleted file mode 100644 index 716c613125..0000000000 --- a/vendor/github.com/konsorten/go-windows-terminal-sequences/go.mod +++ /dev/null @@ -1 +0,0 @@ -module github.com/konsorten/go-windows-terminal-sequences diff --git a/vendor/github.com/konsorten/go-windows-terminal-sequences/sequences.go b/vendor/github.com/konsorten/go-windows-terminal-sequences/sequences.go deleted file mode 100644 index ef18d8f978..0000000000 --- a/vendor/github.com/konsorten/go-windows-terminal-sequences/sequences.go +++ /dev/null @@ -1,36 +0,0 @@ -// +build windows - -package sequences - -import ( - "syscall" - "unsafe" -) - -var ( - kernel32Dll *syscall.LazyDLL = syscall.NewLazyDLL("Kernel32.dll") - setConsoleMode *syscall.LazyProc = kernel32Dll.NewProc("SetConsoleMode") -) - -func EnableVirtualTerminalProcessing(stream syscall.Handle, enable bool) error { - const ENABLE_VIRTUAL_TERMINAL_PROCESSING uint32 = 0x4 - - var mode uint32 - err := syscall.GetConsoleMode(syscall.Stdout, &mode) - if err != nil { - return err - } - - if enable { - mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING - } else { - mode &^= ENABLE_VIRTUAL_TERMINAL_PROCESSING - } - - ret, _, err := setConsoleMode.Call(uintptr(unsafe.Pointer(stream)), uintptr(mode)) - if ret == 0 { - return err - } - - return nil -} diff --git a/vendor/github.com/pkg/errors/.gitignore b/vendor/github.com/pkg/errors/.gitignore deleted file mode 100644 index daf913b1b3..0000000000 --- a/vendor/github.com/pkg/errors/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Compiled Object files, Static and Dynamic libs (Shared Objects) -*.o -*.a -*.so - -# Folders -_obj -_test - -# Architecture specific extensions/prefixes -*.[568vq] -[568vq].out - -*.cgo1.go -*.cgo2.c -_cgo_defun.c -_cgo_gotypes.go -_cgo_export.* - -_testmain.go - -*.exe -*.test -*.prof diff --git a/vendor/github.com/pkg/errors/.travis.yml b/vendor/github.com/pkg/errors/.travis.yml deleted file mode 100644 index 588ceca183..0000000000 --- a/vendor/github.com/pkg/errors/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: go -go_import_path: github.com/pkg/errors -go: - - 1.4.3 - - 1.5.4 - - 1.6.2 - - 1.7.1 - - tip - -script: - - go test -v ./... diff --git a/vendor/github.com/pkg/errors/LICENSE b/vendor/github.com/pkg/errors/LICENSE deleted file mode 100644 index 835ba3e755..0000000000 --- a/vendor/github.com/pkg/errors/LICENSE +++ /dev/null @@ -1,23 +0,0 @@ -Copyright (c) 2015, Dave Cheney -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/pkg/errors/README.md b/vendor/github.com/pkg/errors/README.md deleted file mode 100644 index 273db3c98a..0000000000 --- a/vendor/github.com/pkg/errors/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# errors [![Travis-CI](https://travis-ci.org/pkg/errors.svg)](https://travis-ci.org/pkg/errors) [![AppVeyor](https://ci.appveyor.com/api/projects/status/b98mptawhudj53ep/branch/master?svg=true)](https://ci.appveyor.com/project/davecheney/errors/branch/master) [![GoDoc](https://godoc.org/github.com/pkg/errors?status.svg)](http://godoc.org/github.com/pkg/errors) [![Report card](https://goreportcard.com/badge/github.com/pkg/errors)](https://goreportcard.com/report/github.com/pkg/errors) - -Package errors provides simple error handling primitives. - -`go get github.com/pkg/errors` - -The traditional error handling idiom in Go is roughly akin to -```go -if err != nil { - return err -} -``` -which applied recursively up the call stack results in error reports without context or debugging information. The errors package allows programmers to add context to the failure path in their code in a way that does not destroy the original value of the error. - -## Adding context to an error - -The errors.Wrap function returns a new error that adds context to the original error. For example -```go -_, err := ioutil.ReadAll(r) -if err != nil { - return errors.Wrap(err, "read failed") -} -``` -## Retrieving the cause of an error - -Using `errors.Wrap` constructs a stack of errors, adding context to the preceding error. Depending on the nature of the error it may be necessary to reverse the operation of errors.Wrap to retrieve the original error for inspection. Any error value which implements this interface can be inspected by `errors.Cause`. -```go -type causer interface { - Cause() error -} -``` -`errors.Cause` will recursively retrieve the topmost error which does not implement `causer`, which is assumed to be the original cause. For example: -```go -switch err := errors.Cause(err).(type) { -case *MyError: - // handle specifically -default: - // unknown error -} -``` - -[Read the package documentation for more information](https://godoc.org/github.com/pkg/errors). - -## Contributing - -We welcome pull requests, bug fixes and issue reports. With that said, the bar for adding new symbols to this package is intentionally set high. - -Before proposing a change, please discuss your change by raising an issue. - -## Licence - -BSD-2-Clause diff --git a/vendor/github.com/pkg/errors/appveyor.yml b/vendor/github.com/pkg/errors/appveyor.yml deleted file mode 100644 index a932eade02..0000000000 --- a/vendor/github.com/pkg/errors/appveyor.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: build-{build}.{branch} - -clone_folder: C:\gopath\src\github.com\pkg\errors -shallow_clone: true # for startup speed - -environment: - GOPATH: C:\gopath - -platform: - - x64 - -# http://www.appveyor.com/docs/installed-software -install: - # some helpful output for debugging builds - - go version - - go env - # pre-installed MinGW at C:\MinGW is 32bit only - # but MSYS2 at C:\msys64 has mingw64 - - set PATH=C:\msys64\mingw64\bin;%PATH% - - gcc --version - - g++ --version - -build_script: - - go install -v ./... - -test_script: - - set PATH=C:\gopath\bin;%PATH% - - go test -v ./... - -#artifacts: -# - path: '%GOPATH%\bin\*.exe' -deploy: off diff --git a/vendor/github.com/pkg/errors/errors.go b/vendor/github.com/pkg/errors/errors.go deleted file mode 100644 index 842ee80456..0000000000 --- a/vendor/github.com/pkg/errors/errors.go +++ /dev/null @@ -1,269 +0,0 @@ -// Package errors provides simple error handling primitives. -// -// The traditional error handling idiom in Go is roughly akin to -// -// if err != nil { -// return err -// } -// -// which applied recursively up the call stack results in error reports -// without context or debugging information. The errors package allows -// programmers to add context to the failure path in their code in a way -// that does not destroy the original value of the error. -// -// Adding context to an error -// -// The errors.Wrap function returns a new error that adds context to the -// original error by recording a stack trace at the point Wrap is called, -// and the supplied message. For example -// -// _, err := ioutil.ReadAll(r) -// if err != nil { -// return errors.Wrap(err, "read failed") -// } -// -// If additional control is required the errors.WithStack and errors.WithMessage -// functions destructure errors.Wrap into its component operations of annotating -// an error with a stack trace and an a message, respectively. -// -// Retrieving the cause of an error -// -// Using errors.Wrap constructs a stack of errors, adding context to the -// preceding error. Depending on the nature of the error it may be necessary -// to reverse the operation of errors.Wrap to retrieve the original error -// for inspection. Any error value which implements this interface -// -// type causer interface { -// Cause() error -// } -// -// can be inspected by errors.Cause. errors.Cause will recursively retrieve -// the topmost error which does not implement causer, which is assumed to be -// the original cause. For example: -// -// switch err := errors.Cause(err).(type) { -// case *MyError: -// // handle specifically -// default: -// // unknown error -// } -// -// causer interface is not exported by this package, but is considered a part -// of stable public API. -// -// Formatted printing of errors -// -// All error values returned from this package implement fmt.Formatter and can -// be formatted by the fmt package. The following verbs are supported -// -// %s print the error. If the error has a Cause it will be -// printed recursively -// %v see %s -// %+v extended format. Each Frame of the error's StackTrace will -// be printed in detail. -// -// Retrieving the stack trace of an error or wrapper -// -// New, Errorf, Wrap, and Wrapf record a stack trace at the point they are -// invoked. This information can be retrieved with the following interface. -// -// type stackTracer interface { -// StackTrace() errors.StackTrace -// } -// -// Where errors.StackTrace is defined as -// -// type StackTrace []Frame -// -// The Frame type represents a call site in the stack trace. Frame supports -// the fmt.Formatter interface that can be used for printing information about -// the stack trace of this error. For example: -// -// if err, ok := err.(stackTracer); ok { -// for _, f := range err.StackTrace() { -// fmt.Printf("%+s:%d", f) -// } -// } -// -// stackTracer interface is not exported by this package, but is considered a part -// of stable public API. -// -// See the documentation for Frame.Format for more details. -package errors - -import ( - "fmt" - "io" -) - -// New returns an error with the supplied message. -// New also records the stack trace at the point it was called. -func New(message string) error { - return &fundamental{ - msg: message, - stack: callers(), - } -} - -// Errorf formats according to a format specifier and returns the string -// as a value that satisfies error. -// Errorf also records the stack trace at the point it was called. -func Errorf(format string, args ...interface{}) error { - return &fundamental{ - msg: fmt.Sprintf(format, args...), - stack: callers(), - } -} - -// fundamental is an error that has a message and a stack, but no caller. -type fundamental struct { - msg string - *stack -} - -func (f *fundamental) Error() string { return f.msg } - -func (f *fundamental) Format(s fmt.State, verb rune) { - switch verb { - case 'v': - if s.Flag('+') { - io.WriteString(s, f.msg) - f.stack.Format(s, verb) - return - } - fallthrough - case 's': - io.WriteString(s, f.msg) - case 'q': - fmt.Fprintf(s, "%q", f.msg) - } -} - -// WithStack annotates err with a stack trace at the point WithStack was called. -// If err is nil, WithStack returns nil. -func WithStack(err error) error { - if err == nil { - return nil - } - return &withStack{ - err, - callers(), - } -} - -type withStack struct { - error - *stack -} - -func (w *withStack) Cause() error { return w.error } - -func (w *withStack) Format(s fmt.State, verb rune) { - switch verb { - case 'v': - if s.Flag('+') { - fmt.Fprintf(s, "%+v", w.Cause()) - w.stack.Format(s, verb) - return - } - fallthrough - case 's': - io.WriteString(s, w.Error()) - case 'q': - fmt.Fprintf(s, "%q", w.Error()) - } -} - -// Wrap returns an error annotating err with a stack trace -// at the point Wrap is called, and the supplied message. -// If err is nil, Wrap returns nil. -func Wrap(err error, message string) error { - if err == nil { - return nil - } - err = &withMessage{ - cause: err, - msg: message, - } - return &withStack{ - err, - callers(), - } -} - -// Wrapf returns an error annotating err with a stack trace -// at the point Wrapf is call, and the format specifier. -// If err is nil, Wrapf returns nil. -func Wrapf(err error, format string, args ...interface{}) error { - if err == nil { - return nil - } - err = &withMessage{ - cause: err, - msg: fmt.Sprintf(format, args...), - } - return &withStack{ - err, - callers(), - } -} - -// WithMessage annotates err with a new message. -// If err is nil, WithMessage returns nil. -func WithMessage(err error, message string) error { - if err == nil { - return nil - } - return &withMessage{ - cause: err, - msg: message, - } -} - -type withMessage struct { - cause error - msg string -} - -func (w *withMessage) Error() string { return w.msg + ": " + w.cause.Error() } -func (w *withMessage) Cause() error { return w.cause } - -func (w *withMessage) Format(s fmt.State, verb rune) { - switch verb { - case 'v': - if s.Flag('+') { - fmt.Fprintf(s, "%+v\n", w.Cause()) - io.WriteString(s, w.msg) - return - } - fallthrough - case 's', 'q': - io.WriteString(s, w.Error()) - } -} - -// Cause returns the underlying cause of the error, if possible. -// An error value has a cause if it implements the following -// interface: -// -// type causer interface { -// Cause() error -// } -// -// If the error does not implement Cause, the original error will -// be returned. If the error is nil, nil will be returned without further -// investigation. -func Cause(err error) error { - type causer interface { - Cause() error - } - - for err != nil { - cause, ok := err.(causer) - if !ok { - break - } - err = cause.Cause() - } - return err -} diff --git a/vendor/github.com/pkg/errors/stack.go b/vendor/github.com/pkg/errors/stack.go deleted file mode 100644 index 6b1f2891a5..0000000000 --- a/vendor/github.com/pkg/errors/stack.go +++ /dev/null @@ -1,178 +0,0 @@ -package errors - -import ( - "fmt" - "io" - "path" - "runtime" - "strings" -) - -// Frame represents a program counter inside a stack frame. -type Frame uintptr - -// pc returns the program counter for this frame; -// multiple frames may have the same PC value. -func (f Frame) pc() uintptr { return uintptr(f) - 1 } - -// file returns the full path to the file that contains the -// function for this Frame's pc. -func (f Frame) file() string { - fn := runtime.FuncForPC(f.pc()) - if fn == nil { - return "unknown" - } - file, _ := fn.FileLine(f.pc()) - return file -} - -// line returns the line number of source code of the -// function for this Frame's pc. -func (f Frame) line() int { - fn := runtime.FuncForPC(f.pc()) - if fn == nil { - return 0 - } - _, line := fn.FileLine(f.pc()) - return line -} - -// Format formats the frame according to the fmt.Formatter interface. -// -// %s source file -// %d source line -// %n function name -// %v equivalent to %s:%d -// -// Format accepts flags that alter the printing of some verbs, as follows: -// -// %+s path of source file relative to the compile time GOPATH -// %+v equivalent to %+s:%d -func (f Frame) Format(s fmt.State, verb rune) { - switch verb { - case 's': - switch { - case s.Flag('+'): - pc := f.pc() - fn := runtime.FuncForPC(pc) - if fn == nil { - io.WriteString(s, "unknown") - } else { - file, _ := fn.FileLine(pc) - fmt.Fprintf(s, "%s\n\t%s", fn.Name(), file) - } - default: - io.WriteString(s, path.Base(f.file())) - } - case 'd': - fmt.Fprintf(s, "%d", f.line()) - case 'n': - name := runtime.FuncForPC(f.pc()).Name() - io.WriteString(s, funcname(name)) - case 'v': - f.Format(s, 's') - io.WriteString(s, ":") - f.Format(s, 'd') - } -} - -// StackTrace is stack of Frames from innermost (newest) to outermost (oldest). -type StackTrace []Frame - -func (st StackTrace) Format(s fmt.State, verb rune) { - switch verb { - case 'v': - switch { - case s.Flag('+'): - for _, f := range st { - fmt.Fprintf(s, "\n%+v", f) - } - case s.Flag('#'): - fmt.Fprintf(s, "%#v", []Frame(st)) - default: - fmt.Fprintf(s, "%v", []Frame(st)) - } - case 's': - fmt.Fprintf(s, "%s", []Frame(st)) - } -} - -// stack represents a stack of program counters. -type stack []uintptr - -func (s *stack) Format(st fmt.State, verb rune) { - switch verb { - case 'v': - switch { - case st.Flag('+'): - for _, pc := range *s { - f := Frame(pc) - fmt.Fprintf(st, "\n%+v", f) - } - } - } -} - -func (s *stack) StackTrace() StackTrace { - f := make([]Frame, len(*s)) - for i := 0; i < len(f); i++ { - f[i] = Frame((*s)[i]) - } - return f -} - -func callers() *stack { - const depth = 32 - var pcs [depth]uintptr - n := runtime.Callers(3, pcs[:]) - var st stack = pcs[0:n] - return &st -} - -// funcname removes the path prefix component of a function's name reported by func.Name(). -func funcname(name string) string { - i := strings.LastIndex(name, "/") - name = name[i+1:] - i = strings.Index(name, ".") - return name[i+1:] -} - -func trimGOPATH(name, file string) string { - // Here we want to get the source file path relative to the compile time - // GOPATH. As of Go 1.6.x there is no direct way to know the compiled - // GOPATH at runtime, but we can infer the number of path segments in the - // GOPATH. We note that fn.Name() returns the function name qualified by - // the import path, which does not include the GOPATH. Thus we can trim - // segments from the beginning of the file path until the number of path - // separators remaining is one more than the number of path separators in - // the function name. For example, given: - // - // GOPATH /home/user - // file /home/user/src/pkg/sub/file.go - // fn.Name() pkg/sub.Type.Method - // - // We want to produce: - // - // pkg/sub/file.go - // - // From this we can easily see that fn.Name() has one less path separator - // than our desired output. We count separators from the end of the file - // path until it finds two more than in the function name and then move - // one character forward to preserve the initial path segment without a - // leading separator. - const sep = "/" - goal := strings.Count(name, sep) + 2 - i := len(file) - for n := 0; n < goal; n++ { - i = strings.LastIndex(file[:i], sep) - if i == -1 { - // not enough separators found, set i so that the slice expression - // below leaves file unmodified - i = -len(sep) - break - } - } - // get back to 0 or trim the leading separator - file = file[i+len(sep):] - return file -} diff --git a/vendor/github.com/russross/blackfriday/.gitignore b/vendor/github.com/russross/blackfriday/.gitignore deleted file mode 100644 index 75623dcccb..0000000000 --- a/vendor/github.com/russross/blackfriday/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -*.out -*.swp -*.8 -*.6 -_obj -_test* -markdown -tags diff --git a/vendor/github.com/russross/blackfriday/.travis.yml b/vendor/github.com/russross/blackfriday/.travis.yml deleted file mode 100644 index 2f3351d7ae..0000000000 --- a/vendor/github.com/russross/blackfriday/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -sudo: false -language: go -go: - - "1.9.x" - - "1.10.x" - - tip -matrix: - fast_finish: true - allow_failures: - - go: tip -install: - - # Do nothing. This is needed to prevent default install action "go get -t -v ./..." from happening here (we want it to happen inside script step). -script: - - go get -t -v ./... - - diff -u <(echo -n) <(gofmt -d -s .) - - go tool vet . - - go test -v -race ./... diff --git a/vendor/github.com/russross/blackfriday/LICENSE.txt b/vendor/github.com/russross/blackfriday/LICENSE.txt deleted file mode 100644 index 2885af3602..0000000000 --- a/vendor/github.com/russross/blackfriday/LICENSE.txt +++ /dev/null @@ -1,29 +0,0 @@ -Blackfriday is distributed under the Simplified BSD License: - -> Copyright © 2011 Russ Ross -> All rights reserved. -> -> Redistribution and use in source and binary forms, with or without -> modification, are permitted provided that the following conditions -> are met: -> -> 1. Redistributions of source code must retain the above copyright -> notice, this list of conditions and the following disclaimer. -> -> 2. Redistributions in binary form must reproduce the above -> copyright notice, this list of conditions and the following -> disclaimer in the documentation and/or other materials provided with -> the distribution. -> -> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -> "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -> LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -> FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE -> COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -> INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, -> BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -> LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -> CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -> LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN -> ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -> POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/russross/blackfriday/README.md b/vendor/github.com/russross/blackfriday/README.md deleted file mode 100644 index 3c62e13753..0000000000 --- a/vendor/github.com/russross/blackfriday/README.md +++ /dev/null @@ -1,369 +0,0 @@ -Blackfriday -[![Build Status][BuildSVG]][BuildURL] -[![Godoc][GodocV2SVG]][GodocV2URL] -=========== - -Blackfriday is a [Markdown][1] processor implemented in [Go][2]. It -is paranoid about its input (so you can safely feed it user-supplied -data), it is fast, it supports common extensions (tables, smart -punctuation substitutions, etc.), and it is safe for all utf-8 -(unicode) input. - -HTML output is currently supported, along with Smartypants -extensions. - -It started as a translation from C of [Sundown][3]. - - -Installation ------------- - -Blackfriday is compatible with any modern Go release. With Go and git installed: - - go get -u gopkg.in/russross/blackfriday.v2 - -will download, compile, and install the package into your `$GOPATH` directory -hierarchy. - - -Versions --------- - -Currently maintained and recommended version of Blackfriday is `v2`. It's being -developed on its own branch: https://github.com/russross/blackfriday/tree/v2 and the -documentation is available at -https://godoc.org/gopkg.in/russross/blackfriday.v2. - -It is `go get`-able via [gopkg.in][6] at `gopkg.in/russross/blackfriday.v2`, -but we highly recommend using package management tool like [dep][7] or -[Glide][8] and make use of semantic versioning. With package management you -should import `github.com/russross/blackfriday` and specify that you're using -version 2.0.0. - -Version 2 offers a number of improvements over v1: - -* Cleaned up API -* A separate call to [`Parse`][4], which produces an abstract syntax tree for - the document -* Latest bug fixes -* Flexibility to easily add your own rendering extensions - -Potential drawbacks: - -* Our benchmarks show v2 to be slightly slower than v1. Currently in the - ballpark of around 15%. -* API breakage. If you can't afford modifying your code to adhere to the new API - and don't care too much about the new features, v2 is probably not for you. -* Several bug fixes are trailing behind and still need to be forward-ported to - v2. See issue [#348](https://github.com/russross/blackfriday/issues/348) for - tracking. - -If you are still interested in the legacy `v1`, you can import it from -`github.com/russross/blackfriday`. Documentation for the legacy v1 can be found -here: https://godoc.org/github.com/russross/blackfriday - -### Known issue with `dep` - -There is a known problem with using Blackfriday v1 _transitively_ and `dep`. -Currently `dep` prioritizes semver versions over anything else, and picks the -latest one, plus it does not apply a `[[constraint]]` specifier to transitively -pulled in packages. So if you're using something that uses Blackfriday v1, but -that something does not use `dep` yet, you will get Blackfriday v2 pulled in and -your first dependency will fail to build. - -There are couple of fixes for it, documented here: -https://github.com/golang/dep/blob/master/docs/FAQ.md#how-do-i-constrain-a-transitive-dependencys-version - -Meanwhile, `dep` team is working on a more general solution to the constraints -on transitive dependencies problem: https://github.com/golang/dep/issues/1124. - - -Usage ------ - -### v1 - -For basic usage, it is as simple as getting your input into a byte -slice and calling: - - output := blackfriday.MarkdownBasic(input) - -This renders it with no extensions enabled. To get a more useful -feature set, use this instead: - - output := blackfriday.MarkdownCommon(input) - -### v2 - -For the most sensible markdown processing, it is as simple as getting your input -into a byte slice and calling: - -```go -output := blackfriday.Run(input) -``` - -Your input will be parsed and the output rendered with a set of most popular -extensions enabled. If you want the most basic feature set, corresponding with -the bare Markdown specification, use: - -```go -output := blackfriday.Run(input, blackfriday.WithNoExtensions()) -``` - -### Sanitize untrusted content - -Blackfriday itself does nothing to protect against malicious content. If you are -dealing with user-supplied markdown, we recommend running Blackfriday's output -through HTML sanitizer such as [Bluemonday][5]. - -Here's an example of simple usage of Blackfriday together with Bluemonday: - -```go -import ( - "github.com/microcosm-cc/bluemonday" - "gopkg.in/russross/blackfriday.v2" -) - -// ... -unsafe := blackfriday.Run(input) -html := bluemonday.UGCPolicy().SanitizeBytes(unsafe) -``` - -### Custom options, v1 - -If you want to customize the set of options, first get a renderer -(currently only the HTML output engine), then use it to -call the more general `Markdown` function. For examples, see the -implementations of `MarkdownBasic` and `MarkdownCommon` in -`markdown.go`. - -### Custom options, v2 - -If you want to customize the set of options, use `blackfriday.WithExtensions`, -`blackfriday.WithRenderer` and `blackfriday.WithRefOverride`. - -### `blackfriday-tool` - -You can also check out `blackfriday-tool` for a more complete example -of how to use it. Download and install it using: - - go get github.com/russross/blackfriday-tool - -This is a simple command-line tool that allows you to process a -markdown file using a standalone program. You can also browse the -source directly on github if you are just looking for some example -code: - -* - -Note that if you have not already done so, installing -`blackfriday-tool` will be sufficient to download and install -blackfriday in addition to the tool itself. The tool binary will be -installed in `$GOPATH/bin`. This is a statically-linked binary that -can be copied to wherever you need it without worrying about -dependencies and library versions. - -### Sanitized anchor names - -Blackfriday includes an algorithm for creating sanitized anchor names -corresponding to a given input text. This algorithm is used to create -anchors for headings when `EXTENSION_AUTO_HEADER_IDS` is enabled. The -algorithm has a specification, so that other packages can create -compatible anchor names and links to those anchors. - -The specification is located at https://godoc.org/github.com/russross/blackfriday#hdr-Sanitized_Anchor_Names. - -[`SanitizedAnchorName`](https://godoc.org/github.com/russross/blackfriday#SanitizedAnchorName) exposes this functionality, and can be used to -create compatible links to the anchor names generated by blackfriday. -This algorithm is also implemented in a small standalone package at -[`github.com/shurcooL/sanitized_anchor_name`](https://godoc.org/github.com/shurcooL/sanitized_anchor_name). It can be useful for clients -that want a small package and don't need full functionality of blackfriday. - - -Features --------- - -All features of Sundown are supported, including: - -* **Compatibility**. The Markdown v1.0.3 test suite passes with - the `--tidy` option. Without `--tidy`, the differences are - mostly in whitespace and entity escaping, where blackfriday is - more consistent and cleaner. - -* **Common extensions**, including table support, fenced code - blocks, autolinks, strikethroughs, non-strict emphasis, etc. - -* **Safety**. Blackfriday is paranoid when parsing, making it safe - to feed untrusted user input without fear of bad things - happening. The test suite stress tests this and there are no - known inputs that make it crash. If you find one, please let me - know and send me the input that does it. - - NOTE: "safety" in this context means *runtime safety only*. In order to - protect yourself against JavaScript injection in untrusted content, see - [this example](https://github.com/russross/blackfriday#sanitize-untrusted-content). - -* **Fast processing**. It is fast enough to render on-demand in - most web applications without having to cache the output. - -* **Thread safety**. You can run multiple parsers in different - goroutines without ill effect. There is no dependence on global - shared state. - -* **Minimal dependencies**. Blackfriday only depends on standard - library packages in Go. The source code is pretty - self-contained, so it is easy to add to any project, including - Google App Engine projects. - -* **Standards compliant**. Output successfully validates using the - W3C validation tool for HTML 4.01 and XHTML 1.0 Transitional. - - -Extensions ----------- - -In addition to the standard markdown syntax, this package -implements the following extensions: - -* **Intra-word emphasis supression**. The `_` character is - commonly used inside words when discussing code, so having - markdown interpret it as an emphasis command is usually the - wrong thing. Blackfriday lets you treat all emphasis markers as - normal characters when they occur inside a word. - -* **Tables**. Tables can be created by drawing them in the input - using a simple syntax: - - ``` - Name | Age - --------|------ - Bob | 27 - Alice | 23 - ``` - -* **Fenced code blocks**. In addition to the normal 4-space - indentation to mark code blocks, you can explicitly mark them - and supply a language (to make syntax highlighting simple). Just - mark it like this: - - ``` go - func getTrue() bool { - return true - } - ``` - - You can use 3 or more backticks to mark the beginning of the - block, and the same number to mark the end of the block. - - To preserve classes of fenced code blocks while using the bluemonday - HTML sanitizer, use the following policy: - - ``` go - p := bluemonday.UGCPolicy() - p.AllowAttrs("class").Matching(regexp.MustCompile("^language-[a-zA-Z0-9]+$")).OnElements("code") - html := p.SanitizeBytes(unsafe) - ``` - -* **Definition lists**. A simple definition list is made of a single-line - term followed by a colon and the definition for that term. - - Cat - : Fluffy animal everyone likes - - Internet - : Vector of transmission for pictures of cats - - Terms must be separated from the previous definition by a blank line. - -* **Footnotes**. A marker in the text that will become a superscript number; - a footnote definition that will be placed in a list of footnotes at the - end of the document. A footnote looks like this: - - This is a footnote.[^1] - - [^1]: the footnote text. - -* **Autolinking**. Blackfriday can find URLs that have not been - explicitly marked as links and turn them into links. - -* **Strikethrough**. Use two tildes (`~~`) to mark text that - should be crossed out. - -* **Hard line breaks**. With this extension enabled (it is off by - default in the `MarkdownBasic` and `MarkdownCommon` convenience - functions), newlines in the input translate into line breaks in - the output. - -* **Smart quotes**. Smartypants-style punctuation substitution is - supported, turning normal double- and single-quote marks into - curly quotes, etc. - -* **LaTeX-style dash parsing** is an additional option, where `--` - is translated into `–`, and `---` is translated into - `—`. This differs from most smartypants processors, which - turn a single hyphen into an ndash and a double hyphen into an - mdash. - -* **Smart fractions**, where anything that looks like a fraction - is translated into suitable HTML (instead of just a few special - cases like most smartypant processors). For example, `4/5` - becomes `45`, which renders as - 45. - - -Other renderers ---------------- - -Blackfriday is structured to allow alternative rendering engines. Here -are a few of note: - -* [github_flavored_markdown](https://godoc.org/github.com/shurcooL/github_flavored_markdown): - provides a GitHub Flavored Markdown renderer with fenced code block - highlighting, clickable heading anchor links. - - It's not customizable, and its goal is to produce HTML output - equivalent to the [GitHub Markdown API endpoint](https://developer.github.com/v3/markdown/#render-a-markdown-document-in-raw-mode), - except the rendering is performed locally. - -* [markdownfmt](https://github.com/shurcooL/markdownfmt): like gofmt, - but for markdown. - -* [LaTeX output](https://bitbucket.org/ambrevar/blackfriday-latex): - renders output as LaTeX. - -* [bfchroma](https://github.com/Depado/bfchroma/): provides convenience - integration with the [Chroma](https://github.com/alecthomas/chroma) code - highlighting library. bfchroma is only compatible with v2 of Blackfriday and - provides a drop-in renderer ready to use with Blackfriday, as well as - options and means for further customization. - - -TODO ----- - -* More unit testing -* Improve Unicode support. It does not understand all Unicode - rules (about what constitutes a letter, a punctuation symbol, - etc.), so it may fail to detect word boundaries correctly in - some instances. It is safe on all UTF-8 input. - - -License -------- - -[Blackfriday is distributed under the Simplified BSD License](LICENSE.txt) - - - [1]: https://daringfireball.net/projects/markdown/ "Markdown" - [2]: https://golang.org/ "Go Language" - [3]: https://github.com/vmg/sundown "Sundown" - [4]: https://godoc.org/gopkg.in/russross/blackfriday.v2#Parse "Parse func" - [5]: https://github.com/microcosm-cc/bluemonday "Bluemonday" - [6]: https://labix.org/gopkg.in "gopkg.in" - [7]: https://github.com/golang/dep/ "dep" - [8]: https://github.com/Masterminds/glide "Glide" - - [BuildSVG]: https://travis-ci.org/russross/blackfriday.svg?branch=master - [BuildURL]: https://travis-ci.org/russross/blackfriday - [GodocV2SVG]: https://godoc.org/gopkg.in/russross/blackfriday.v2?status.svg - [GodocV2URL]: https://godoc.org/gopkg.in/russross/blackfriday.v2 diff --git a/vendor/github.com/russross/blackfriday/block.go b/vendor/github.com/russross/blackfriday/block.go deleted file mode 100644 index 45c21a6c26..0000000000 --- a/vendor/github.com/russross/blackfriday/block.go +++ /dev/null @@ -1,1474 +0,0 @@ -// -// Blackfriday Markdown Processor -// Available at http://github.com/russross/blackfriday -// -// Copyright © 2011 Russ Ross . -// Distributed under the Simplified BSD License. -// See README.md for details. -// - -// -// Functions to parse block-level elements. -// - -package blackfriday - -import ( - "bytes" - "strings" - "unicode" -) - -// Parse block-level data. -// Note: this function and many that it calls assume that -// the input buffer ends with a newline. -func (p *parser) block(out *bytes.Buffer, data []byte) { - if len(data) == 0 || data[len(data)-1] != '\n' { - panic("block input is missing terminating newline") - } - - // this is called recursively: enforce a maximum depth - if p.nesting >= p.maxNesting { - return - } - p.nesting++ - - // parse out one block-level construct at a time - for len(data) > 0 { - // prefixed header: - // - // # Header 1 - // ## Header 2 - // ... - // ###### Header 6 - if p.isPrefixHeader(data) { - data = data[p.prefixHeader(out, data):] - continue - } - - // block of preformatted HTML: - // - //
- // ... - //
- if data[0] == '<' { - if i := p.html(out, data, true); i > 0 { - data = data[i:] - continue - } - } - - // title block - // - // % stuff - // % more stuff - // % even more stuff - if p.flags&EXTENSION_TITLEBLOCK != 0 { - if data[0] == '%' { - if i := p.titleBlock(out, data, true); i > 0 { - data = data[i:] - continue - } - } - } - - // blank lines. note: returns the # of bytes to skip - if i := p.isEmpty(data); i > 0 { - data = data[i:] - continue - } - - // indented code block: - // - // func max(a, b int) int { - // if a > b { - // return a - // } - // return b - // } - if p.codePrefix(data) > 0 { - data = data[p.code(out, data):] - continue - } - - // fenced code block: - // - // ``` go info string here - // func fact(n int) int { - // if n <= 1 { - // return n - // } - // return n * fact(n-1) - // } - // ``` - if p.flags&EXTENSION_FENCED_CODE != 0 { - if i := p.fencedCodeBlock(out, data, true); i > 0 { - data = data[i:] - continue - } - } - - // horizontal rule: - // - // ------ - // or - // ****** - // or - // ______ - if p.isHRule(data) { - p.r.HRule(out) - var i int - for i = 0; data[i] != '\n'; i++ { - } - data = data[i:] - continue - } - - // block quote: - // - // > A big quote I found somewhere - // > on the web - if p.quotePrefix(data) > 0 { - data = data[p.quote(out, data):] - continue - } - - // table: - // - // Name | Age | Phone - // ------|-----|--------- - // Bob | 31 | 555-1234 - // Alice | 27 | 555-4321 - if p.flags&EXTENSION_TABLES != 0 { - if i := p.table(out, data); i > 0 { - data = data[i:] - continue - } - } - - // an itemized/unordered list: - // - // * Item 1 - // * Item 2 - // - // also works with + or - - if p.uliPrefix(data) > 0 { - data = data[p.list(out, data, 0):] - continue - } - - // a numbered/ordered list: - // - // 1. Item 1 - // 2. Item 2 - if p.oliPrefix(data) > 0 { - data = data[p.list(out, data, LIST_TYPE_ORDERED):] - continue - } - - // definition lists: - // - // Term 1 - // : Definition a - // : Definition b - // - // Term 2 - // : Definition c - if p.flags&EXTENSION_DEFINITION_LISTS != 0 { - if p.dliPrefix(data) > 0 { - data = data[p.list(out, data, LIST_TYPE_DEFINITION):] - continue - } - } - - // anything else must look like a normal paragraph - // note: this finds underlined headers, too - data = data[p.paragraph(out, data):] - } - - p.nesting-- -} - -func (p *parser) isPrefixHeader(data []byte) bool { - if data[0] != '#' { - return false - } - - if p.flags&EXTENSION_SPACE_HEADERS != 0 { - level := 0 - for level < 6 && data[level] == '#' { - level++ - } - if data[level] != ' ' { - return false - } - } - return true -} - -func (p *parser) prefixHeader(out *bytes.Buffer, data []byte) int { - level := 0 - for level < 6 && data[level] == '#' { - level++ - } - i := skipChar(data, level, ' ') - end := skipUntilChar(data, i, '\n') - skip := end - id := "" - if p.flags&EXTENSION_HEADER_IDS != 0 { - j, k := 0, 0 - // find start/end of header id - for j = i; j < end-1 && (data[j] != '{' || data[j+1] != '#'); j++ { - } - for k = j + 1; k < end && data[k] != '}'; k++ { - } - // extract header id iff found - if j < end && k < end { - id = string(data[j+2 : k]) - end = j - skip = k + 1 - for end > 0 && data[end-1] == ' ' { - end-- - } - } - } - for end > 0 && data[end-1] == '#' { - if isBackslashEscaped(data, end-1) { - break - } - end-- - } - for end > 0 && data[end-1] == ' ' { - end-- - } - if end > i { - if id == "" && p.flags&EXTENSION_AUTO_HEADER_IDS != 0 { - id = SanitizedAnchorName(string(data[i:end])) - } - work := func() bool { - p.inline(out, data[i:end]) - return true - } - p.r.Header(out, work, level, id) - } - return skip -} - -func (p *parser) isUnderlinedHeader(data []byte) int { - // test of level 1 header - if data[0] == '=' { - i := skipChar(data, 1, '=') - i = skipChar(data, i, ' ') - if data[i] == '\n' { - return 1 - } else { - return 0 - } - } - - // test of level 2 header - if data[0] == '-' { - i := skipChar(data, 1, '-') - i = skipChar(data, i, ' ') - if data[i] == '\n' { - return 2 - } else { - return 0 - } - } - - return 0 -} - -func (p *parser) titleBlock(out *bytes.Buffer, data []byte, doRender bool) int { - if data[0] != '%' { - return 0 - } - splitData := bytes.Split(data, []byte("\n")) - var i int - for idx, b := range splitData { - if !bytes.HasPrefix(b, []byte("%")) { - i = idx // - 1 - break - } - } - - data = bytes.Join(splitData[0:i], []byte("\n")) - p.r.TitleBlock(out, data) - - return len(data) -} - -func (p *parser) html(out *bytes.Buffer, data []byte, doRender bool) int { - var i, j int - - // identify the opening tag - if data[0] != '<' { - return 0 - } - curtag, tagfound := p.htmlFindTag(data[1:]) - - // handle special cases - if !tagfound { - // check for an HTML comment - if size := p.htmlComment(out, data, doRender); size > 0 { - return size - } - - // check for an
tag - if size := p.htmlHr(out, data, doRender); size > 0 { - return size - } - - // check for HTML CDATA - if size := p.htmlCDATA(out, data, doRender); size > 0 { - return size - } - - // no special case recognized - return 0 - } - - // look for an unindented matching closing tag - // followed by a blank line - found := false - /* - closetag := []byte("\n") - j = len(curtag) + 1 - for !found { - // scan for a closing tag at the beginning of a line - if skip := bytes.Index(data[j:], closetag); skip >= 0 { - j += skip + len(closetag) - } else { - break - } - - // see if it is the only thing on the line - if skip := p.isEmpty(data[j:]); skip > 0 { - // see if it is followed by a blank line/eof - j += skip - if j >= len(data) { - found = true - i = j - } else { - if skip := p.isEmpty(data[j:]); skip > 0 { - j += skip - found = true - i = j - } - } - } - } - */ - - // if not found, try a second pass looking for indented match - // but not if tag is "ins" or "del" (following original Markdown.pl) - if !found && curtag != "ins" && curtag != "del" { - i = 1 - for i < len(data) { - i++ - for i < len(data) && !(data[i-1] == '<' && data[i] == '/') { - i++ - } - - if i+2+len(curtag) >= len(data) { - break - } - - j = p.htmlFindEnd(curtag, data[i-1:]) - - if j > 0 { - i += j - 1 - found = true - break - } - } - } - - if !found { - return 0 - } - - // the end of the block has been found - if doRender { - // trim newlines - end := i - for end > 0 && data[end-1] == '\n' { - end-- - } - p.r.BlockHtml(out, data[:end]) - } - - return i -} - -func (p *parser) renderHTMLBlock(out *bytes.Buffer, data []byte, start int, doRender bool) int { - // html block needs to end with a blank line - if i := p.isEmpty(data[start:]); i > 0 { - size := start + i - if doRender { - // trim trailing newlines - end := size - for end > 0 && data[end-1] == '\n' { - end-- - } - p.r.BlockHtml(out, data[:end]) - } - return size - } - return 0 -} - -// HTML comment, lax form -func (p *parser) htmlComment(out *bytes.Buffer, data []byte, doRender bool) int { - i := p.inlineHTMLComment(out, data) - return p.renderHTMLBlock(out, data, i, doRender) -} - -// HTML CDATA section -func (p *parser) htmlCDATA(out *bytes.Buffer, data []byte, doRender bool) int { - const cdataTag = "') { - i++ - } - i++ - // no end-of-comment marker - if i >= len(data) { - return 0 - } - return p.renderHTMLBlock(out, data, i, doRender) -} - -// HR, which is the only self-closing block tag considered -func (p *parser) htmlHr(out *bytes.Buffer, data []byte, doRender bool) int { - if data[0] != '<' || (data[1] != 'h' && data[1] != 'H') || (data[2] != 'r' && data[2] != 'R') { - return 0 - } - if data[3] != ' ' && data[3] != '/' && data[3] != '>' { - // not an
tag after all; at least not a valid one - return 0 - } - - i := 3 - for data[i] != '>' && data[i] != '\n' { - i++ - } - - if data[i] == '>' { - return p.renderHTMLBlock(out, data, i+1, doRender) - } - - return 0 -} - -func (p *parser) htmlFindTag(data []byte) (string, bool) { - i := 0 - for isalnum(data[i]) { - i++ - } - key := string(data[:i]) - if _, ok := blockTags[key]; ok { - return key, true - } - return "", false -} - -func (p *parser) htmlFindEnd(tag string, data []byte) int { - // assume data[0] == '<' && data[1] == '/' already tested - - // check if tag is a match - closetag := []byte("") - if !bytes.HasPrefix(data, closetag) { - return 0 - } - i := len(closetag) - - // check that the rest of the line is blank - skip := 0 - if skip = p.isEmpty(data[i:]); skip == 0 { - return 0 - } - i += skip - skip = 0 - - if i >= len(data) { - return i - } - - if p.flags&EXTENSION_LAX_HTML_BLOCKS != 0 { - return i - } - if skip = p.isEmpty(data[i:]); skip == 0 { - // following line must be blank - return 0 - } - - return i + skip -} - -func (*parser) isEmpty(data []byte) int { - // it is okay to call isEmpty on an empty buffer - if len(data) == 0 { - return 0 - } - - var i int - for i = 0; i < len(data) && data[i] != '\n'; i++ { - if data[i] != ' ' && data[i] != '\t' { - return 0 - } - } - return i + 1 -} - -func (*parser) isHRule(data []byte) bool { - i := 0 - - // skip up to three spaces - for i < 3 && data[i] == ' ' { - i++ - } - - // look at the hrule char - if data[i] != '*' && data[i] != '-' && data[i] != '_' { - return false - } - c := data[i] - - // the whole line must be the char or whitespace - n := 0 - for data[i] != '\n' { - switch { - case data[i] == c: - n++ - case data[i] != ' ': - return false - } - i++ - } - - return n >= 3 -} - -// isFenceLine checks if there's a fence line (e.g., ``` or ``` go) at the beginning of data, -// and returns the end index if so, or 0 otherwise. It also returns the marker found. -// If syntax is not nil, it gets set to the syntax specified in the fence line. -// A final newline is mandatory to recognize the fence line, unless newlineOptional is true. -func isFenceLine(data []byte, info *string, oldmarker string, newlineOptional bool) (end int, marker string) { - i, size := 0, 0 - - // skip up to three spaces - for i < len(data) && i < 3 && data[i] == ' ' { - i++ - } - - // check for the marker characters: ~ or ` - if i >= len(data) { - return 0, "" - } - if data[i] != '~' && data[i] != '`' { - return 0, "" - } - - c := data[i] - - // the whole line must be the same char or whitespace - for i < len(data) && data[i] == c { - size++ - i++ - } - - // the marker char must occur at least 3 times - if size < 3 { - return 0, "" - } - marker = string(data[i-size : i]) - - // if this is the end marker, it must match the beginning marker - if oldmarker != "" && marker != oldmarker { - return 0, "" - } - - // TODO(shurcooL): It's probably a good idea to simplify the 2 code paths here - // into one, always get the info string, and discard it if the caller doesn't care. - if info != nil { - infoLength := 0 - i = skipChar(data, i, ' ') - - if i >= len(data) { - if newlineOptional && i == len(data) { - return i, marker - } - return 0, "" - } - - infoStart := i - - if data[i] == '{' { - i++ - infoStart++ - - for i < len(data) && data[i] != '}' && data[i] != '\n' { - infoLength++ - i++ - } - - if i >= len(data) || data[i] != '}' { - return 0, "" - } - - // strip all whitespace at the beginning and the end - // of the {} block - for infoLength > 0 && isspace(data[infoStart]) { - infoStart++ - infoLength-- - } - - for infoLength > 0 && isspace(data[infoStart+infoLength-1]) { - infoLength-- - } - - i++ - } else { - for i < len(data) && !isverticalspace(data[i]) { - infoLength++ - i++ - } - } - - *info = strings.TrimSpace(string(data[infoStart : infoStart+infoLength])) - } - - i = skipChar(data, i, ' ') - if i >= len(data) || data[i] != '\n' { - if newlineOptional && i == len(data) { - return i, marker - } - return 0, "" - } - - return i + 1, marker // Take newline into account. -} - -// fencedCodeBlock returns the end index if data contains a fenced code block at the beginning, -// or 0 otherwise. It writes to out if doRender is true, otherwise it has no side effects. -// If doRender is true, a final newline is mandatory to recognize the fenced code block. -func (p *parser) fencedCodeBlock(out *bytes.Buffer, data []byte, doRender bool) int { - var infoString string - beg, marker := isFenceLine(data, &infoString, "", false) - if beg == 0 || beg >= len(data) { - return 0 - } - - var work bytes.Buffer - - for { - // safe to assume beg < len(data) - - // check for the end of the code block - newlineOptional := !doRender - fenceEnd, _ := isFenceLine(data[beg:], nil, marker, newlineOptional) - if fenceEnd != 0 { - beg += fenceEnd - break - } - - // copy the current line - end := skipUntilChar(data, beg, '\n') + 1 - - // did we reach the end of the buffer without a closing marker? - if end >= len(data) { - return 0 - } - - // verbatim copy to the working buffer - if doRender { - work.Write(data[beg:end]) - } - beg = end - } - - if doRender { - p.r.BlockCode(out, work.Bytes(), infoString) - } - - return beg -} - -func (p *parser) table(out *bytes.Buffer, data []byte) int { - var header bytes.Buffer - i, columns := p.tableHeader(&header, data) - if i == 0 { - return 0 - } - - var body bytes.Buffer - - for i < len(data) { - pipes, rowStart := 0, i - for ; data[i] != '\n'; i++ { - if data[i] == '|' { - pipes++ - } - } - - if pipes == 0 { - i = rowStart - break - } - - // include the newline in data sent to tableRow - i++ - p.tableRow(&body, data[rowStart:i], columns, false) - } - - p.r.Table(out, header.Bytes(), body.Bytes(), columns) - - return i -} - -// check if the specified position is preceded by an odd number of backslashes -func isBackslashEscaped(data []byte, i int) bool { - backslashes := 0 - for i-backslashes-1 >= 0 && data[i-backslashes-1] == '\\' { - backslashes++ - } - return backslashes&1 == 1 -} - -func (p *parser) tableHeader(out *bytes.Buffer, data []byte) (size int, columns []int) { - i := 0 - colCount := 1 - for i = 0; data[i] != '\n'; i++ { - if data[i] == '|' && !isBackslashEscaped(data, i) { - colCount++ - } - } - - // doesn't look like a table header - if colCount == 1 { - return - } - - // include the newline in the data sent to tableRow - header := data[:i+1] - - // column count ignores pipes at beginning or end of line - if data[0] == '|' { - colCount-- - } - if i > 2 && data[i-1] == '|' && !isBackslashEscaped(data, i-1) { - colCount-- - } - - columns = make([]int, colCount) - - // move on to the header underline - i++ - if i >= len(data) { - return - } - - if data[i] == '|' && !isBackslashEscaped(data, i) { - i++ - } - i = skipChar(data, i, ' ') - - // each column header is of form: / *:?-+:? *|/ with # dashes + # colons >= 3 - // and trailing | optional on last column - col := 0 - for data[i] != '\n' { - dashes := 0 - - if data[i] == ':' { - i++ - columns[col] |= TABLE_ALIGNMENT_LEFT - dashes++ - } - for data[i] == '-' { - i++ - dashes++ - } - if data[i] == ':' { - i++ - columns[col] |= TABLE_ALIGNMENT_RIGHT - dashes++ - } - for data[i] == ' ' { - i++ - } - - // end of column test is messy - switch { - case dashes < 3: - // not a valid column - return - - case data[i] == '|' && !isBackslashEscaped(data, i): - // marker found, now skip past trailing whitespace - col++ - i++ - for data[i] == ' ' { - i++ - } - - // trailing junk found after last column - if col >= colCount && data[i] != '\n' { - return - } - - case (data[i] != '|' || isBackslashEscaped(data, i)) && col+1 < colCount: - // something else found where marker was required - return - - case data[i] == '\n': - // marker is optional for the last column - col++ - - default: - // trailing junk found after last column - return - } - } - if col != colCount { - return - } - - p.tableRow(out, header, columns, true) - size = i + 1 - return -} - -func (p *parser) tableRow(out *bytes.Buffer, data []byte, columns []int, header bool) { - i, col := 0, 0 - var rowWork bytes.Buffer - - if data[i] == '|' && !isBackslashEscaped(data, i) { - i++ - } - - for col = 0; col < len(columns) && i < len(data); col++ { - for data[i] == ' ' { - i++ - } - - cellStart := i - - for (data[i] != '|' || isBackslashEscaped(data, i)) && data[i] != '\n' { - i++ - } - - cellEnd := i - - // skip the end-of-cell marker, possibly taking us past end of buffer - i++ - - for cellEnd > cellStart && data[cellEnd-1] == ' ' { - cellEnd-- - } - - var cellWork bytes.Buffer - p.inline(&cellWork, data[cellStart:cellEnd]) - - if header { - p.r.TableHeaderCell(&rowWork, cellWork.Bytes(), columns[col]) - } else { - p.r.TableCell(&rowWork, cellWork.Bytes(), columns[col]) - } - } - - // pad it out with empty columns to get the right number - for ; col < len(columns); col++ { - if header { - p.r.TableHeaderCell(&rowWork, nil, columns[col]) - } else { - p.r.TableCell(&rowWork, nil, columns[col]) - } - } - - // silently ignore rows with too many cells - - p.r.TableRow(out, rowWork.Bytes()) -} - -// returns blockquote prefix length -func (p *parser) quotePrefix(data []byte) int { - i := 0 - for i < 3 && data[i] == ' ' { - i++ - } - if data[i] == '>' { - if data[i+1] == ' ' { - return i + 2 - } - return i + 1 - } - return 0 -} - -// blockquote ends with at least one blank line -// followed by something without a blockquote prefix -func (p *parser) terminateBlockquote(data []byte, beg, end int) bool { - if p.isEmpty(data[beg:]) <= 0 { - return false - } - if end >= len(data) { - return true - } - return p.quotePrefix(data[end:]) == 0 && p.isEmpty(data[end:]) == 0 -} - -// parse a blockquote fragment -func (p *parser) quote(out *bytes.Buffer, data []byte) int { - var raw bytes.Buffer - beg, end := 0, 0 - for beg < len(data) { - end = beg - // Step over whole lines, collecting them. While doing that, check for - // fenced code and if one's found, incorporate it altogether, - // irregardless of any contents inside it - for data[end] != '\n' { - if p.flags&EXTENSION_FENCED_CODE != 0 { - if i := p.fencedCodeBlock(out, data[end:], false); i > 0 { - // -1 to compensate for the extra end++ after the loop: - end += i - 1 - break - } - } - end++ - } - end++ - - if pre := p.quotePrefix(data[beg:]); pre > 0 { - // skip the prefix - beg += pre - } else if p.terminateBlockquote(data, beg, end) { - break - } - - // this line is part of the blockquote - raw.Write(data[beg:end]) - beg = end - } - - var cooked bytes.Buffer - p.block(&cooked, raw.Bytes()) - p.r.BlockQuote(out, cooked.Bytes()) - return end -} - -// returns prefix length for block code -func (p *parser) codePrefix(data []byte) int { - if data[0] == ' ' && data[1] == ' ' && data[2] == ' ' && data[3] == ' ' { - return 4 - } - return 0 -} - -func (p *parser) code(out *bytes.Buffer, data []byte) int { - var work bytes.Buffer - - i := 0 - for i < len(data) { - beg := i - for data[i] != '\n' { - i++ - } - i++ - - blankline := p.isEmpty(data[beg:i]) > 0 - if pre := p.codePrefix(data[beg:i]); pre > 0 { - beg += pre - } else if !blankline { - // non-empty, non-prefixed line breaks the pre - i = beg - break - } - - // verbatim copy to the working buffeu - if blankline { - work.WriteByte('\n') - } else { - work.Write(data[beg:i]) - } - } - - // trim all the \n off the end of work - workbytes := work.Bytes() - eol := len(workbytes) - for eol > 0 && workbytes[eol-1] == '\n' { - eol-- - } - if eol != len(workbytes) { - work.Truncate(eol) - } - - work.WriteByte('\n') - - p.r.BlockCode(out, work.Bytes(), "") - - return i -} - -// returns unordered list item prefix -func (p *parser) uliPrefix(data []byte) int { - i := 0 - - // start with up to 3 spaces - for i < 3 && data[i] == ' ' { - i++ - } - - // need a *, +, or - followed by a space - if (data[i] != '*' && data[i] != '+' && data[i] != '-') || - data[i+1] != ' ' { - return 0 - } - return i + 2 -} - -// returns ordered list item prefix -func (p *parser) oliPrefix(data []byte) int { - i := 0 - - // start with up to 3 spaces - for i < 3 && data[i] == ' ' { - i++ - } - - // count the digits - start := i - for data[i] >= '0' && data[i] <= '9' { - i++ - } - - // we need >= 1 digits followed by a dot and a space - if start == i || data[i] != '.' || data[i+1] != ' ' { - return 0 - } - return i + 2 -} - -// returns definition list item prefix -func (p *parser) dliPrefix(data []byte) int { - i := 0 - - // need a : followed by a spaces - if data[i] != ':' || data[i+1] != ' ' { - return 0 - } - for data[i] == ' ' { - i++ - } - return i + 2 -} - -// parse ordered or unordered list block -func (p *parser) list(out *bytes.Buffer, data []byte, flags int) int { - i := 0 - flags |= LIST_ITEM_BEGINNING_OF_LIST - work := func() bool { - for i < len(data) { - skip := p.listItem(out, data[i:], &flags) - i += skip - - if skip == 0 || flags&LIST_ITEM_END_OF_LIST != 0 { - break - } - flags &= ^LIST_ITEM_BEGINNING_OF_LIST - } - return true - } - - p.r.List(out, work, flags) - return i -} - -// Parse a single list item. -// Assumes initial prefix is already removed if this is a sublist. -func (p *parser) listItem(out *bytes.Buffer, data []byte, flags *int) int { - // keep track of the indentation of the first line - itemIndent := 0 - for itemIndent < 3 && data[itemIndent] == ' ' { - itemIndent++ - } - - i := p.uliPrefix(data) - if i == 0 { - i = p.oliPrefix(data) - } - if i == 0 { - i = p.dliPrefix(data) - // reset definition term flag - if i > 0 { - *flags &= ^LIST_TYPE_TERM - } - } - if i == 0 { - // if in defnition list, set term flag and continue - if *flags&LIST_TYPE_DEFINITION != 0 { - *flags |= LIST_TYPE_TERM - } else { - return 0 - } - } - - // skip leading whitespace on first line - for data[i] == ' ' { - i++ - } - - // find the end of the line - line := i - for i > 0 && data[i-1] != '\n' { - i++ - } - - // get working buffer - var raw bytes.Buffer - - // put the first line into the working buffer - raw.Write(data[line:i]) - line = i - - // process the following lines - containsBlankLine := false - sublist := 0 - codeBlockMarker := "" - -gatherlines: - for line < len(data) { - i++ - - // find the end of this line - for data[i-1] != '\n' { - i++ - } - - // if it is an empty line, guess that it is part of this item - // and move on to the next line - if p.isEmpty(data[line:i]) > 0 { - containsBlankLine = true - raw.Write(data[line:i]) - line = i - continue - } - - // calculate the indentation - indent := 0 - for indent < 4 && line+indent < i && data[line+indent] == ' ' { - indent++ - } - - chunk := data[line+indent : i] - - if p.flags&EXTENSION_FENCED_CODE != 0 { - // determine if in or out of codeblock - // if in codeblock, ignore normal list processing - _, marker := isFenceLine(chunk, nil, codeBlockMarker, false) - if marker != "" { - if codeBlockMarker == "" { - // start of codeblock - codeBlockMarker = marker - } else { - // end of codeblock. - *flags |= LIST_ITEM_CONTAINS_BLOCK - codeBlockMarker = "" - } - } - // we are in a codeblock, write line, and continue - if codeBlockMarker != "" || marker != "" { - raw.Write(data[line+indent : i]) - line = i - continue gatherlines - } - } - - // evaluate how this line fits in - switch { - // is this a nested list item? - case (p.uliPrefix(chunk) > 0 && !p.isHRule(chunk)) || - p.oliPrefix(chunk) > 0 || - p.dliPrefix(chunk) > 0: - - if containsBlankLine { - // end the list if the type changed after a blank line - if indent <= itemIndent && - ((*flags&LIST_TYPE_ORDERED != 0 && p.uliPrefix(chunk) > 0) || - (*flags&LIST_TYPE_ORDERED == 0 && p.oliPrefix(chunk) > 0)) { - - *flags |= LIST_ITEM_END_OF_LIST - break gatherlines - } - *flags |= LIST_ITEM_CONTAINS_BLOCK - } - - // to be a nested list, it must be indented more - // if not, it is the next item in the same list - if indent <= itemIndent { - break gatherlines - } - - // is this the first item in the nested list? - if sublist == 0 { - sublist = raw.Len() - } - - // is this a nested prefix header? - case p.isPrefixHeader(chunk): - // if the header is not indented, it is not nested in the list - // and thus ends the list - if containsBlankLine && indent < 4 { - *flags |= LIST_ITEM_END_OF_LIST - break gatherlines - } - *flags |= LIST_ITEM_CONTAINS_BLOCK - - // anything following an empty line is only part - // of this item if it is indented 4 spaces - // (regardless of the indentation of the beginning of the item) - case containsBlankLine && indent < 4: - if *flags&LIST_TYPE_DEFINITION != 0 && i < len(data)-1 { - // is the next item still a part of this list? - next := i - for data[next] != '\n' { - next++ - } - for next < len(data)-1 && data[next] == '\n' { - next++ - } - if i < len(data)-1 && data[i] != ':' && data[next] != ':' { - *flags |= LIST_ITEM_END_OF_LIST - } - } else { - *flags |= LIST_ITEM_END_OF_LIST - } - break gatherlines - - // a blank line means this should be parsed as a block - case containsBlankLine: - *flags |= LIST_ITEM_CONTAINS_BLOCK - } - - containsBlankLine = false - - // add the line into the working buffer without prefix - raw.Write(data[line+indent : i]) - - line = i - } - - // If reached end of data, the Renderer.ListItem call we're going to make below - // is definitely the last in the list. - if line >= len(data) { - *flags |= LIST_ITEM_END_OF_LIST - } - - rawBytes := raw.Bytes() - - // render the contents of the list item - var cooked bytes.Buffer - if *flags&LIST_ITEM_CONTAINS_BLOCK != 0 && *flags&LIST_TYPE_TERM == 0 { - // intermediate render of block item, except for definition term - if sublist > 0 { - p.block(&cooked, rawBytes[:sublist]) - p.block(&cooked, rawBytes[sublist:]) - } else { - p.block(&cooked, rawBytes) - } - } else { - // intermediate render of inline item - if sublist > 0 { - p.inline(&cooked, rawBytes[:sublist]) - p.block(&cooked, rawBytes[sublist:]) - } else { - p.inline(&cooked, rawBytes) - } - } - - // render the actual list item - cookedBytes := cooked.Bytes() - parsedEnd := len(cookedBytes) - - // strip trailing newlines - for parsedEnd > 0 && cookedBytes[parsedEnd-1] == '\n' { - parsedEnd-- - } - p.r.ListItem(out, cookedBytes[:parsedEnd], *flags) - - return line -} - -// render a single paragraph that has already been parsed out -func (p *parser) renderParagraph(out *bytes.Buffer, data []byte) { - if len(data) == 0 { - return - } - - // trim leading spaces - beg := 0 - for data[beg] == ' ' { - beg++ - } - - // trim trailing newline - end := len(data) - 1 - - // trim trailing spaces - for end > beg && data[end-1] == ' ' { - end-- - } - - work := func() bool { - p.inline(out, data[beg:end]) - return true - } - p.r.Paragraph(out, work) -} - -func (p *parser) paragraph(out *bytes.Buffer, data []byte) int { - // prev: index of 1st char of previous line - // line: index of 1st char of current line - // i: index of cursor/end of current line - var prev, line, i int - - // keep going until we find something to mark the end of the paragraph - for i < len(data) { - // mark the beginning of the current line - prev = line - current := data[i:] - line = i - - // did we find a blank line marking the end of the paragraph? - if n := p.isEmpty(current); n > 0 { - // did this blank line followed by a definition list item? - if p.flags&EXTENSION_DEFINITION_LISTS != 0 { - if i < len(data)-1 && data[i+1] == ':' { - return p.list(out, data[prev:], LIST_TYPE_DEFINITION) - } - } - - p.renderParagraph(out, data[:i]) - return i + n - } - - // an underline under some text marks a header, so our paragraph ended on prev line - if i > 0 { - if level := p.isUnderlinedHeader(current); level > 0 { - // render the paragraph - p.renderParagraph(out, data[:prev]) - - // ignore leading and trailing whitespace - eol := i - 1 - for prev < eol && data[prev] == ' ' { - prev++ - } - for eol > prev && data[eol-1] == ' ' { - eol-- - } - - // render the header - // this ugly double closure avoids forcing variables onto the heap - work := func(o *bytes.Buffer, pp *parser, d []byte) func() bool { - return func() bool { - pp.inline(o, d) - return true - } - }(out, p, data[prev:eol]) - - id := "" - if p.flags&EXTENSION_AUTO_HEADER_IDS != 0 { - id = SanitizedAnchorName(string(data[prev:eol])) - } - - p.r.Header(out, work, level, id) - - // find the end of the underline - for data[i] != '\n' { - i++ - } - return i - } - } - - // if the next line starts a block of HTML, then the paragraph ends here - if p.flags&EXTENSION_LAX_HTML_BLOCKS != 0 { - if data[i] == '<' && p.html(out, current, false) > 0 { - // rewind to before the HTML block - p.renderParagraph(out, data[:i]) - return i - } - } - - // if there's a prefixed header or a horizontal rule after this, paragraph is over - if p.isPrefixHeader(current) || p.isHRule(current) { - p.renderParagraph(out, data[:i]) - return i - } - - // if there's a fenced code block, paragraph is over - if p.flags&EXTENSION_FENCED_CODE != 0 { - if p.fencedCodeBlock(out, current, false) > 0 { - p.renderParagraph(out, data[:i]) - return i - } - } - - // if there's a definition list item, prev line is a definition term - if p.flags&EXTENSION_DEFINITION_LISTS != 0 { - if p.dliPrefix(current) != 0 { - return p.list(out, data[prev:], LIST_TYPE_DEFINITION) - } - } - - // if there's a list after this, paragraph is over - if p.flags&EXTENSION_NO_EMPTY_LINE_BEFORE_BLOCK != 0 { - if p.uliPrefix(current) != 0 || - p.oliPrefix(current) != 0 || - p.quotePrefix(current) != 0 || - p.codePrefix(current) != 0 { - p.renderParagraph(out, data[:i]) - return i - } - } - - // otherwise, scan to the beginning of the next line - for data[i] != '\n' { - i++ - } - i++ - } - - p.renderParagraph(out, data[:i]) - return i -} - -// SanitizedAnchorName returns a sanitized anchor name for the given text. -// -// It implements the algorithm specified in the package comment. -func SanitizedAnchorName(text string) string { - var anchorName []rune - futureDash := false - for _, r := range text { - switch { - case unicode.IsLetter(r) || unicode.IsNumber(r): - if futureDash && len(anchorName) > 0 { - anchorName = append(anchorName, '-') - } - futureDash = false - anchorName = append(anchorName, unicode.ToLower(r)) - default: - futureDash = true - } - } - return string(anchorName) -} diff --git a/vendor/github.com/russross/blackfriday/doc.go b/vendor/github.com/russross/blackfriday/doc.go deleted file mode 100644 index 9656c42a19..0000000000 --- a/vendor/github.com/russross/blackfriday/doc.go +++ /dev/null @@ -1,32 +0,0 @@ -// Package blackfriday is a Markdown processor. -// -// It translates plain text with simple formatting rules into HTML or LaTeX. -// -// Sanitized Anchor Names -// -// Blackfriday includes an algorithm for creating sanitized anchor names -// corresponding to a given input text. This algorithm is used to create -// anchors for headings when EXTENSION_AUTO_HEADER_IDS is enabled. The -// algorithm is specified below, so that other packages can create -// compatible anchor names and links to those anchors. -// -// The algorithm iterates over the input text, interpreted as UTF-8, -// one Unicode code point (rune) at a time. All runes that are letters (category L) -// or numbers (category N) are considered valid characters. They are mapped to -// lower case, and included in the output. All other runes are considered -// invalid characters. Invalid characters that preceed the first valid character, -// as well as invalid character that follow the last valid character -// are dropped completely. All other sequences of invalid characters -// between two valid characters are replaced with a single dash character '-'. -// -// SanitizedAnchorName exposes this functionality, and can be used to -// create compatible links to the anchor names generated by blackfriday. -// This algorithm is also implemented in a small standalone package at -// github.com/shurcooL/sanitized_anchor_name. It can be useful for clients -// that want a small package and don't need full functionality of blackfriday. -package blackfriday - -// NOTE: Keep Sanitized Anchor Name algorithm in sync with package -// github.com/shurcooL/sanitized_anchor_name. -// Otherwise, users of sanitized_anchor_name will get anchor names -// that are incompatible with those generated by blackfriday. diff --git a/vendor/github.com/russross/blackfriday/go.mod b/vendor/github.com/russross/blackfriday/go.mod deleted file mode 100644 index b05561a066..0000000000 --- a/vendor/github.com/russross/blackfriday/go.mod +++ /dev/null @@ -1 +0,0 @@ -module github.com/russross/blackfriday diff --git a/vendor/github.com/russross/blackfriday/html.go b/vendor/github.com/russross/blackfriday/html.go deleted file mode 100644 index e0a6c69c96..0000000000 --- a/vendor/github.com/russross/blackfriday/html.go +++ /dev/null @@ -1,938 +0,0 @@ -// -// Blackfriday Markdown Processor -// Available at http://github.com/russross/blackfriday -// -// Copyright © 2011 Russ Ross . -// Distributed under the Simplified BSD License. -// See README.md for details. -// - -// -// -// HTML rendering backend -// -// - -package blackfriday - -import ( - "bytes" - "fmt" - "regexp" - "strconv" - "strings" -) - -// Html renderer configuration options. -const ( - HTML_SKIP_HTML = 1 << iota // skip preformatted HTML blocks - HTML_SKIP_STYLE // skip embedded