diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7cbc913..3d3fe77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,115 +15,64 @@ on: permissions: contents: write - id-token: write jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.13' - - - name: Install and test - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - python -m pytest tests/ -v --tb=short - build: - needs: test runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + suffix: linux-amd64 + - goos: linux + goarch: arm64 + suffix: linux-arm64 + - goos: darwin + goarch: amd64 + suffix: darwin-amd64 + - goos: darwin + goarch: arm64 + suffix: darwin-arm64 + - goos: windows + goarch: amd64 + suffix: windows-amd64.exe + steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Set up Go + uses: actions/setup-go@v5 with: - python-version: '3.13' - - - name: Install build tools - run: python -m pip install --upgrade pip setuptools wheel build - - - name: Determine version - id: version - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT - else - echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - fi - - - name: Verify version matches __init__.py - run: | - EXPECTED="${{ steps.version.outputs.version }}" - ACTUAL=$(python -c "import grncli; print(grncli.__version__)") - if [ "$EXPECTED" != "$ACTUAL" ]; then - echo "Version mismatch: tag=$EXPECTED, __init__.py=$ACTUAL" - exit 1 - fi - - - name: Build wheel and sdist - run: python -m build - - - name: Build offline bundle - run: | - pip install . - bash scripts/make-bundle + go-version: '1.22' - - name: Upload PyPI artifacts - uses: actions/upload-artifact@v4 - with: - name: pypi-dist - path: | - dist/*.whl - dist/*.tar.gz + - name: Build + working-directory: go + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: '0' + run: go build -o grn-${{ matrix.suffix }} . - - name: Upload bundle artifact + - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: bundle - path: dist/grncli-bundle.zip + name: grn-${{ matrix.suffix }} + path: go/grn-${{ matrix.suffix }} - github-release: + release: needs: build runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4 + - name: Download all artifacts + uses: actions/download-artifact@v4 with: - name: pypi-dist - path: dist/ - - - uses: actions/download-artifact@v4 - with: - name: bundle path: dist/ + merge-multiple: true - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} generate_release_notes: true - files: | - dist/*.whl - dist/*.tar.gz - dist/grncli-bundle.zip - - publish-pypi: - needs: build - runs-on: ubuntu-latest - environment: release - steps: - - uses: actions/download-artifact@v4 - with: - name: pypi-dist - path: dist/ - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} - attestations: false + files: dist/grn-* diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2b13e62..09e4173 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,26 +8,30 @@ on: branches: [main, develop] jobs: - test: + build: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - python-version: ['3.10', '3.13'] + go-version: ['1.22', '1.23'] steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v5 with: - python-version: ${{ matrix.python-version }} + go-version: ${{ matrix.go-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" + - name: Build + working-directory: go + run: CGO_ENABLED=0 go build -o grn . + + - name: Verify binary + working-directory: go + run: ./grn --version - name: Run tests - run: python -m pytest tests/ -v --tb=short + working-directory: go + run: go test ./... -v diff --git a/.gitignore b/.gitignore index 3ccc80c..f9ef3c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# Go +go/grn +go/grn-* +*.exe + +# Python (legacy) __pycache__/ *.pyc *.pyo diff --git a/CLAUDE.md b/CLAUDE.md index 8750bdd..6be079e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,86 +2,124 @@ ## Project overview -GreenNode CLI (`grn`) is a unified command-line tool for managing GreenNode (VNG Cloud) services. Architecture cloned from AWS CLI with hand-written commands. VKS (VNG Kubernetes Service) is the first service; other product teams add their own services by following the same pattern. +GreenNode CLI (`grn`) is a unified command-line tool for managing GreenNode (VNG Cloud) services. Written in Go, distributed as a single binary. VKS (VNG Kubernetes Service) is the first service; other product teams add their own services. - **Repo**: `vngcloud/greennode-cli` - **Docs**: https://vngcloud.github.io/greennode-cli/ -- **PyPI**: https://pypi.org/project/grncli/ +- **Language**: Go (using cobra CLI framework) +- **Binary**: Single file, zero runtime dependencies + +## Project structure + +``` +go/ +├── main.go # Entry point +├── cmd/ +│ ├── root.go # Root command + global flags + --version +│ ├── configure/ +│ │ ├── configure.go # Interactive setup +│ │ ├── list.go # grn configure list +│ │ ├── get.go # grn configure get +│ │ └── set.go # grn configure set +│ └── vks/ +│ ├── vks.go # VKS parent command +│ ├── helpers.go # Shared utilities (client, output, parsing) +│ ├── list_clusters.go # Auto-pagination +│ ├── get_cluster.go +│ ├── create_cluster.go # --dry-run validation +│ ├── update_cluster.go +│ ├── delete_cluster.go # Confirm + --force + --dry-run +│ ├── list_nodegroups.go +│ ├── get_nodegroup.go +│ ├── create_nodegroup.go +│ ├── update_nodegroup.go +│ ├── delete_nodegroup.go +│ ├── wait_cluster_active.go # Polling waiter +│ └── auto_upgrade.go # Set/delete auto-upgrade +├── internal/ +│ ├── config/ +│ │ ├── config.go # Config + credentials loading (INI) +│ │ └── writer.go # ConfigFileWriter (0600 perms) +│ ├── auth/token.go # OAuth2 Client Credentials (IAM) +│ ├── client/client.go # HTTP client with retry + auto-refresh +│ ├── formatter/formatter.go # JSON/Table/Text + JMESPath +│ └── validator/validator.go # ID format validation +├── go.mod, go.sum +``` ## Code conventions -- All source code text must be in **English** — error messages, descriptions, comments, docstrings, ARG_TABLE help_text -- Follow existing AWS CLI patterns: `CLIDriver` → `ServiceCommand` → `BasicCommand` -- Each command file has one class, one responsibility -- Use `display_output(result, parsed_globals)` helper for API response formatting +- All source code text in **English** +- Use cobra for all commands +- Internal packages in `internal/` (not importable externally) +- Commands in `cmd/` following cobra patterns +- Use `cobra.Command` with `RunE` for error handling ## VNG Cloud API quirks -- **IAM API uses camelCase**: `grantType`, `accessToken`, `expiresIn` (not snake_case OAuth2 standard) +- **IAM API uses camelCase**: `grantType`, `accessToken`, `expiresIn` - **VKS API pagination is 0-based**: page 0 = first page -- **`--version` conflict**: Use `--k8s-version` for Kubernetes version to avoid clash with global `--version` flag +- **`--version` conflict**: Use `--k8s-version` for Kubernetes version ## Adding a new command -1. Create file in `grncli/customizations/vks/.py` -2. Extend `BasicCommand` with `NAME`, `DESCRIPTION`, `ARG_TABLE` -3. Implement `_run_main(self, parsed_args, parsed_globals)` -4. Register in `grncli/customizations/vks/__init__.py` -5. Add `validate_id()` calls for any ID args used in URLs +1. Create file in `cmd/vks/.go` +2. Define `cobra.Command` with Use, Short, RunE +3. Add flags via `cmd.Flags()` +4. Register in `cmd/vks/vks.go` init(): `VksCmd.AddCommand(newCmd)` +5. Add `validator.ValidateID()` for any ID args in URLs 6. Add `--dry-run` for create/update/delete commands -7. Add `--force` + confirmation prompt for delete commands +7. Add `--force` + confirmation for delete commands ## Adding a new service -1. Create `grncli/customizations//` -2. Write commands extending `BasicCommand` -3. Register in `grncli/handlers.py` -4. See `grncli/customizations/vks/` for reference +1. Create `cmd//` directory +2. Create parent command file with `cobra.Command` +3. Register in `cmd/root.go` init(): `rootCmd.AddCommand(serviceCmd)` ## Security rules -- **Credential masking**: `grn configure list` and `grn configure get` must mask `client_id`/`client_secret` (show last 4 chars only) -- **Input validation**: All cluster-id and nodegroup-id args must be validated via `validators.validate_id()` before constructing URLs — prevents path traversal -- **SSL default on**: `--no-verify-ssl` must print warning to stderr -- **Tokens in memory only**: Never write tokens to disk or logs -- **Dependency pinning**: Pin to major versions (`httpx<1.0`, `PyYAML<7.0`) +- **Credential masking**: `configure list` and `configure get` mask client_id/client_secret (last 4 chars only) +- **No credential env vars**: `GRN_CLIENT_ID`/`GRN_CLIENT_SECRET` not supported — file only +- **Input validation**: All cluster-id/nodegroup-id validated via `validator.ValidateID()` before URLs +- **SSL default on**: `--no-verify-ssl` prints warning to stderr +- **Tokens in memory only**: Never written to disk or logged +- **File permissions**: Credentials file created with 0600, directory 0700 -## Testing +## Building ```bash -python -m pytest tests/ -v -``` +cd go +CGO_ENABLED=0 go build -o grn . -- Tests must pass on Python 3.10-3.13 × Ubuntu/macOS/Windows -- Skip Unix-only tests on Windows with `@pytest.mark.skipif(platform.system() == 'Windows', ...)` +# Cross-compile +GOOS=linux GOARCH=amd64 go build -o grn-linux-amd64 . +GOOS=darwin GOARCH=arm64 go build -o grn-darwin-arm64 . +GOOS=windows GOARCH=amd64 go build -o grn-windows-amd64.exe . +``` ## Git workflow -- **Do not auto commit/push** — only change source code, user will ask for commit/push when ready -- **Branches**: `main` (production), `develop` (testing), `feat/*` or `fix/*` (feature/bug branches) -- **PRs**: feature → develop (test), feature → main (release-ready) -- **Changelog**: Add fragment via `./scripts/new-change` for every change +- **Do not auto commit/push** — only change source code, user will ask for commit/push +- **Main branch is protected** — must use PR +- **Changelog**: `./scripts/new-change` for every change - **Release**: `./scripts/bump-version minor` → `git push && git push --tags` -- **Main branch is protected** — cannot push directly, must use PR ## Documentation update rule -**After completing any feature or bugfix, update ALL related documentation before considering the work done:** - -1. **GitHub Pages docs** (`docs/`): - - Add/update command reference page in `docs/commands/vks/.md` - - Update `docs/commands/vks/index.md` command table - - Update relevant usage guides if behavior changes (pagination, dry-run, etc.) - - Update `mkdocs.yml` nav if new pages added - -2. **CHANGELOG**: Add changelog fragment via `./scripts/new-change` +**After ANY change to business logic, security, configuration, or commands:** -3. **README.md**: Update if installation, configuration, or basic commands change +1. Review ALL docs below and update what's affected +2. If unsure whether a doc needs updating, read it and check -4. **CLAUDE.md**: Update if conventions, security rules, or key files change +**Docs to check:** -**After ANY change to business logic, security, configuration, or commands:** -Review ALL docs above and update what's affected. If unsure whether a doc needs updating, read it and check. +- `docs/` (GitHub Pages) — command references, usage guides +- `README.md` +- `CLAUDE.md` +- `CONTRIBUTING.md` +- `docs/DEVELOPMENT.md` +- `./scripts/new-change` — changelog fragment Code without docs is not done. @@ -89,13 +127,11 @@ Code without docs is not done. | File | Purpose | |------|---------| -| `grncli/clidriver.py` | CLIDriver + ServiceCommand — main orchestrator | -| `grncli/session.py` | Config, credentials, region, endpoints, SSL, timeouts | -| `grncli/auth.py` | TokenManager — OAuth2 Client Credentials with IAM | -| `grncli/client.py` | HTTP client with retry (3x backoff) + auto token refresh | -| `grncli/customizations/commands.py` | BasicCommand base class + display_output + help system | -| `grncli/customizations/vks/validators.py` | ID format validation | -| `grncli/data/cli.json` | Global CLI options (AWS CLI style) | -| `mkdocs.yml` | Documentation site config | -| `scripts/bump-version` | Bump version + merge changelog + commit + tag | -| `scripts/new-change` | Create changelog fragment | +| `cmd/root.go` | Root command, global flags, --version | +| `cmd/vks/helpers.go` | Client creation, output formatting, label/taint parsing | +| `internal/config/config.go` | Config loading from ~/.greenode/, REGIONS map | +| `internal/config/writer.go` | INI file writer with 0600 perms | +| `internal/auth/token.go` | TokenManager — OAuth2 with IAM (camelCase) | +| `internal/client/client.go` | HTTP client with retry (3x backoff) + 401 refresh | +| `internal/formatter/formatter.go` | JSON/Table/Text + JMESPath | +| `internal/validator/validator.go` | ID format validation | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68869f8..f3e2d4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,28 +1,26 @@ -# Contributing to Greenode CLI - -Thank you for your interest in contributing to the Greenode CLI! +# Contributing to GreenNode CLI ## Getting Started ### Prerequisites -- Python 3.10 or later +- Go 1.22 or later - Git ### Setup development environment ```bash git clone https://github.com/vngcloud/greennode-cli.git -cd greennode-cli -python -m venv .venv -source .venv/bin/activate # On Windows: .venv\Scripts\activate -pip install -e ".[dev]" +cd greennode-cli/go +go build -o grn . +./grn --version ``` -### Run tests +### Build ```bash -python -m pytest tests/ -v +cd go +CGO_ENABLED=0 go build -o grn . ``` ## Development Workflow @@ -30,17 +28,17 @@ python -m pytest tests/ -v ### 1. Create a feature branch ```bash -git checkout develop -git pull +git checkout main && git pull git checkout -b feat/your-feature-name ``` -### 2. Make changes and test +### 2. Make changes and build ```bash +cd go # Write code -# Write tests -python -m pytest tests/ -v +CGO_ENABLED=0 go build -o grn . +./grn vks --help ``` ### 3. Add a changelog entry @@ -65,53 +63,33 @@ docs(readme): update installation instructions ### 5. Create a Pull Request -- PR to `develop` for testing - PR to `main` when release-ready - CI must pass before merge - At least 1 approval required -## Adding a New Service - -Other product teams can add CLI commands: - -1. Create `grncli/customizations//` -2. Write commands extending `BasicCommand` (see `grncli/customizations/vks/` for reference) -3. Register in `grncli/handlers.py` - -### Command template +## Adding a New Command -```python -from grncli.customizations.commands import BasicCommand, display_output +1. Create `go/cmd/vks/.go` +2. Define `cobra.Command` with Use, Short, RunE +3. Register in `go/cmd/vks/vks.go`: `VksCmd.AddCommand(newCmd)` +4. Add `validator.ValidateID()` for any ID args +5. Add `--dry-run` for create/update/delete +6. Add `--force` + confirmation for delete -class MyCommand(BasicCommand): - NAME = 'my-command' - DESCRIPTION = 'Description of my command' - ARG_TABLE = [ - {'name': 'my-arg', 'help_text': 'Argument description', 'required': True}, - ] +## Adding a New Service - def _run_main(self, parsed_args, parsed_globals): - client = self._session.create_client('my-service') - result = client.get('/v1/my-endpoint') - display_output(result, parsed_globals) - return 0 -``` +1. Create `go/cmd//` directory +2. Create parent command with `cobra.Command` +3. Register in `go/cmd/root.go`: `rootCmd.AddCommand(serviceCmd)` ## Code Style -- All source code text (messages, comments, descriptions) must be in English -- Follow existing patterns in the codebase -- Add tests for new features -- Validate user inputs (especially IDs used in URLs) +- All source code text in English +- Use cobra patterns for all commands +- Validate user inputs (IDs used in URLs) - Use `--dry-run` for create/update/delete commands - Add `--force` to skip confirmation on delete commands -## Reporting Issues - -- Use [GitHub Issues](https://github.com/vngcloud/greennode-cli/issues) -- Search existing issues before creating a new one -- Use the provided issue templates - ## License By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE). diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index b4df83f..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include README.md -include LICENSE -include requirements.txt -recursive-include grncli/data *.json diff --git a/README.md b/README.md index 9c751ea..f8de0ee 100644 --- a/README.md +++ b/README.md @@ -10,38 +10,37 @@ The GreenNode CLI (`grn`) is a unified tool to manage your GreenNode services fr ### Requirements -- Python 3.10 or later (3.10.x, 3.11.x, 3.12.x, 3.13.x) +- No dependencies required — `grn` is a single binary ### Installation -The safest way to install the GreenNode CLI is to use `pip` in a `virtualenv`: +Download the latest binary for your platform from [GitHub Releases](https://github.com/vngcloud/greennode-cli/releases): -```bash -python -m pip install grncli -``` - -or, if you are not installing in a `virtualenv`, to install globally: +**macOS / Linux:** ```bash -sudo python -m pip install grncli +# Download (replace OS and ARCH as needed) +curl -L -o grn https://github.com/vngcloud/greennode-cli/releases/latest/download/grn-darwin-arm64 +chmod +x grn +sudo mv grn /usr/local/bin/ ``` -or for your user: +**Or build from source:** ```bash -python -m pip install --user grncli +git clone https://github.com/vngcloud/greennode-cli.git +cd greennode-cli/go +go build -o grn . +sudo mv grn /usr/local/bin/ ``` -If you have the grncli package installed and want to upgrade to the latest version: +**Verify installation:** ```bash -python -m pip install --upgrade grncli +grn --version +# grn-cli/0.1.0 Go/1.22.2 darwin/arm64 ``` -On Linux and macOS, the GreenNode CLI can also be installed using a [bundled installer](https://vngcloud.github.io/greennode-cli/installation/#bundled-installer). For offline environments, see the [offline install](https://vngcloud.github.io/greennode-cli/installation/#offline-install) guide. - -If you want to run the `develop` branch of the GreenNode CLI, see the [Contributing Guide](CONTRIBUTING.md). - ### Configuration Before using the GreenNode CLI, you need to configure your credentials. The quickest way is to run: @@ -59,12 +58,6 @@ Default output format [json]: Credentials are obtained from the [VNG Cloud IAM Portal](https://hcm-3.console.vngcloud.vn/iam/) under Service Accounts. -You can also configure the region via environment variable: - -```bash -export GRN_DEFAULT_REGION=HCM-3 -``` - Or create the credential files directly: ```ini @@ -109,7 +102,7 @@ To get help on any command: ```bash grn help grn vks -grn vks create-cluster help +grn vks create-cluster --help ``` To check the version: @@ -130,7 +123,6 @@ The best way to interact with our team is through GitHub: - [Documentation](https://vngcloud.github.io/greennode-cli/) - [Changelog](CHANGELOG.md) - [Contributing Guide](CONTRIBUTING.md) -- [PyPI Package](https://pypi.org/project/grncli/) - [VNG Cloud Console](https://hcm-3.console.vngcloud.vn/) ## License diff --git a/bin/grn b/bin/grn deleted file mode 100755 index 780db22..0000000 --- a/bin/grn +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env python3 -# greenode-cli/bin/grn -import sys -import grncli.clidriver - - -def main(): - return grncli.clidriver.main() - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1e343d3..3db2413 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,184 +1,120 @@ -# Greenode CLI — Development Guide +# GreenNode CLI — Development Guide ## Developer Workflow: Feature/Bug → Release ### Phase 1: Development ```bash -# 1. Create a new branch -git checkout -b feat/add-vks-describe-events +# 1. Create a feature branch +git checkout main && git pull +git checkout -b feat/add-new-command -# 2. Code + test -vim grncli/customizations/vks/describe_events.py -python -m pytest tests/ -v +# 2. Code +# Add command in go/cmd/vks/.go +# Register in go/cmd/vks/vks.go -# 3. Add changelog fragment -./scripts/new-change -t feature -c vks -d "Add describe-events command" -# → Creates: .changes/next-release/feature-vks-a1b2c3d4.json +# 3. Build and test +cd go +CGO_ENABLED=0 go build -o grn . +./grn vks --help +./grn vks --dry-run ... -# 4. Commit + push -git add . -git commit -m "feat(vks): add describe-events command" -git push -u origin feat/add-vks-describe-events -``` - -### Phase 2: PR to develop (testing) +# 4. Add changelog fragment +cd .. +./scripts/new-change -t feature -c vks -d "Add new command" -``` -5. Create PR on GitHub (feat/add-vks-describe-events → develop) - -6. GitHub Actions auto-trigger: - - run-tests.yml - ├── Python 3.10 × Ubuntu ✅ - ├── Python 3.10 × macOS ✅ - ├── Python 3.10 × Windows ✅ - ├── Python 3.11 × Ubuntu ✅ - ├── ... - └── Python 3.13 × Windows ✅ - - bundle-test.yml - ├── Python 3.10 × Ubuntu ✅ - ├── Python 3.10 × macOS ✅ - ├── Python 3.13 × Ubuntu ✅ - └── Python 3.13 × macOS ✅ - -7. Review + merge PR to develop -8. Test on develop environment +# 5. Commit + push +git add . +git commit -m "feat(vks): add new command" +git push -u origin feat/add-new-command ``` -### Phase 3: PR to main (release-ready) +### Phase 2: Pull Request ``` -9. Create PR on GitHub (feat/add-vks-describe-events → main) - - Same CI checks run again on main - - Review + merge PR to main +5. Create PR on GitHub (feat/add-new-command → main) +6. CI runs tests +7. Review + merge PR to main ``` -### Phase 4: Release +### Phase 3: Release ```bash -# 10. Checkout main +# 8. Checkout main git checkout main git pull -# 11. Bump version (e.g. 0.1.0 → 0.2.0) +# 9. Bump version ./scripts/bump-version minor -``` +# Updates go/cmd/root.go version, merges changelog, commits, tags -The `bump-version` script automatically: -- Updates `grncli/__init__.py`: `'0.1.0'` → `'0.2.0'` -- Merges `.changes/next-release/*.json` → `.changes/0.2.0.json` -- Clears `.changes/next-release/` -- Regenerates `CHANGELOG.md` -- Commits: `release: v0.2.0` -- Creates git tag: `v0.2.0` - -```bash -# 10. Push + push tags +# 10. Push git push && git push --tags -``` - -``` -11. GitHub Actions auto-trigger (release.yml): - - Job 1: test - pip install + pytest ✅ - - Job 2: build (depends on test) - Verify tag v0.2.0 == __init__.py 0.2.0 ✅ - python -m build → dist/grncli-0.2.0.whl ✅ - scripts/make-bundle → grncli-bundle.zip ✅ - Upload artifacts ✅ - - Job 3: github-release (depends on build) - Create GitHub Release "v0.2.0" ✅ - Upload: grncli-0.2.0.whl - grncli-0.2.0.tar.gz - grncli-bundle.zip - - Job 4: publish-pypi (depends on build) - Publish to PyPI ✅ - → pip install grncli==0.2.0 +# → GitHub Actions: build binaries → GitHub Release → upload artifacts ``` ### Phase 4: Users Install ```bash -# From PyPI -pip install grncli -pip install grncli==0.2.0 +# Download binary from GitHub Releases +curl -L -o grn https://github.com/vngcloud/greennode-cli/releases/latest/download/grn-darwin-arm64 +chmod +x grn +sudo mv grn /usr/local/bin/ -# From GitHub Releases (offline bundle) -unzip grncli-bundle.zip -cd grncli-bundle && ./install-offline +# Or build from source +git clone https://github.com/vngcloud/greennode-cli.git +cd greennode-cli/go && go build -o grn . ``` --- -## Hotfix Flow - -For urgent fixes that skip the PR process: +## Building ```bash -git checkout main -vim grncli/auth.py # Fix bug -python -m pytest tests/ -v -./scripts/new-change -t bugfix -c auth -d "Fix token refresh race condition" -git commit -am "fix(auth): fix token refresh race condition" -./scripts/bump-version patch # 0.2.0 → 0.2.1 -git push && git push --tags # → release.yml triggers +cd go + +# Build for current platform +CGO_ENABLED=0 go build -o grn . + +# Cross-compile for all platforms +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o grn-linux-amd64 . +GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o grn-linux-arm64 . +GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o grn-darwin-amd64 . +GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o grn-darwin-arm64 . +GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o grn-windows-amd64.exe . ``` --- -## Manual Release (Workflow Dispatch) - -Trigger a release manually from GitHub UI: +## Hotfix Flow +```bash +git checkout main +cd go +# Fix bug +CGO_ENABLED=0 go build -o grn . +./grn vks +cd .. +./scripts/new-change -t bugfix -c auth -d "Fix token refresh" +git commit -am "fix(auth): fix token refresh" +./scripts/bump-version patch +git push && git push --tags ``` -GitHub → Actions → Release → Run workflow → Input version: "0.2.1" -``` - -**When to use:** -- Release workflow failed mid-way (e.g. PyPI publish timeout) — re-run with same version -- Tag exists but release workflow was not yet configured at that time -- Need to rebuild release artifacts without bumping version - -90% of releases use tag trigger (via `bump-version` + push). Manual dispatch is a fallback. --- ## Changelog Management -### Adding entries - ```bash # Interactive ./scripts/new-change # CLI args -./scripts/new-change -t feature -c vks -d "Add describe-events command" +./scripts/new-change -t feature -c vks -d "Add new command" ./scripts/new-change -t bugfix -c auth -d "Fix token refresh" -./scripts/new-change -t enhancement -c configure -d "Add region validation" -``` - -**Change types:** `feature`, `bugfix`, `enhancement`, `api-change` - -### Viewing unreleased changes - -```bash -ls .changes/next-release/ -cat .changes/next-release/*.json -``` - -### Regenerating CHANGELOG.md - -```bash -./scripts/render-changelog ``` ---- +Change types: `feature`, `bugfix`, `enhancement`, `api-change` ## Version Bumping @@ -188,25 +124,11 @@ cat .changes/next-release/*.json ./scripts/bump-version major # 0.1.0 → 1.0.0 (breaking changes) ``` ---- - ## CI/CD Workflows | Workflow | Trigger | Purpose | |----------|---------|---------| -| `run-tests.yml` | PR, push to main | Test matrix: Python 3.10-3.13 × Ubuntu/macOS/Windows | -| `release.yml` | Tag push `v*`, manual dispatch | Build + GitHub Release + PyPI publish | -| `bundle-test.yml` | PR, push to main | Test offline bundle installation | -| `stale.yml` | Daily schedule | Auto-close stale issues (30 days stale, 7 days close) | - ---- - -## Adding a New Service - -Other product teams can add CLI commands: - -1. Create `grncli/customizations//` -2. Write commands extending `BasicCommand` -3. Register in `grncli/handlers.py` - -See `grncli/customizations/vks/` for a complete reference implementation. +| `run-tests.yml` | PR to main/develop | Build + test Go binary | +| `release.yml` | Tag push `v*`, manual dispatch | Build multi-platform binaries + GitHub Release | +| `deploy-docs.yml` | Push to main (docs/) | Deploy documentation to GitHub Pages | +| `stale.yml` | Daily schedule | Auto-close stale issues | diff --git a/docs/development/contributing.md b/docs/development/contributing.md index 2a443a5..b762104 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -6,17 +6,21 @@ See [CONTRIBUTING.md](https://github.com/vngcloud/greennode-cli/blob/main/CONTRI ```bash git clone https://github.com/vngcloud/greennode-cli.git -cd greennode-cli -python -m venv .venv -source .venv/bin/activate -pip install -e ".[dev]" -python -m pytest tests/ -v +cd greennode-cli/go +go build -o grn . +./grn --version ``` -## Adding a new service +## Adding a new command + +1. Create `cmd/vks/.go` +2. Define `cobra.Command` with Use, Short, RunE +3. Register in `cmd/vks/vks.go` +4. Add `validator.ValidateID()` for ID args +5. Add `--dry-run` for create/update/delete -1. Create `grncli/customizations//` -2. Write commands extending `BasicCommand` -3. Register in `grncli/handlers.py` +## Adding a new service -See `grncli/customizations/vks/` for a complete reference implementation. +1. Create `cmd//` directory +2. Create parent command with `cobra.Command` +3. Register in `cmd/root.go` diff --git a/docs/development/release.md b/docs/development/release.md index fc83355..fb3bf10 100644 --- a/docs/development/release.md +++ b/docs/development/release.md @@ -2,8 +2,6 @@ ## Adding changelog entries -Every PR should include a changelog fragment: - ```bash ./scripts/new-change # Interactive ./scripts/new-change -t feature -c vks -d "Add new command" # CLI args @@ -20,22 +18,6 @@ Change types: `feature`, `bugfix`, `enhancement`, `api-change` git push && git push --tags # Triggers GitHub Actions release ``` -The `bump-version` script automatically: - -1. Updates version in `grncli/__init__.py` -2. Merges changelog fragments into versioned file -3. Regenerates `CHANGELOG.md` -4. Commits and tags - -## CI/CD workflows - -| Workflow | Trigger | Purpose | -|----------|---------|---------| -| `run-tests.yml` | PR, push to main/develop | Test matrix: Python 3.10-3.13 × Ubuntu/macOS/Windows | -| `release.yml` | Tag push `v*`, manual dispatch | Build + GitHub Release + PyPI publish | -| `bundle-test.yml` | PR, push to main/develop | Test offline bundle installation | -| `stale.yml` | Daily schedule | Auto-close stale issues | - ## Release flow ``` @@ -45,8 +27,15 @@ Developer workflow: 3. Push: git push && git push --tags GitHub Actions (automatic): -4. run-tests → Tests pass -5. release → Build wheel + sdist + bundle -6. → Create GitHub Release with artifacts -7. → Publish to PyPI (requires approval) +4. Build Go binaries for Linux/macOS/Windows (amd64 + arm64) +5. Create GitHub Release with binaries attached ``` + +## CI/CD workflows + +| Workflow | Trigger | Purpose | +|----------|---------|---------| +| `run-tests.yml` | PR to main/develop | Build + test Go binary | +| `release.yml` | Tag push `v*`, manual dispatch | Build multi-platform binaries + GitHub Release | +| `deploy-docs.yml` | Push to main (docs/) | Deploy documentation to GitHub Pages | +| `stale.yml` | Daily schedule | Auto-close stale issues | diff --git a/docs/index.md b/docs/index.md index 794d937..bcb12e8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,13 +2,15 @@ Universal Command Line Interface for GreenNode. -The GreenNode CLI (`grn`) is a unified tool to manage your GreenNode services from the command line. +The GreenNode CLI (`grn`) is a unified tool to manage your GreenNode services from the command line. Written in Go, distributed as a single binary with zero dependencies. ## Quick Start ```bash -# Install -pip install grncli +# Download binary (macOS Apple Silicon example) +curl -L -o grn https://github.com/vngcloud/greennode-cli/releases/latest/download/grn-darwin-arm64 +chmod +x grn +sudo mv grn /usr/local/bin/ # Configure credentials grn configure @@ -22,6 +24,7 @@ grn vks get-cluster --cluster-id ## Features +- **Single Binary** — Zero dependencies, download and run - **VKS Management** — Full cluster and node group lifecycle (create, get, update, delete) - **Multiple Output Formats** — JSON, table, and text with JMESPath query filtering - **Auto-pagination** — List commands fetch all pages by default @@ -31,6 +34,7 @@ grn vks get-cluster --cluster-id - **Profile Support** — Multiple credential profiles for different environments - **Retry with Backoff** — Automatic retry for transient errors (5xx, timeouts) - **Security** — Credentials masked in output, input validation, SSL by default +- **Cross-platform** — Linux, macOS, Windows (amd64, arm64) ## Adding New Services diff --git a/docs/installation.md b/docs/installation.md index 6f24688..1c781f8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,78 +1,53 @@ # Installation -## Prerequisites +## Download binary -- Python 3.10 or later -- `pip` 21.0 or greater -- `setuptools` 68.0 or greater +Download the latest binary for your platform from [GitHub Releases](https://github.com/vngcloud/greennode-cli/releases): -## Install from PyPI - -The recommended way to install the GreenNode CLI is to use `pip` in a `virtualenv`: - -```bash -python -m pip install grncli -``` - -or, if you are not installing in a `virtualenv`, to install globally: +### macOS ```bash -sudo python -m pip install grncli -``` - -or for your user: - -```bash -python -m pip install --user grncli -``` +# Apple Silicon (M1/M2/M3) +curl -L -o grn https://github.com/vngcloud/greennode-cli/releases/latest/download/grn-darwin-arm64 -If you have the grncli package installed and want to upgrade to the latest version: +# Intel +curl -L -o grn https://github.com/vngcloud/greennode-cli/releases/latest/download/grn-darwin-amd64 -```bash -python -m pip install --upgrade grncli +chmod +x grn +sudo mv grn /usr/local/bin/ ``` -## Install from source +### Linux ```bash -git clone https://github.com/vngcloud/greennode-cli.git -cd greennode-cli -python -m pip install . -``` +# x86_64 +curl -L -o grn https://github.com/vngcloud/greennode-cli/releases/latest/download/grn-linux-amd64 -To install with development dependencies: +# ARM64 +curl -L -o grn https://github.com/vngcloud/greennode-cli/releases/latest/download/grn-linux-arm64 -```bash -python -m pip install -e ".[dev]" +chmod +x grn +sudo mv grn /usr/local/bin/ ``` -## Bundled installer - -On Linux and macOS, the GreenNode CLI can be installed using a standalone installer that creates an isolated virtualenv: +### Windows -```bash -./scripts/install -``` - -This installs to `~/.local/lib/GreenNode` and symlinks `grn` to `~/.local/bin/`. Make sure `~/.local/bin` is in your `PATH`. +Download `grn-windows-amd64.exe` from [GitHub Releases](https://github.com/vngcloud/greennode-cli/releases) and add to your PATH. -## Offline install +## Build from source -For environments without internet access, you can build a self-contained bundle: +Requires [Go 1.22+](https://go.dev/dl/): ```bash -# On a machine with internet access -./scripts/make-bundle - -# Transfer dist/grncli-bundle.zip to target machine, then: -unzip grncli-bundle.zip -cd grncli-bundle -./install-offline +git clone https://github.com/vngcloud/greennode-cli.git +cd greennode-cli/go +go build -o grn . +sudo mv grn /usr/local/bin/ ``` ## Verify installation ```bash grn --version -# grn-cli/0.1.0 Python/3.13.5 Darwin/25.2.0 +# grn-cli/0.1.0 Go/1.22.2 darwin/arm64 ``` diff --git a/docs/usage/getting-started.md b/docs/usage/getting-started.md index 6939777..239a080 100644 --- a/docs/usage/getting-started.md +++ b/docs/usage/getting-started.md @@ -6,7 +6,7 @@ grn # Show available commands grn help # Same grn vks # Show available VKS commands -grn vks create-cluster help # Show command help with required/optional args +grn vks create-cluster --help # Show command help with all flags ``` ## Command structure diff --git a/go/.gitignore b/go/.gitignore new file mode 100644 index 0000000..c30f5f2 --- /dev/null +++ b/go/.gitignore @@ -0,0 +1 @@ +grn diff --git a/go/cmd/configure/configure.go b/go/cmd/configure/configure.go new file mode 100644 index 0000000..b302735 --- /dev/null +++ b/go/cmd/configure/configure.go @@ -0,0 +1,116 @@ +package configure + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/config" +) + +var validRegions = []string{"HCM-3", "HAN"} +var validOutputs = []string{"json", "text", "table"} + +// ConfigureCmd is the `grn configure` command. +var ConfigureCmd = &cobra.Command{ + Use: "configure", + Short: "Configure GreenNode CLI credentials and settings", + Long: `Interactive setup for GreenNode CLI. + +Prompts for Client ID, Client Secret, Region, and Output format. +Saves credentials to ~/.greenode/credentials and config to ~/.greenode/config.`, + Run: runConfigure, +} + +func init() { + ConfigureCmd.AddCommand(listCmd) + ConfigureCmd.AddCommand(getCmd) + ConfigureCmd.AddCommand(setCmd) +} + +func runConfigure(cmd *cobra.Command, args []string) { + profile := cmd.Flag("profile").Value.String() + if profile == "" { + profile = os.Getenv("GRN_PROFILE") + } + if profile == "" { + profile = "default" + } + + // Load existing config for defaults + cfg, _ := config.LoadConfig(profile) + + reader := bufio.NewReader(os.Stdin) + + clientID := promptWithDefault(reader, "Client ID", maskCred(cfg.ClientID)) + clientSecret := promptWithDefault(reader, "Client Secret", maskCred(cfg.ClientSecret)) + region := promptWithDefault(reader, "Default region name", cfg.Region) + output := promptWithDefault(reader, "Default output format", cfg.Output) + + // If user entered masked value or empty, keep original + if clientID == maskCred(cfg.ClientID) || clientID == "" { + clientID = cfg.ClientID + } + if clientSecret == maskCred(cfg.ClientSecret) || clientSecret == "" { + clientSecret = cfg.ClientSecret + } + + // Validate region + if !contains(validRegions, region) { + fmt.Fprintf(os.Stderr, "Warning: invalid region '%s', using default 'HCM-3'\n", region) + region = "HCM-3" + } + + // Validate output + if !contains(validOutputs, output) { + fmt.Fprintf(os.Stderr, "Warning: invalid output format '%s', using default 'json'\n", output) + output = "json" + } + + writer := config.NewConfigFileWriter() + + if err := writer.WriteCredentials(profile, clientID, clientSecret); err != nil { + fmt.Fprintf(os.Stderr, "Error saving credentials: %v\n", err) + os.Exit(1) + } + + if err := writer.WriteConfig(profile, region, output); err != nil { + fmt.Fprintf(os.Stderr, "Error saving config: %v\n", err) + os.Exit(1) + } + + fmt.Println("Configuration saved successfully.") +} + +func promptWithDefault(reader *bufio.Reader, prompt, defaultVal string) string { + if defaultVal != "" { + fmt.Printf("%s [%s]: ", prompt, defaultVal) + } else { + fmt.Printf("%s: ", prompt) + } + + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + return defaultVal + } + return input +} + +func maskCred(value string) string { + if value == "" { + return "" + } + return config.MaskCredential(value) +} + +func contains(list []string, val string) bool { + for _, v := range list { + if v == val { + return true + } + } + return false +} diff --git a/go/cmd/configure/get.go b/go/cmd/configure/get.go new file mode 100644 index 0000000..fde7dba --- /dev/null +++ b/go/cmd/configure/get.go @@ -0,0 +1,55 @@ +package configure + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/config" +) + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get a configuration value", + Args: cobra.ExactArgs(1), + Run: runGet, +} + +func runGet(cmd *cobra.Command, args []string) { + key := args[0] + profile := cmd.Flag("profile").Value.String() + if profile == "" { + profile = os.Getenv("GRN_PROFILE") + } + if profile == "" { + profile = "default" + } + + cfg, err := config.LoadConfig(profile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + var value string + switch key { + case "client_id": + value = config.MaskCredential(cfg.ClientID) + case "client_secret": + value = config.MaskCredential(cfg.ClientSecret) + case "region": + value = cfg.Region + case "output": + value = cfg.Output + case "profile": + value = cfg.Profile + default: + fmt.Fprintf(os.Stderr, "Unknown configuration key: %s\n", key) + os.Exit(1) + } + + if value == "" { + value = "" + } + fmt.Println(value) +} diff --git a/go/cmd/configure/list.go b/go/cmd/configure/list.go new file mode 100644 index 0000000..8292aae --- /dev/null +++ b/go/cmd/configure/list.go @@ -0,0 +1,97 @@ +package configure + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/config" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List current configuration values", + Run: runList, +} + +type configEntry struct { + name string + value string + typ string + location string +} + +func runList(cmd *cobra.Command, args []string) { + profile := cmd.Flag("profile").Value.String() + if profile == "" { + profile = os.Getenv("GRN_PROFILE") + } + if profile == "" { + profile = "default" + } + + cfg, _ := config.LoadConfig(profile) + configDir := config.DefaultConfigDir() + credsFile := filepath.Join(configDir, "credentials") + configFile := filepath.Join(configDir, "config") + + entries := []configEntry{ + resolveEntry("profile", profile, "", ""), + resolveCredEntry("client_id", cfg.ClientID, credsFile), + resolveCredEntry("client_secret", cfg.ClientSecret, credsFile), + resolveConfigEntry("region", cfg.Region, configFile), + resolveConfigEntry("output", cfg.Output, configFile), + } + + // Print header + fmt.Printf("%13s %24s %15s %s\n", "Name", "Value", "Type", "Location") + fmt.Printf("%13s %24s %15s %s\n", "----", "-----", "----", "--------") + + for _, e := range entries { + fmt.Printf("%13s %24s %15s %s\n", e.name, e.value, e.typ, e.location) + } +} + +func resolveEntry(name, value, typ, location string) configEntry { + if value == "" { + return configEntry{name: name, value: "", typ: "None", location: "None"} + } + if typ == "" { + typ = "None" + } + if location == "" { + location = "None" + } + return configEntry{name: name, value: value, typ: typ, location: location} +} + +func resolveCredEntry(name, value, credsFile string) configEntry { + if value == "" { + return configEntry{name: name, value: "", typ: "None", location: "None"} + } + home, _ := os.UserHomeDir() + loc := "~" + credsFile[len(home):] + return configEntry{name: name, value: config.MaskCredential(value), typ: "config-file", location: loc} +} + +func resolveConfigEntry(name, value, configFile string) configEntry { + if value == "" { + return configEntry{name: name, value: "", typ: "None", location: "None"} + } + + // Check if value came from env var + envMap := map[string]string{ + "region": "GRN_DEFAULT_REGION", + "output": "GRN_DEFAULT_OUTPUT", + } + if envVar, ok := envMap[name]; ok { + if os.Getenv(envVar) != "" { + return configEntry{name: name, value: value, typ: "env", location: envVar} + } + } + + home, _ := os.UserHomeDir() + loc := "~" + configFile[len(home):] + return configEntry{name: name, value: value, typ: "config-file", location: loc} +} diff --git a/go/cmd/configure/set.go b/go/cmd/configure/set.go new file mode 100644 index 0000000..0f9eabd --- /dev/null +++ b/go/cmd/configure/set.go @@ -0,0 +1,62 @@ +package configure + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/config" +) + +var setCmd = &cobra.Command{ + Use: "set ", + Short: "Set a configuration value", + Args: cobra.ExactArgs(2), + Run: runSet, +} + +func runSet(cmd *cobra.Command, args []string) { + key := args[0] + value := args[1] + profile := cmd.Flag("profile").Value.String() + if profile == "" { + profile = os.Getenv("GRN_PROFILE") + } + if profile == "" { + profile = "default" + } + + writer := config.NewConfigFileWriter() + + switch key { + case "client_id": + cfg, _ := config.LoadConfig(profile) + if err := writer.WriteCredentials(profile, value, cfg.ClientSecret); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + case "client_secret": + cfg, _ := config.LoadConfig(profile) + if err := writer.WriteCredentials(profile, cfg.ClientID, value); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + case "region": + cfg, _ := config.LoadConfig(profile) + if err := writer.WriteConfig(profile, value, cfg.Output); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + case "output": + cfg, _ := config.LoadConfig(profile) + if err := writer.WriteConfig(profile, cfg.Region, value); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "Unknown configuration key: %s\n", key) + os.Exit(1) + } + + fmt.Printf("Set '%s' to '%s' for profile '%s'.\n", key, value, profile) +} diff --git a/go/cmd/root.go b/go/cmd/root.go new file mode 100644 index 0000000..6ce3772 --- /dev/null +++ b/go/cmd/root.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "fmt" + "os" + "runtime" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/cmd/configure" + "github.com/vngcloud/greennode-cli/cmd/vks" +) + +const cliVersion = "0.1.0" + +// Global flags +var ( + Profile string + Region string + Output string + Query string + EndpointURL string + NoVerifySSL bool + Debug bool + CLIReadTimeout int + CLIConnectTimeout int + Color string +) + +var rootCmd = &cobra.Command{ + Use: "grn", + Short: "GreenNode CLI - unified command-line tool for GreenNode (VNG Cloud) services", + Version: fmt.Sprintf("%s Go/%s %s/%s", cliVersion, runtime.Version()[2:], runtime.GOOS, runtime.GOARCH), + Long: `GreenNode CLI (grn) is a unified command-line tool for managing +GreenNode (VNG Cloud) services including VKS (VNG Kubernetes Service). + +To get started, run: + grn configure + +For help on any command: + grn --help`, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + rootCmd.PersistentFlags().StringVar(&Profile, "profile", "", "Use a specific profile from credentials file") + rootCmd.PersistentFlags().StringVar(&Region, "region", "", "The region to use (e.g. HCM-3, HAN)") + rootCmd.PersistentFlags().StringVar(&Output, "output", "", "The output format (json, text, table)") + rootCmd.PersistentFlags().StringVar(&Query, "query", "", "JMESPath query to filter output") + rootCmd.PersistentFlags().StringVar(&EndpointURL, "endpoint-url", "", "Override the service endpoint URL") + rootCmd.PersistentFlags().BoolVar(&NoVerifySSL, "no-verify-ssl", false, "Disable SSL certificate verification") + rootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "Enable debug logging") + rootCmd.PersistentFlags().IntVar(&CLIReadTimeout, "cli-read-timeout", 30, "HTTP read timeout in seconds") + rootCmd.PersistentFlags().IntVar(&CLIConnectTimeout, "cli-connect-timeout", 30, "HTTP connect timeout in seconds") + rootCmd.PersistentFlags().StringVar(&Color, "color", "auto", "Color output (on, off, auto)") + + rootCmd.SetVersionTemplate("grn-cli/{{.Version}}\n") + + rootCmd.AddCommand(configure.ConfigureCmd) + rootCmd.AddCommand(vks.VksCmd) +} + +// Execute runs the root command. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go/cmd/vks/auto_upgrade.go b/go/cmd/vks/auto_upgrade.go new file mode 100644 index 0000000..e5597ed --- /dev/null +++ b/go/cmd/vks/auto_upgrade.go @@ -0,0 +1,104 @@ +package vks + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var setAutoUpgradeConfigCmd = &cobra.Command{ + Use: "set-auto-upgrade-config", + Short: "Configure auto-upgrade schedule for a cluster", + RunE: runSetAutoUpgradeConfig, +} + +var deleteAutoUpgradeConfigCmd = &cobra.Command{ + Use: "delete-auto-upgrade-config", + Short: "Delete auto-upgrade config for a cluster", + RunE: runDeleteAutoUpgradeConfig, +} + +func init() { + // set-auto-upgrade-config flags + f := setAutoUpgradeConfigCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("weekdays", "", "Days of the week, e.g. Mon,Wed,Fri (required)") + f.String("time", "", "Time of day in 24h format HH:mm, e.g. 03:00 (required)") + setAutoUpgradeConfigCmd.MarkFlagRequired("cluster-id") + setAutoUpgradeConfigCmd.MarkFlagRequired("weekdays") + setAutoUpgradeConfigCmd.MarkFlagRequired("time") + + // delete-auto-upgrade-config flags + g := deleteAutoUpgradeConfigCmd.Flags() + g.String("cluster-id", "", "Cluster ID (required)") + g.Bool("force", false, "Skip confirmation prompt") + deleteAutoUpgradeConfigCmd.MarkFlagRequired("cluster-id") +} + +func runSetAutoUpgradeConfig(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + weekdays, _ := cmd.Flags().GetString("weekdays") + timeVal, _ := cmd.Flags().GetString("time") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + body := map[string]interface{}{ + "weekdays": weekdays, + "time": timeVal, + } + + result, err := apiClient.Put( + fmt.Sprintf("/v1/clusters/%s/auto-upgrade-config", clusterID), body, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} + +func runDeleteAutoUpgradeConfig(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + force, _ := cmd.Flags().GetBool("force") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + if !force { + fmt.Print("Are you sure you want to delete the auto-upgrade config? (yes/no): ") + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + if strings.TrimSpace(strings.ToLower(response)) != "yes" { + fmt.Println("Delete cancelled.") + return nil + } + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Delete( + fmt.Sprintf("/v1/clusters/%s/auto-upgrade-config", clusterID), nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/create_cluster.go b/go/cmd/vks/create_cluster.go new file mode 100644 index 0000000..5bc8081 --- /dev/null +++ b/go/cmd/vks/create_cluster.go @@ -0,0 +1,191 @@ +package vks + +import ( + "fmt" + "os" + "regexp" + "strconv" + + "github.com/spf13/cobra" +) + +var createClusterCmd = &cobra.Command{ + Use: "create-cluster", + Short: "Create a new VKS cluster", + RunE: runCreateCluster, +} + +func init() { + f := createClusterCmd.Flags() + // Cluster settings (required) + f.String("name", "", "Cluster name (required)") + f.String("k8s-version", "", "Kubernetes version (required)") + f.String("network-type", "", "Network type: CALICO, CILIUM_OVERLAY, CILIUM_NATIVE_ROUTING (required)") + f.String("vpc-id", "", "VPC ID (required)") + f.String("subnet-id", "", "Subnet ID (required)") + // Node group settings (required) + f.String("node-group-name", "", "Default node group name (required)") + f.String("flavor-id", "", "Flavor ID for node group (required)") + f.String("image-id", "", "Image ID for node group (required)") + f.String("disk-type", "", "Disk type ID (required)") + f.String("ssh-key-id", "", "SSH key ID for node group (required)") + + for _, name := range []string{"name", "k8s-version", "network-type", "vpc-id", "subnet-id", "node-group-name", "flavor-id", "image-id", "disk-type", "ssh-key-id"} { + createClusterCmd.MarkFlagRequired(name) + } + + // Cluster settings (optional) + f.String("cidr", "", "CIDR block (required for CALICO and CILIUM_OVERLAY)") + f.String("description", "", "Cluster description") + f.Bool("enable-private-cluster", false, "Enable private cluster") + f.String("release-channel", "STABLE", "Release channel (RAPID, STABLE)") + f.Bool("no-load-balancer-plugin", false, "Disable load balancer plugin") + f.Bool("no-block-store-csi-plugin", false, "Disable block store CSI plugin") + + // Node group settings (optional) + f.Int("disk-size", 100, "Disk size in GiB (20-5000)") + f.Int("num-nodes", 1, "Number of nodes (0-10)") + f.Bool("enable-private-nodes", false, "Enable private nodes") + f.String("security-groups", "", "Security group IDs (comma-separated)") + f.String("labels", "", "Node labels as key=value pairs (comma-separated)") + f.String("taints", "", "Node taints as key=value:effect (comma-separated)") + f.Bool("dry-run", false, "Validate parameters without creating the cluster") +} + +func runCreateCluster(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + k8sVersion, _ := cmd.Flags().GetString("k8s-version") + networkType, _ := cmd.Flags().GetString("network-type") + vpcID, _ := cmd.Flags().GetString("vpc-id") + subnetID, _ := cmd.Flags().GetString("subnet-id") + cidr, _ := cmd.Flags().GetString("cidr") + description, _ := cmd.Flags().GetString("description") + enablePrivateCluster, _ := cmd.Flags().GetBool("enable-private-cluster") + releaseChannel, _ := cmd.Flags().GetString("release-channel") + noLBPlugin, _ := cmd.Flags().GetBool("no-load-balancer-plugin") + noCSIPlugin, _ := cmd.Flags().GetBool("no-block-store-csi-plugin") + + ngName, _ := cmd.Flags().GetString("node-group-name") + flavorID, _ := cmd.Flags().GetString("flavor-id") + imageID, _ := cmd.Flags().GetString("image-id") + diskType, _ := cmd.Flags().GetString("disk-type") + sshKeyID, _ := cmd.Flags().GetString("ssh-key-id") + diskSize, _ := cmd.Flags().GetInt("disk-size") + numNodes, _ := cmd.Flags().GetInt("num-nodes") + enablePrivateNodes, _ := cmd.Flags().GetBool("enable-private-nodes") + securityGroups, _ := cmd.Flags().GetString("security-groups") + labels, _ := cmd.Flags().GetString("labels") + taints, _ := cmd.Flags().GetString("taints") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + // Build node group + nodeGroup := map[string]interface{}{ + "name": ngName, + "flavorId": flavorID, + "imageId": imageID, + "diskSize": diskSize, + "diskType": diskType, + "numNodes": numNodes, + "enablePrivateNodes": enablePrivateNodes, + "sshKeyId": sshKeyID, + "upgradeConfig": map[string]interface{}{ + "maxSurge": 1, + "maxUnavailable": 0, + "strategy": "SURGE", + }, + "subnetId": subnetID, + "securityGroups": []string{}, + } + + if securityGroups != "" { + nodeGroup["securityGroups"] = parseCommaSeparated(securityGroups) + } + if labels != "" { + nodeGroup["labels"] = parseLabels(labels) + } + if taints != "" { + nodeGroup["taints"] = parseTaints(taints) + } + + // Build cluster body + body := map[string]interface{}{ + "name": name, + "version": k8sVersion, + "networkType": networkType, + "vpcId": vpcID, + "subnetId": subnetID, + "enablePrivateCluster": enablePrivateCluster, + "releaseChannel": releaseChannel, + "enabledBlockStoreCsiPlugin": !noCSIPlugin, + "enabledLoadBalancerPlugin": !noLBPlugin, + "enabledServiceEndpoint": false, + "azStrategy": "SINGLE", + "nodeGroups": []interface{}{nodeGroup}, + } + + if cidr != "" { + body["cidr"] = cidr + } + if description != "" { + body["description"] = description + } + + if dryRun { + return validateCreateCluster(name, ngName, networkType, cidr, diskSize, numNodes) + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post("/v1/clusters", body) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} + +func validateCreateCluster(name, ngName, networkType, cidr string, diskSize, numNodes int) error { + clusterNameRE := regexp.MustCompile(`^[a-z0-9][a-z0-9\-]{3,18}[a-z0-9]$`) + ngNameRE := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{3,13}[a-z0-9]$`) + + var errors []string + + if !clusterNameRE.MatchString(name) { + errors = append(errors, fmt.Sprintf( + "Cluster name '%s' is invalid. Must be 5-20 chars, lowercase alphanumeric and hyphens, start/end with alphanumeric.", name)) + } + + if (networkType == "CALICO" || networkType == "CILIUM_OVERLAY") && cidr == "" { + errors = append(errors, fmt.Sprintf("--cidr is required when network-type is %s", networkType)) + } + + if !ngNameRE.MatchString(ngName) { + errors = append(errors, fmt.Sprintf( + "Node group name '%s' is invalid. Must be 5-15 chars, lowercase alphanumeric and hyphens, start/end with alphanumeric.", ngName)) + } + + if diskSize < 20 || diskSize > 5000 { + errors = append(errors, fmt.Sprintf("Disk size %s out of range (20-5000 GiB)", strconv.Itoa(diskSize))) + } + + if numNodes < 0 || numNodes > 10 { + errors = append(errors, fmt.Sprintf("Number of nodes %s out of range (0-10)", strconv.Itoa(numNodes))) + } + + fmt.Println("=== DRY RUN: Validation results ===") + fmt.Println() + if len(errors) > 0 { + fmt.Printf("Found %d error(s):\n", len(errors)) + for _, e := range errors { + fmt.Printf(" - %s\n", e) + } + os.Exit(1) + } + + fmt.Println("All parameters are valid. Run without --dry-run to create the cluster.") + return nil +} diff --git a/go/cmd/vks/create_nodegroup.go b/go/cmd/vks/create_nodegroup.go new file mode 100644 index 0000000..c65f7b2 --- /dev/null +++ b/go/cmd/vks/create_nodegroup.go @@ -0,0 +1,140 @@ +package vks + +import ( + "fmt" + "os" + "regexp" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var createNodegroupCmd = &cobra.Command{ + Use: "create-nodegroup", + Short: "Create a new node group", + RunE: runCreateNodegroup, +} + +func init() { + f := createNodegroupCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("name", "", "Node group name (required)") + f.String("image-id", "", "Image ID (required)") + f.String("flavor-id", "", "Flavor ID (required)") + f.String("disk-type", "", "Disk type ID (required)") + f.String("ssh-key-id", "", "SSH key ID (required)") + f.Bool("enable-private-nodes", false, "Enable private nodes") + f.Int("num-nodes", 1, "Number of nodes (0-10)") + f.Int("disk-size", 100, "Disk size in GiB (20-5000)") + f.String("security-groups", "", "Security group IDs (comma-separated)") + f.String("subnet-id", "", "Subnet ID for node group") + f.String("labels", "", "Node labels as key=value pairs (comma-separated)") + f.String("taints", "", "Node taints as key=value:effect (comma-separated)") + f.Bool("enable-encryption-volume", false, "Enable volume encryption") + f.Bool("dry-run", false, "Validate parameters without creating") + + for _, name := range []string{"cluster-id", "name", "image-id", "flavor-id", "disk-type", "ssh-key-id"} { + createNodegroupCmd.MarkFlagRequired(name) + } +} + +func runCreateNodegroup(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + name, _ := cmd.Flags().GetString("name") + imageID, _ := cmd.Flags().GetString("image-id") + flavorID, _ := cmd.Flags().GetString("flavor-id") + diskType, _ := cmd.Flags().GetString("disk-type") + sshKeyID, _ := cmd.Flags().GetString("ssh-key-id") + enablePrivateNodes, _ := cmd.Flags().GetBool("enable-private-nodes") + numNodes, _ := cmd.Flags().GetInt("num-nodes") + diskSize, _ := cmd.Flags().GetInt("disk-size") + securityGroups, _ := cmd.Flags().GetString("security-groups") + subnetID, _ := cmd.Flags().GetString("subnet-id") + labelsStr, _ := cmd.Flags().GetString("labels") + taintsStr, _ := cmd.Flags().GetString("taints") + enableEncryption, _ := cmd.Flags().GetBool("enable-encryption-volume") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + body := map[string]interface{}{ + "name": name, + "numNodes": numNodes, + "imageId": imageID, + "flavorId": flavorID, + "diskSize": diskSize, + "diskType": diskType, + "enablePrivateNodes": enablePrivateNodes, + "sshKeyId": sshKeyID, + "enabledEncryptionVolume": enableEncryption, + "securityGroups": []string{}, + "upgradeConfig": map[string]interface{}{ + "maxSurge": 1, + "maxUnavailable": 0, + "strategy": "SURGE", + }, + } + + if securityGroups != "" { + body["securityGroups"] = parseCommaSeparated(securityGroups) + } + if subnetID != "" { + body["subnetId"] = subnetID + } + if labelsStr != "" { + body["labels"] = parseLabels(labelsStr) + } + if taintsStr != "" { + body["taints"] = parseTaints(taintsStr) + } + + if dryRun { + return validateCreateNodegroup(name, diskSize, numNodes) + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post( + fmt.Sprintf("/v1/clusters/%s/node-groups", clusterID), body, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} + +func validateCreateNodegroup(name string, diskSize, numNodes int) error { + ngNameRE := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{3,13}[a-z0-9]$`) + var errors []string + + if !ngNameRE.MatchString(name) { + errors = append(errors, fmt.Sprintf( + "Node group name '%s' is invalid. Must be 5-15 chars, lowercase alphanumeric and hyphens.", name)) + } + if diskSize < 20 || diskSize > 5000 { + errors = append(errors, fmt.Sprintf("Disk size %d out of range (20-5000 GiB)", diskSize)) + } + if numNodes < 0 || numNodes > 10 { + errors = append(errors, fmt.Sprintf("Number of nodes %d out of range (0-10)", numNodes)) + } + + fmt.Println("=== DRY RUN: Validation results ===") + fmt.Println() + if len(errors) > 0 { + fmt.Printf("Found %d error(s):\n", len(errors)) + for _, e := range errors { + fmt.Printf(" - %s\n", e) + } + os.Exit(1) + } + + fmt.Println("All parameters are valid. Run without --dry-run to create.") + return nil +} diff --git a/go/cmd/vks/delete_cluster.go b/go/cmd/vks/delete_cluster.go new file mode 100644 index 0000000..d0d36e5 --- /dev/null +++ b/go/cmd/vks/delete_cluster.go @@ -0,0 +1,109 @@ +package vks + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var deleteClusterCmd = &cobra.Command{ + Use: "delete-cluster", + Short: "Delete a VKS cluster", + RunE: runDeleteCluster, +} + +func init() { + f := deleteClusterCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.Bool("dry-run", false, "Preview what will be deleted without executing") + f.Bool("force", false, "Skip confirmation prompt") + + deleteClusterCmd.MarkFlagRequired("cluster-id") +} + +func runDeleteCluster(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // Fetch cluster info for preview + cluster, err := apiClient.Get(fmt.Sprintf("/v1/clusters/%s", clusterID), nil) + if err != nil { + return fmt.Errorf("failed to fetch cluster: %w", err) + } + + nodegroups, err := apiClient.Get( + fmt.Sprintf("/v1/clusters/%s/node-groups", clusterID), + map[string]string{"page": "0", "pageSize": "50"}, + ) + if err != nil { + return fmt.Errorf("failed to fetch node groups: %w", err) + } + + // Show preview + printClusterPreview(cluster, nodegroups) + + if dryRun { + fmt.Println("Run without --dry-run to delete.") + return nil + } + + // Confirm unless --force + if !force { + fmt.Print("\nAre you sure you want to delete this cluster? (yes/no): ") + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + if strings.TrimSpace(strings.ToLower(response)) != "yes" { + fmt.Println("Delete cancelled.") + return nil + } + } + + result, err := apiClient.Delete(fmt.Sprintf("/v1/clusters/%s", clusterID), nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} + +func printClusterPreview(cluster, nodegroups interface{}) { + clusterMap, _ := cluster.(map[string]interface{}) + fmt.Println("The following resources will be deleted:") + fmt.Println() + fmt.Println("Cluster:") + fmt.Printf(" ID: %v\n", clusterMap["id"]) + fmt.Printf(" Name: %v\n", clusterMap["name"]) + fmt.Printf(" Status: %v\n", clusterMap["status"]) + fmt.Printf(" Version: %v\n", clusterMap["version"]) + fmt.Printf(" Nodes: %v\n", clusterMap["numNodes"]) + fmt.Println() + + ngMap, _ := nodegroups.(map[string]interface{}) + items, _ := ngMap["items"].([]interface{}) + if len(items) > 0 { + fmt.Printf("Node groups (%d):\n", len(items)) + for _, item := range items { + ng, _ := item.(map[string]interface{}) + fmt.Printf(" - %v (ID: %v, nodes: %v)\n", ng["name"], ng["id"], ng["numNodes"]) + } + } else { + fmt.Println("Node groups: none") + } + + fmt.Println("\nThis action is irreversible.") +} diff --git a/go/cmd/vks/delete_nodegroup.go b/go/cmd/vks/delete_nodegroup.go new file mode 100644 index 0000000..0f49c7d --- /dev/null +++ b/go/cmd/vks/delete_nodegroup.go @@ -0,0 +1,102 @@ +package vks + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var deleteNodegroupCmd = &cobra.Command{ + Use: "delete-nodegroup", + Short: "Delete a node group", + RunE: runDeleteNodegroup, +} + +func init() { + f := deleteNodegroupCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("nodegroup-id", "", "Node group ID (required)") + f.Bool("force-delete", false, "Force delete on API side") + f.Bool("dry-run", false, "Preview what will be deleted without executing") + f.Bool("force", false, "Skip confirmation prompt") + + deleteNodegroupCmd.MarkFlagRequired("cluster-id") + deleteNodegroupCmd.MarkFlagRequired("nodegroup-id") +} + +func runDeleteNodegroup(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + nodegroupID, _ := cmd.Flags().GetString("nodegroup-id") + forceDelete, _ := cmd.Flags().GetBool("force-delete") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + if err := validator.ValidateID(nodegroupID, "nodegroup-id"); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // Fetch nodegroup info for preview + ng, err := apiClient.Get( + fmt.Sprintf("/v1/clusters/%s/node-groups/%s", clusterID, nodegroupID), nil, + ) + if err != nil { + return fmt.Errorf("failed to fetch node group: %w", err) + } + + ngMap, _ := ng.(map[string]interface{}) + fmt.Println("The following node group will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", ngMap["id"]) + fmt.Printf(" Name: %v\n", ngMap["name"]) + fmt.Printf(" Status: %v\n", ngMap["status"]) + fmt.Printf(" Nodes: %v\n", ngMap["numNodes"]) + fmt.Println() + fmt.Println("This action is irreversible.") + + if dryRun { + fmt.Println("Run without --dry-run to delete.") + return nil + } + + if !force { + fmt.Print("\nAre you sure you want to delete this node group? (yes/no): ") + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + if strings.TrimSpace(strings.ToLower(response)) != "yes" { + fmt.Println("Delete cancelled.") + return nil + } + } + + params := map[string]string{} + if forceDelete { + params["forceDelete"] = "true" + } + + var paramsArg map[string]string + if len(params) > 0 { + paramsArg = params + } + + result, err := apiClient.Delete( + fmt.Sprintf("/v1/clusters/%s/node-groups/%s", clusterID, nodegroupID), paramsArg, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/get_cluster.go b/go/cmd/vks/get_cluster.go new file mode 100644 index 0000000..95eccf3 --- /dev/null +++ b/go/cmd/vks/get_cluster.go @@ -0,0 +1,40 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getClusterCmd = &cobra.Command{ + Use: "get-cluster", + Short: "Get details of a VKS cluster", + RunE: runGetCluster, +} + +func init() { + getClusterCmd.Flags().String("cluster-id", "", "Cluster ID (required)") + getClusterCmd.MarkFlagRequired("cluster-id") +} + +func runGetCluster(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v1/clusters/%s", clusterID), nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/get_nodegroup.go b/go/cmd/vks/get_nodegroup.go new file mode 100644 index 0000000..7372901 --- /dev/null +++ b/go/cmd/vks/get_nodegroup.go @@ -0,0 +1,51 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getNodegroupCmd = &cobra.Command{ + Use: "get-nodegroup", + Short: "Get details of a node group", + RunE: runGetNodegroup, +} + +func init() { + f := getNodegroupCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("nodegroup-id", "", "Node group ID (required)") + + getNodegroupCmd.MarkFlagRequired("cluster-id") + getNodegroupCmd.MarkFlagRequired("nodegroup-id") +} + +func runGetNodegroup(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + nodegroupID, _ := cmd.Flags().GetString("nodegroup-id") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + if err := validator.ValidateID(nodegroupID, "nodegroup-id"); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get( + fmt.Sprintf("/v1/clusters/%s/node-groups/%s", clusterID, nodegroupID), nil, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/helpers.go b/go/cmd/vks/helpers.go new file mode 100644 index 0000000..5394a56 --- /dev/null +++ b/go/cmd/vks/helpers.go @@ -0,0 +1,136 @@ +package vks + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/auth" + "github.com/vngcloud/greennode-cli/internal/client" + "github.com/vngcloud/greennode-cli/internal/config" + "github.com/vngcloud/greennode-cli/internal/formatter" +) + +// createClient builds a GreenodeClient from the current command flags. +func createClient(cmd *cobra.Command) (*client.GreenodeClient, error) { + profile, _ := cmd.Flags().GetString("profile") + region, _ := cmd.Flags().GetString("region") + endpointURL, _ := cmd.Flags().GetString("endpoint-url") + noVerifySSL, _ := cmd.Flags().GetBool("no-verify-ssl") + debug, _ := cmd.Flags().GetBool("debug") + readTimeout, _ := cmd.Flags().GetInt("cli-read-timeout") + + cfg, err := config.LoadConfig(profile) + if err != nil { + return nil, err + } + + if cfg.ClientID == "" || cfg.ClientSecret == "" { + return nil, fmt.Errorf("credentials not configured. Run 'grn configure' to set up credentials") + } + + if region != "" { + cfg.Region = region + } + + var baseURL string + if endpointURL != "" { + baseURL = endpointURL + } else { + baseURL, err = cfg.GetEndpoint("vks") + if err != nil { + return nil, err + } + } + + if noVerifySSL { + fmt.Fprintln(os.Stderr, "Warning: SSL certificate verification is disabled. This is not recommended for production use.") + } + + tokenManager := auth.NewTokenManager(cfg.ClientID, cfg.ClientSecret) + timeout := time.Duration(readTimeout) * time.Second + + return client.NewGreenodeClient(baseURL, tokenManager, timeout, !noVerifySSL, debug), nil +} + +// outputResult formats and prints the API response. +func outputResult(cmd *cobra.Command, data interface{}) error { + output, _ := cmd.Flags().GetString("output") + query, _ := cmd.Flags().GetString("query") + + if output == "" { + // Load from config + profile, _ := cmd.Flags().GetString("profile") + cfg, _ := config.LoadConfig(profile) + if cfg != nil { + output = cfg.Output + } + } + if output == "" { + output = "json" + } + + return formatter.Format(data, output, query, os.Stdout) +} + +// parseLabels parses "key1=val1,key2=val2" into a map. +func parseLabels(labelsStr string) map[string]string { + result := make(map[string]string) + if labelsStr == "" { + return result + } + for _, pair := range strings.Split(labelsStr, ",") { + pair = strings.TrimSpace(pair) + if idx := strings.Index(pair, "="); idx > 0 { + result[strings.TrimSpace(pair[:idx])] = strings.TrimSpace(pair[idx+1:]) + } + } + return result +} + +// Taint represents a Kubernetes taint. +type Taint struct { + Key string `json:"key"` + Value string `json:"value"` + Effect string `json:"effect"` +} + +// parseTaints parses "key=value:effect,..." into a slice of Taints. +func parseTaints(taintsStr string) []Taint { + var result []Taint + if taintsStr == "" { + return result + } + for _, t := range strings.Split(taintsStr, ",") { + t = strings.TrimSpace(t) + if colonIdx := strings.LastIndex(t, ":"); colonIdx > 0 { + kv := t[:colonIdx] + effect := strings.TrimSpace(t[colonIdx+1:]) + key, value := kv, "" + if eqIdx := strings.Index(kv, "="); eqIdx > 0 { + key = strings.TrimSpace(kv[:eqIdx]) + value = strings.TrimSpace(kv[eqIdx+1:]) + } + result = append(result, Taint{Key: key, Value: value, Effect: effect}) + } + } + return result +} + +// parseCommaSeparated splits a comma-separated string into a trimmed slice. +func parseCommaSeparated(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} diff --git a/go/cmd/vks/list_clusters.go b/go/cmd/vks/list_clusters.go new file mode 100644 index 0000000..054c3ef --- /dev/null +++ b/go/cmd/vks/list_clusters.go @@ -0,0 +1,55 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var listClustersCmd = &cobra.Command{ + Use: "list-clusters", + Short: "List all VKS clusters", + RunE: runListClusters, +} + +func init() { + listClustersCmd.Flags().Int("page", -1, "Page number (0-based)") + listClustersCmd.Flags().Int("page-size", 50, "Number of items per page") + listClustersCmd.Flags().Bool("no-paginate", false, "Disable auto-pagination") +} + +func runListClusters(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + noPaginate, _ := cmd.Flags().GetBool("no-paginate") + + var result interface{} + + if page >= 0 || noPaginate { + // Single page request + if page < 0 { + page = 0 + } + params := map[string]string{ + "page": fmt.Sprintf("%d", page), + "pageSize": fmt.Sprintf("%d", pageSize), + } + result, err = apiClient.Get("/v1/clusters", params) + } else { + // Auto-pagination: fetch all pages + result, err = apiClient.GetAllPages("/v1/clusters", pageSize) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/list_nodegroups.go b/go/cmd/vks/list_nodegroups.go new file mode 100644 index 0000000..fc868fc --- /dev/null +++ b/go/cmd/vks/list_nodegroups.go @@ -0,0 +1,64 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var listNodegroupsCmd = &cobra.Command{ + Use: "list-nodegroups", + Short: "List node groups in a VKS cluster", + RunE: runListNodegroups, +} + +func init() { + f := listNodegroupsCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.Int("page", -1, "Page number (0-based)") + f.Int("page-size", 50, "Number of items per page") + f.Bool("no-paginate", false, "Disable auto-pagination") + + listNodegroupsCmd.MarkFlagRequired("cluster-id") +} + +func runListNodegroups(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + noPaginate, _ := cmd.Flags().GetBool("no-paginate") + + path := fmt.Sprintf("/v1/clusters/%s/node-groups", clusterID) + var result interface{} + + if page >= 0 || noPaginate { + if page < 0 { + page = 0 + } + params := map[string]string{ + "page": fmt.Sprintf("%d", page), + "pageSize": fmt.Sprintf("%d", pageSize), + } + result, err = apiClient.Get(path, params) + } else { + result, err = apiClient.GetAllPages(path, pageSize) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/update_cluster.go b/go/cmd/vks/update_cluster.go new file mode 100644 index 0000000..1e6ed2d --- /dev/null +++ b/go/cmd/vks/update_cluster.go @@ -0,0 +1,92 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var updateClusterCmd = &cobra.Command{ + Use: "update-cluster", + Short: "Update a VKS cluster", + RunE: runUpdateCluster, +} + +func init() { + f := updateClusterCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("k8s-version", "", "Kubernetes version (required)") + f.String("whitelist-node-cidrs", "", "Whitelist CIDRs, comma-separated (required)") + f.Bool("enabled-load-balancer-plugin", false, "Enable load balancer plugin") + f.Bool("no-load-balancer-plugin", false, "Disable load balancer plugin") + f.Bool("enabled-block-store-csi-plugin", false, "Enable block store CSI plugin") + f.Bool("no-block-store-csi-plugin", false, "Disable block store CSI plugin") + f.Bool("dry-run", false, "Validate parameters without updating") + + updateClusterCmd.MarkFlagRequired("cluster-id") + updateClusterCmd.MarkFlagRequired("k8s-version") + updateClusterCmd.MarkFlagRequired("whitelist-node-cidrs") +} + +func runUpdateCluster(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + k8sVersion, _ := cmd.Flags().GetString("k8s-version") + whitelistCIDRs, _ := cmd.Flags().GetString("whitelist-node-cidrs") + enabledLB, _ := cmd.Flags().GetBool("enabled-load-balancer-plugin") + noLB, _ := cmd.Flags().GetBool("no-load-balancer-plugin") + enabledCSI, _ := cmd.Flags().GetBool("enabled-block-store-csi-plugin") + noCSI, _ := cmd.Flags().GetBool("no-block-store-csi-plugin") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + body := map[string]interface{}{ + "version": k8sVersion, + "whitelistNodeCIDRs": parseCommaSeparated(whitelistCIDRs), + } + + if enabledLB { + body["enabledLoadBalancerPlugin"] = true + } else if noLB { + body["enabledLoadBalancerPlugin"] = false + } + + if enabledCSI { + body["enabledBlockStoreCsiPlugin"] = true + } else if noCSI { + body["enabledBlockStoreCsiPlugin"] = false + } + + if dryRun { + fmt.Println("=== DRY RUN: Update cluster ===") + fmt.Println() + fmt.Printf("Cluster ID: %s\n", clusterID) + fmt.Printf("New version: %s\n", k8sVersion) + fmt.Printf("Whitelist CIDRs: %s\n", whitelistCIDRs) + if v, ok := body["enabledLoadBalancerPlugin"]; ok { + fmt.Printf("Load balancer plugin: %v\n", v) + } + if v, ok := body["enabledBlockStoreCsiPlugin"]; ok { + fmt.Printf("Block store CSI plugin: %v\n", v) + } + fmt.Println("\nRun without --dry-run to update.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Put(fmt.Sprintf("/v1/clusters/%s", clusterID), body) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/update_nodegroup.go b/go/cmd/vks/update_nodegroup.go new file mode 100644 index 0000000..0010f95 --- /dev/null +++ b/go/cmd/vks/update_nodegroup.go @@ -0,0 +1,134 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var updateNodegroupCmd = &cobra.Command{ + Use: "update-nodegroup", + Short: "Update a node group", + RunE: runUpdateNodegroup, +} + +func init() { + f := updateNodegroupCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("nodegroup-id", "", "Node group ID (required)") + f.String("image-id", "", "Image ID (required)") + f.String("num-nodes", "", "New number of nodes") + f.String("security-groups", "", "Security group IDs (comma-separated)") + f.String("labels", "", "Node labels as key=value pairs (comma-separated)") + f.String("taints", "", "Node taints as key=value:effect (comma-separated)") + f.String("auto-scale-min", "", "Auto-scale minimum nodes") + f.String("auto-scale-max", "", "Auto-scale maximum nodes") + f.String("upgrade-strategy", "", "Upgrade strategy (SURGE)") + f.String("upgrade-max-surge", "", "Max surge during upgrade") + f.String("upgrade-max-unavailable", "", "Max unavailable during upgrade") + f.Bool("dry-run", false, "Preview update without executing") + + updateNodegroupCmd.MarkFlagRequired("cluster-id") + updateNodegroupCmd.MarkFlagRequired("nodegroup-id") + updateNodegroupCmd.MarkFlagRequired("image-id") +} + +func runUpdateNodegroup(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + nodegroupID, _ := cmd.Flags().GetString("nodegroup-id") + imageID, _ := cmd.Flags().GetString("image-id") + numNodes, _ := cmd.Flags().GetString("num-nodes") + securityGroups, _ := cmd.Flags().GetString("security-groups") + labelsStr, _ := cmd.Flags().GetString("labels") + taintsStr, _ := cmd.Flags().GetString("taints") + autoScaleMin, _ := cmd.Flags().GetString("auto-scale-min") + autoScaleMax, _ := cmd.Flags().GetString("auto-scale-max") + upgradeStrategy, _ := cmd.Flags().GetString("upgrade-strategy") + upgradeMaxSurge, _ := cmd.Flags().GetString("upgrade-max-surge") + upgradeMaxUnavail, _ := cmd.Flags().GetString("upgrade-max-unavailable") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + if err := validator.ValidateID(nodegroupID, "nodegroup-id"); err != nil { + return err + } + + body := map[string]interface{}{ + "imageId": imageID, + } + + if numNodes != "" { + body["numNodes"] = toInt(numNodes) + } + if securityGroups != "" { + body["securityGroups"] = parseCommaSeparated(securityGroups) + } + if labelsStr != "" { + body["labels"] = parseLabels(labelsStr) + } + if taintsStr != "" { + body["taints"] = parseTaints(taintsStr) + } + + if autoScaleMin != "" || autoScaleMax != "" { + autoScaleConfig := map[string]interface{}{} + if autoScaleMin != "" { + autoScaleConfig["minSize"] = toInt(autoScaleMin) + } + if autoScaleMax != "" { + autoScaleConfig["maxSize"] = toInt(autoScaleMax) + } + body["autoScaleConfig"] = autoScaleConfig + } + + if upgradeStrategy != "" || upgradeMaxSurge != "" || upgradeMaxUnavail != "" { + upgradeConfig := map[string]interface{}{} + if upgradeStrategy != "" { + upgradeConfig["strategy"] = upgradeStrategy + } + if upgradeMaxSurge != "" { + upgradeConfig["maxSurge"] = toInt(upgradeMaxSurge) + } + if upgradeMaxUnavail != "" { + upgradeConfig["maxUnavailable"] = toInt(upgradeMaxUnavail) + } + body["upgradeConfig"] = upgradeConfig + } + + if dryRun { + fmt.Println("=== DRY RUN: Update node group ===") + fmt.Println() + fmt.Printf("Cluster ID: %s\n", clusterID) + fmt.Printf("Node group ID: %s\n", nodegroupID) + for key, value := range body { + fmt.Printf(" %s: %v\n", key, value) + } + fmt.Println("\nRun without --dry-run to update.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Put( + fmt.Sprintf("/v1/clusters/%s/node-groups/%s", clusterID, nodegroupID), body, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} + +func toInt(s string) int { + var n int + fmt.Sscanf(s, "%d", &n) + return n +} diff --git a/go/cmd/vks/vks.go b/go/cmd/vks/vks.go new file mode 100644 index 0000000..0198ada --- /dev/null +++ b/go/cmd/vks/vks.go @@ -0,0 +1,38 @@ +package vks + +import ( + "github.com/spf13/cobra" +) + +// VksCmd is the parent command for all VKS subcommands. +var VksCmd = &cobra.Command{ + Use: "vks", + Short: "VNG Kubernetes Service (VKS) commands", + Long: "Manage VKS clusters, node groups, and related resources.", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +func init() { + // Cluster commands + VksCmd.AddCommand(listClustersCmd) + VksCmd.AddCommand(getClusterCmd) + VksCmd.AddCommand(createClusterCmd) + VksCmd.AddCommand(updateClusterCmd) + VksCmd.AddCommand(deleteClusterCmd) + + // Nodegroup commands + VksCmd.AddCommand(listNodegroupsCmd) + VksCmd.AddCommand(getNodegroupCmd) + VksCmd.AddCommand(createNodegroupCmd) + VksCmd.AddCommand(updateNodegroupCmd) + VksCmd.AddCommand(deleteNodegroupCmd) + + // Wait commands + VksCmd.AddCommand(waitClusterActiveCmd) + + // Auto-upgrade commands + VksCmd.AddCommand(setAutoUpgradeConfigCmd) + VksCmd.AddCommand(deleteAutoUpgradeConfigCmd) +} diff --git a/go/cmd/vks/wait_cluster_active.go b/go/cmd/vks/wait_cluster_active.go new file mode 100644 index 0000000..5fd7238 --- /dev/null +++ b/go/cmd/vks/wait_cluster_active.go @@ -0,0 +1,75 @@ +package vks + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var waitClusterActiveCmd = &cobra.Command{ + Use: "wait-cluster-active", + Short: "Wait until cluster reaches ACTIVE status", + RunE: runWaitClusterActive, +} + +func init() { + f := waitClusterActiveCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.Int("delay", 15, "Seconds between polls") + f.Int("max-attempts", 40, "Maximum poll attempts") + + waitClusterActiveCmd.MarkFlagRequired("cluster-id") +} + +func runWaitClusterActive(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + delay, _ := cmd.Flags().GetInt("delay") + maxAttempts, _ := cmd.Flags().GetInt("max-attempts") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + for attempt := 1; attempt <= maxAttempts; attempt++ { + result, err := apiClient.Get(fmt.Sprintf("/v1/clusters/%s", clusterID), nil) + if err != nil { + fmt.Fprintf(os.Stderr, "\rWaiting for cluster %s: error fetching status (attempt %d/%d)", + clusterID, attempt, maxAttempts) + } else { + resultMap, _ := result.(map[string]interface{}) + status, _ := resultMap["status"].(string) + + fmt.Fprintf(os.Stderr, "\rWaiting for cluster %s: %s (attempt %d/%d)", + clusterID, status, attempt, maxAttempts) + + if status == "ACTIVE" { + fmt.Fprintln(os.Stderr) + fmt.Println("Successfully waited for cluster to reach ACTIVE") + return nil + } + + if status == "ERROR" || status == "FAILED" { + fmt.Fprintln(os.Stderr) + fmt.Fprintf(os.Stderr, "Waiter failed: cluster reached %s\n", status) + os.Exit(255) + } + } + + if attempt < maxAttempts { + time.Sleep(time.Duration(delay) * time.Second) + } + } + + fmt.Fprintln(os.Stderr) + fmt.Fprintf(os.Stderr, "Waiter timed out after %d attempts\n", maxAttempts) + os.Exit(255) + return nil +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..b91346b --- /dev/null +++ b/go/go.mod @@ -0,0 +1,14 @@ +module github.com/vngcloud/greennode-cli + +go 1.22.2 + +require ( + github.com/jmespath/go-jmespath v0.4.0 + github.com/spf13/cobra v1.10.2 + gopkg.in/ini.v1 v1.67.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..5469892 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,35 @@ +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +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/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= +gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/internal/auth/token.go b/go/internal/auth/token.go new file mode 100644 index 0000000..4f768d5 --- /dev/null +++ b/go/internal/auth/token.go @@ -0,0 +1,108 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const IAMTokenURL = "https://iamapis.vngcloud.vn/accounts-api/v1/auth/token" + +// tokenResponse matches the IAM API camelCase JSON response. +type tokenResponse struct { + AccessToken string `json:"accessToken"` + ExpiresIn int `json:"expiresIn"` +} + +// TokenManager handles OAuth2 Client Credentials flow with auto-refresh. +type TokenManager struct { + clientID string + clientSecret string + accessToken string + expiresAt time.Time + mu sync.Mutex + httpClient *http.Client +} + +// NewTokenManager creates a new token manager. +func NewTokenManager(clientID, clientSecret string) *TokenManager { + return &TokenManager{ + clientID: clientID, + clientSecret: clientSecret, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// GetToken returns a valid access token, fetching a new one if needed. +func (tm *TokenManager) GetToken() (string, error) { + tm.mu.Lock() + defer tm.mu.Unlock() + + if tm.accessToken != "" && time.Now().Before(tm.expiresAt) { + return tm.accessToken, nil + } + return tm.fetchToken() +} + +// RefreshToken forces a token refresh. +func (tm *TokenManager) RefreshToken() (string, error) { + tm.mu.Lock() + defer tm.mu.Unlock() + + tm.accessToken = "" + tm.expiresAt = time.Time{} + return tm.fetchToken() +} + +func (tm *TokenManager) fetchToken() (string, error) { + credentials := base64.StdEncoding.EncodeToString( + []byte(tm.clientID + ":" + tm.clientSecret), + ) + + data := url.Values{} + data.Set("grantType", "client_credentials") + + req, err := http.NewRequest("POST", IAMTokenURL, strings.NewReader(data.Encode())) + if err != nil { + return "", fmt.Errorf("failed to create token request: %w", err) + } + + req.Header.Set("Authorization", "Basic "+credentials) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := tm.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("IAM authentication request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("IAM authentication error (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var tokenResp tokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", fmt.Errorf("failed to parse token response: %w", err) + } + + tm.accessToken = tokenResp.AccessToken + expiresIn := tokenResp.ExpiresIn + if expiresIn == 0 { + expiresIn = 1800 + } + // Refresh 60 seconds before expiry + tm.expiresAt = time.Now().Add(time.Duration(expiresIn-60) * time.Second) + + return tm.accessToken, nil +} diff --git a/go/internal/client/client.go b/go/internal/client/client.go new file mode 100644 index 0000000..c0578cb --- /dev/null +++ b/go/internal/client/client.go @@ -0,0 +1,272 @@ +package client + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/vngcloud/greennode-cli/internal/auth" +) + +const ( + maxRetries = 3 + retryBaseDelay = 1 * time.Second + defaultTimeout = 30 * time.Second +) + +var statusMessages = map[int]string{ + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 409: "Conflict", + 429: "Too Many Requests", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +} + +var retryableStatusCodes = map[int]bool{ + 500: true, 502: true, 503: true, 504: true, +} + +// GreenodeClient is an HTTP client for Greenode APIs with retry and auto token refresh. +type GreenodeClient struct { + baseURL string + tokenManager *auth.TokenManager + httpClient *http.Client + debug bool +} + +// NewGreenodeClient creates a new API client. +func NewGreenodeClient(baseURL string, tokenManager *auth.TokenManager, timeout time.Duration, verifySSL bool, debug bool) *GreenodeClient { + if timeout == 0 { + timeout = defaultTimeout + } + + transport := &http.Transport{} + if !verifySSL { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec + } + + return &GreenodeClient{ + baseURL: baseURL, + tokenManager: tokenManager, + httpClient: &http.Client{ + Timeout: timeout, + Transport: transport, + }, + debug: debug, + } +} + +// Get performs a GET request. +func (c *GreenodeClient) Get(path string, params map[string]string) (interface{}, error) { + return c.request("GET", path, params, nil) +} + +// Post performs a POST request with a JSON body. +func (c *GreenodeClient) Post(path string, body interface{}) (interface{}, error) { + return c.request("POST", path, nil, body) +} + +// Put performs a PUT request with a JSON body. +func (c *GreenodeClient) Put(path string, body interface{}) (interface{}, error) { + return c.request("PUT", path, nil, body) +} + +// Delete performs a DELETE request. +func (c *GreenodeClient) Delete(path string, params map[string]string) (interface{}, error) { + return c.request("DELETE", path, params, nil) +} + +// GetRaw performs a GET request and returns the raw response body. +func (c *GreenodeClient) GetRaw(path string, params map[string]string) (string, error) { + return c.requestRaw("GET", path, params, nil) +} + +// GetAllPages fetches all pages and merges items into a single result. +func (c *GreenodeClient) GetAllPages(path string, pageSize int) (map[string]interface{}, error) { + if pageSize == 0 { + pageSize = 50 + } + + var allItems []interface{} + page := 0 + + for { + params := map[string]string{ + "page": fmt.Sprintf("%d", page), + "pageSize": fmt.Sprintf("%d", pageSize), + } + result, err := c.Get(path, params) + if err != nil { + return nil, err + } + + resultMap, ok := result.(map[string]interface{}) + if !ok { + break + } + + items, _ := resultMap["items"].([]interface{}) + allItems = append(allItems, items...) + + total, _ := resultMap["total"].(float64) + if len(allItems) >= int(total) || len(items) == 0 { + break + } + page++ + } + + return map[string]interface{}{ + "items": allItems, + "total": float64(len(allItems)), + }, nil +} + +func (c *GreenodeClient) request(method, path string, params map[string]string, body interface{}) (interface{}, error) { + rawBody, err := c.requestRaw(method, path, params, body) + if err != nil { + return nil, err + } + + if rawBody == "" { + return map[string]interface{}{}, nil + } + + var result interface{} + if err := json.Unmarshal([]byte(rawBody), &result); err != nil { + return nil, fmt.Errorf("failed to parse response JSON: %w", err) + } + return result, nil +} + +func (c *GreenodeClient) requestRaw(method, path string, params map[string]string, body interface{}) (string, error) { + fullURL := c.baseURL + path + + if len(params) > 0 { + u, _ := url.Parse(fullURL) + q := u.Query() + for k, v := range params { + q.Set(k, v) + } + u.RawQuery = q.Encode() + fullURL = u.String() + } + + token, err := c.tokenManager.GetToken() + if err != nil { + return "", err + } + + var lastErr error + + for attempt := 0; attempt <= maxRetries; attempt++ { + var reqBody io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return "", fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, fullURL, reqBody) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = err + if attempt < maxRetries { + delay := retryBaseDelay * time.Duration(1<= 400 { + return "", fmt.Errorf("%s", formatError(resp.StatusCode, respBody)) + } + + return string(respBody), nil + } + + return "", fmt.Errorf("request failed after %d attempts", maxRetries+1) +} + +func formatError(statusCode int, body []byte) string { + statusText := statusMessages[statusCode] + if statusText == "" { + statusText = "Error" + } + + detail := "" + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err == nil { + if msg, ok := data["message"].(string); ok && msg != "" { + detail = msg + } else if errMsg, ok := data["error"].(string); ok && errMsg != "" { + detail = errMsg + } else if detailMsg, ok := data["detail"].(string); ok && detailMsg != "" { + detail = detailMsg + } else if errors, ok := data["errors"].([]interface{}); ok && len(errors) > 0 { + if errObj, ok := errors[0].(map[string]interface{}); ok { + if msg, ok := errObj["message"].(string); ok { + detail = msg + } + } + } + } else { + detail = string(body) + } + + if detail != "" { + return fmt.Sprintf("API error (HTTP %d %s): %s", statusCode, statusText, detail) + } + return fmt.Sprintf("API error (HTTP %d %s)", statusCode, statusText) +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go new file mode 100644 index 0000000..e97b4b4 --- /dev/null +++ b/go/internal/config/config.go @@ -0,0 +1,134 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/ini.v1" +) + +// REGIONS maps region names to service endpoints. +var REGIONS = map[string]map[string]string{ + "HCM-3": { + "vks_endpoint": "https://vks.api.vngcloud.vn", + "vserver_endpoint": "https://hcm-3.api.vngcloud.vn/vserver/vserver-gateway", + }, + "HAN": { + "vks_endpoint": "https://vks-han-1.api.vngcloud.vn", + "vserver_endpoint": "https://han-1.api.vngcloud.vn/vserver/vserver-gateway", + }, +} + +// Config holds the resolved CLI configuration. +type Config struct { + ClientID string + ClientSecret string + Region string + Output string + Profile string + Regions map[string]map[string]string +} + +// DefaultConfigDir returns ~/.greenode +func DefaultConfigDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".greenode") +} + +// LoadConfig loads configuration for the given profile. +// Resolution order: env vars -> config files. +func LoadConfig(profile string) (*Config, error) { + if profile == "" { + profile = os.Getenv("GRN_PROFILE") + } + if profile == "" { + profile = "default" + } + + configDir := DefaultConfigDir() + cfg := &Config{ + Profile: profile, + Regions: REGIONS, + } + + // Load credentials + credsFile := filepath.Join(configDir, "credentials") + if _, err := os.Stat(credsFile); err == nil { + iniCreds, err := ini.Load(credsFile) + if err != nil { + return nil, fmt.Errorf("failed to parse credentials file: %w", err) + } + section, err := iniCreds.GetSection(profile) + if err != nil { + return nil, fmt.Errorf("profile '%s' does not exist in %s", profile, credsFile) + } + cfg.ClientID = section.Key("client_id").String() + cfg.ClientSecret = section.Key("client_secret").String() + } + + // Load config file + configFile := filepath.Join(configDir, "config") + if _, err := os.Stat(configFile); err == nil { + iniCfg, err := ini.Load(configFile) + if err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + sectionName := profile + if profile != "default" { + sectionName = "profile " + profile + } + + section, err := iniCfg.GetSection(sectionName) + if err != nil && profile == "default" { + // Try DEFAULT section for default profile + section = iniCfg.Section("") + } + if section != nil { + if v := section.Key("region").String(); v != "" { + cfg.Region = v + } + if v := section.Key("output").String(); v != "" { + cfg.Output = v + } + } + } + + // Env var overrides for region + if v := os.Getenv("GRN_DEFAULT_REGION"); v != "" { + cfg.Region = v + } + + // Default output + if cfg.Output == "" { + cfg.Output = "json" + } + + return cfg, nil +} + +// GetEndpoint returns the service endpoint for the configured region. +func (c *Config) GetEndpoint(serviceName string) (string, error) { + if c.Region == "" { + return "", fmt.Errorf("region is not configured. Use 'grn configure' or the --region flag") + } + regionConfig, ok := c.Regions[c.Region] + if !ok { + return "", fmt.Errorf("invalid region: %s", c.Region) + } + endpointKey := serviceName + "_endpoint" + endpoint, ok := regionConfig[endpointKey] + if !ok { + return "", fmt.Errorf("endpoint not found for service '%s' in region '%s'", serviceName, c.Region) + } + return endpoint, nil +} + +// MaskCredential masks a credential string showing only last 4 chars. +func MaskCredential(value string) string { + if len(value) <= 4 { + return value + } + return "****************" + value[len(value)-4:] +} diff --git a/go/internal/config/writer.go b/go/internal/config/writer.go new file mode 100644 index 0000000..4e54835 --- /dev/null +++ b/go/internal/config/writer.go @@ -0,0 +1,91 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/ini.v1" +) + +// ConfigFileWriter creates/updates INI config files. +type ConfigFileWriter struct { + configDir string +} + +// NewConfigFileWriter creates a new writer targeting the default config directory. +func NewConfigFileWriter() *ConfigFileWriter { + return &ConfigFileWriter{configDir: DefaultConfigDir()} +} + +// ensureDir creates the config directory with proper permissions. +func (w *ConfigFileWriter) ensureDir() error { + return os.MkdirAll(w.configDir, 0700) +} + +// WriteCredentials writes client_id and client_secret for the given profile. +func (w *ConfigFileWriter) WriteCredentials(profile, clientID, clientSecret string) error { + if err := w.ensureDir(); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + filePath := filepath.Join(w.configDir, "credentials") + cfg, err := w.loadOrCreate(filePath) + if err != nil { + return err + } + + section, err := cfg.NewSection(profile) + if err != nil { + return fmt.Errorf("failed to create section '%s': %w", profile, err) + } + section.Key("client_id").SetValue(clientID) + section.Key("client_secret").SetValue(clientSecret) + + return w.save(cfg, filePath) +} + +// WriteConfig writes region and output for the given profile. +func (w *ConfigFileWriter) WriteConfig(profile, region, output string) error { + if err := w.ensureDir(); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + filePath := filepath.Join(w.configDir, "config") + cfg, err := w.loadOrCreate(filePath) + if err != nil { + return err + } + + sectionName := profile + if profile != "default" { + sectionName = "profile " + profile + } + + section, err := cfg.NewSection(sectionName) + if err != nil { + return fmt.Errorf("failed to create section '%s': %w", sectionName, err) + } + section.Key("region").SetValue(region) + section.Key("output").SetValue(output) + + return w.save(cfg, filePath) +} + +func (w *ConfigFileWriter) loadOrCreate(filePath string) (*ini.File, error) { + if _, err := os.Stat(filePath); err == nil { + return ini.Load(filePath) + } + return ini.Empty(), nil +} + +func (w *ConfigFileWriter) save(cfg *ini.File, filePath string) error { + f, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to write %s: %w", filePath, err) + } + defer f.Close() + + _, err = cfg.WriteTo(f) + return err +} diff --git a/go/internal/formatter/formatter.go b/go/internal/formatter/formatter.go new file mode 100644 index 0000000..f1d4c34 --- /dev/null +++ b/go/internal/formatter/formatter.go @@ -0,0 +1,187 @@ +package formatter + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/jmespath/go-jmespath" +) + +// Format formats and outputs the response data. +func Format(data interface{}, outputFormat, query string, w io.Writer) error { + if w == nil { + w = os.Stdout + } + + // Apply JMESPath query if specified + if query != "" { + result, err := jmespath.Search(query, data) + if err != nil { + return fmt.Errorf("JMESPath query error: %w", err) + } + data = result + } + + if data == nil { + return nil + } + + switch outputFormat { + case "json": + formatJSON(data, w) + case "text": + formatText(data, w) + case "table": + formatTable(data, w) + default: + formatJSON(data, w) + } + return nil +} + +func formatJSON(data interface{}, w io.Writer) { + if isEmptyMap(data) { + return + } + out, err := json.MarshalIndent(data, "", " ") + if err != nil { + fmt.Fprintf(w, "%v\n", data) + return + } + fmt.Fprintf(w, "%s\n", string(out)) +} + +func formatText(data interface{}, w io.Writer) { + if data == nil || isEmptyMap(data) { + return + } + + switch v := data.(type) { + case map[string]interface{}: + for _, value := range v { + if items, ok := value.([]interface{}); ok { + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + vals := mapValues(m) + fmt.Fprintln(w, strings.Join(vals, "\t")) + } else { + fmt.Fprintln(w, fmt.Sprint(item)) + } + } + return + } + } + vals := mapValues(v) + fmt.Fprintln(w, strings.Join(vals, "\t")) + case []interface{}: + for _, item := range v { + if m, ok := item.(map[string]interface{}); ok { + vals := mapValues(m) + fmt.Fprintln(w, strings.Join(vals, "\t")) + } else { + fmt.Fprintln(w, fmt.Sprint(item)) + } + } + default: + fmt.Fprintln(w, fmt.Sprint(data)) + } +} + +func formatTable(data interface{}, w io.Writer) { + if data == nil || isEmptyMap(data) { + return + } + + rows := extractRows(data) + if len(rows) == 0 { + return + } + + // Check if rows are maps + if firstMap, ok := rows[0].(map[string]interface{}); ok { + headers := mapKeys(firstMap) + colWidths := make([]int, len(headers)) + for i, h := range headers { + colWidths[i] = len(h) + } + + strRows := make([][]string, len(rows)) + for i, row := range rows { + m, _ := row.(map[string]interface{}) + strRows[i] = make([]string, len(headers)) + for j, h := range headers { + val := fmt.Sprint(m[h]) + strRows[i][j] = val + if len(val) > colWidths[j] { + colWidths[j] = len(val) + } + } + } + + // Print header + headerParts := make([]string, len(headers)) + sepParts := make([]string, len(headers)) + for i, h := range headers { + headerParts[i] = padRight(h, colWidths[i]) + sepParts[i] = strings.Repeat("-", colWidths[i]) + } + fmt.Fprintln(w, strings.Join(headerParts, " | ")) + fmt.Fprintln(w, strings.Join(sepParts, "-+-")) + + // Print rows + for _, row := range strRows { + parts := make([]string, len(row)) + for i, val := range row { + parts[i] = padRight(val, colWidths[i]) + } + fmt.Fprintln(w, strings.Join(parts, " | ")) + } + } +} + +func extractRows(data interface{}) []interface{} { + switch v := data.(type) { + case []interface{}: + return v + case map[string]interface{}: + for _, value := range v { + if items, ok := value.([]interface{}); ok { + return items + } + } + return []interface{}{v} + default: + return []interface{}{v} + } +} + +func mapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +func mapValues(m map[string]interface{}) []string { + vals := make([]string, 0, len(m)) + for _, v := range m { + vals = append(vals, fmt.Sprint(v)) + } + return vals +} + +func isEmptyMap(data interface{}) bool { + m, ok := data.(map[string]interface{}) + return ok && len(m) == 0 +} + +func padRight(s string, n int) string { + if len(s) >= n { + return s + } + return s + strings.Repeat(" ", n-len(s)) +} diff --git a/go/internal/validator/validator.go b/go/internal/validator/validator.go new file mode 100644 index 0000000..a29ca97 --- /dev/null +++ b/go/internal/validator/validator.go @@ -0,0 +1,16 @@ +package validator + +import ( + "fmt" + "regexp" +) + +var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9]$`) + +// ValidateID validates that an ID contains only safe characters. +func ValidateID(value, name string) error { + if value == "" || !idPattern.MatchString(value) { + return fmt.Errorf("invalid %s: '%s'. Must contain only alphanumeric characters and hyphens", name, value) + } + return nil +} diff --git a/go/main.go b/go/main.go new file mode 100644 index 0000000..27ce73a --- /dev/null +++ b/go/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/vngcloud/greennode-cli/cmd" + +func main() { + cmd.Execute() +} diff --git a/grncli/__init__.py b/grncli/__init__.py deleted file mode 100644 index 838dd70..0000000 --- a/grncli/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# greenode-cli/grncli/__init__.py -from __future__ import annotations - -import os - -__version__ = '0.2.1' - -# Data path for cli.json and other data files -_grncli_data_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'data' -) diff --git a/grncli/argparser.py b/grncli/argparser.py deleted file mode 100644 index f4b84af..0000000 --- a/grncli/argparser.py +++ /dev/null @@ -1,101 +0,0 @@ -# greenode-cli/grncli/argparser.py -from __future__ import annotations - -import argparse -from collections import OrderedDict -from difflib import get_close_matches -from typing import Any - -USAGE = "grn [options] [parameters]" - - -class CommandAction(argparse.Action): - """Custom argparse action for dynamic command choices.""" - - def __init__(self, option_strings, dest, command_table, **kwargs): - self.command_table = command_table - super().__init__(option_strings, dest, choices=self.choices, **kwargs) - - def __call__(self, parser, namespace, values, option_string=None): - setattr(namespace, self.dest, values) - - @property - def choices(self): - return list(self.command_table.keys()) - - @choices.setter - def choices(self, val): - pass - - -class CLIArgParser(argparse.ArgumentParser): - """Base parser with custom error handling.""" - - def __init__(self, *args: Any, **kwargs: Any): - kwargs.setdefault('formatter_class', argparse.RawTextHelpFormatter) - kwargs.setdefault('add_help', False) - kwargs.setdefault('conflict_handler', 'resolve') - super().__init__(*args, **kwargs) - - def _check_value(self, action, value): - if action.choices is not None and value not in action.choices: - msg = [f'Invalid command: {value}\n\nAvailable commands:\n'] - for choice in action.choices: - msg.append(f' {choice}') - possible = get_close_matches(value, action.choices, cutoff=0.8) - if possible: - msg.append(f'\n\nDid you mean: {", ".join(possible)}?') - raise argparse.ArgumentError(action, '\n'.join(msg)) - - -class MainArgParser(CLIArgParser): - """Top-level parser: global args + command selection.""" - - def __init__( - self, - command_table: OrderedDict, - version_string: str, - description: str, - argument_table: OrderedDict, - prog: str = 'grn', - ): - super().__init__(description=description, usage=USAGE, prog=prog) - self._build(command_table, version_string, argument_table) - - def _build(self, command_table, version_string, argument_table): - for argument in argument_table.values(): - argument.add_to_parser(self) - self.add_argument( - '--version', action='version', version=version_string - ) - self.add_argument('command', action=CommandAction, - command_table=command_table) - - -class ServiceArgParser(CLIArgParser): - """Service-level parser: operation selection.""" - - def __init__(self, operations_table: OrderedDict, service_name: str): - super().__init__(usage=USAGE) - self._service_name = service_name - self.add_argument('operation', action=CommandAction, - command_table=operations_table) - - -class ArgTableArgParser(CLIArgParser): - """Operation-level parser: parse specific arguments.""" - - def __init__( - self, - argument_table: OrderedDict, - command_table: OrderedDict | None = None, - description: str = '', - ): - super().__init__(usage=USAGE, add_help=True, description=description) - for argument in argument_table.values(): - argument.add_to_parser(self) - if command_table: - self.add_argument( - 'subcommand', action=CommandAction, - command_table=command_table, nargs='?', - ) diff --git a/grncli/arguments.py b/grncli/arguments.py deleted file mode 100644 index b1e7f4e..0000000 --- a/grncli/arguments.py +++ /dev/null @@ -1,92 +0,0 @@ -# greenode-cli/grncli/arguments.py -from __future__ import annotations - -import argparse -from collections import OrderedDict -from typing import Any - - -class BaseCLIArgument: - """Base class for all CLI arguments.""" - - def __init__(self, name: str): - self._name = name - - @property - def name(self) -> str: - return self._name - - @property - def cli_name(self) -> str: - return '--' + self._name - - @property - def py_name(self) -> str: - return self._name.replace('-', '_') - - def add_to_arg_table(self, argument_table: OrderedDict) -> None: - argument_table[self.name] = self - - def add_to_parser(self, parser: argparse.ArgumentParser) -> None: - raise NotImplementedError - - -class CustomArgument(BaseCLIArgument): - """Configurable argument for top-level and custom command args.""" - - TYPE_MAP = { - 'int': int, - 'float': float, - 'str': str, - } - - def __init__( - self, - name: str, - help_text: str = '', - dest: str | None = None, - default: Any = None, - action: str | None = None, - required: bool | None = None, - choices: list[str] | None = None, - nargs: str | int | None = None, - positional_arg: bool = False, - type: str | None = None, - ): - super().__init__(name) - self._help = help_text - self._dest = dest - self._default = default - self._action = action - self._required = required - self._choices = choices - self._nargs = nargs - self._positional_arg = positional_arg - self._type = self.TYPE_MAP.get(type) if type else None - - @property - def cli_name(self) -> str: - if self._positional_arg: - return self._name - return '--' + self._name - - def add_to_parser(self, parser: argparse.ArgumentParser) -> None: - kwargs: dict[str, Any] = {} - if self._help: - kwargs['help'] = self._help - if self._dest is not None: - kwargs['dest'] = self._dest - if self._default is not None: - kwargs['default'] = self._default - if self._action is not None: - kwargs['action'] = self._action - if self._choices is not None: - kwargs['choices'] = self._choices - if self._required is not None and not self._positional_arg: - kwargs['required'] = self._required - if self._nargs is not None: - kwargs['nargs'] = self._nargs - if self._type is not None and self._action is None: - kwargs['type'] = self._type - - parser.add_argument(self.cli_name, **kwargs) diff --git a/grncli/auth.py b/grncli/auth.py deleted file mode 100644 index 0d87fb2..0000000 --- a/grncli/auth.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -import base64 -import time - -import httpx - -IAM_TOKEN_URL = "https://iamapis.vngcloud.vn/accounts-api/v1/auth/token" - - -class TokenManager: - """OAuth2 Client Credentials token manager with auto-refresh.""" - - def __init__(self, client_id: str, client_secret: str): - self._client_id = client_id - self._client_secret = client_secret - self._access_token: str | None = None - self._expires_at: float = 0 - - def get_token(self) -> str: - if self._access_token and time.time() < self._expires_at: - return self._access_token - return self._fetch_token() - - def refresh_token(self) -> str: - self._access_token = None - self._expires_at = 0 - return self._fetch_token() - - def _fetch_token(self) -> str: - credentials = base64.b64encode( - f"{self._client_id}:{self._client_secret}".encode() - ).decode() - - response = httpx.post( - IAM_TOKEN_URL, - headers={ - "Authorization": f"Basic {credentials}", - "Content-Type": "application/x-www-form-urlencoded", - }, - data={"grantType": "client_credentials"}, - timeout=30, - ) - - if response.status_code != 200: - raise RuntimeError( - f"IAM authentication error (HTTP {response.status_code}): " - f"{response.text}" - ) - - data = response.json() - self._access_token = data["accessToken"] - expires_in = data.get("expiresIn", 1800) - self._expires_at = time.time() + expires_in - 60 - return self._access_token diff --git a/grncli/clidriver.py b/grncli/clidriver.py deleted file mode 100644 index bee44fb..0000000 --- a/grncli/clidriver.py +++ /dev/null @@ -1,241 +0,0 @@ -# greenode-cli/grncli/clidriver.py -from __future__ import annotations - -import json -import logging -import os -import platform -import sys -from collections import OrderedDict -from typing import Any - -import jmespath - -import grncli -from grncli.arguments import CustomArgument -from grncli.argparser import MainArgParser, ServiceArgParser -from grncli.commands import CLICommand -from grncli.formatter import get_formatter -from grncli.plugin import load_plugins -from grncli.session import Session - -LOG = logging.getLogger(__name__) - - -def main(args: list[str] | None = None) -> int: - driver = create_clidriver() - return driver.main(args) - - -def create_clidriver() -> CLIDriver: - session = Session() - load_plugins({}, event_hooks=session.emitter) - return CLIDriver(session=session) - - -class CLIDriver: - """Main CLI orchestrator (similar to AWS CLI CLIDriver).""" - - def __init__(self, session: Session): - self.session = session - self._cli_data: dict | None = None - self._command_table: OrderedDict | None = None - self._argument_table: OrderedDict | None = None - - def main(self, args: list[str] | None = None) -> int: - if args is None: - args = sys.argv[1:] - - try: - return self._do_main(args) - except KeyboardInterrupt: - sys.stdout.write('\n') - return 130 - except Exception as e: - sys.stderr.write(f"Error: {e}\n") - LOG.debug("Exception", exc_info=True) - return 255 - - def _do_main(self, args: list[str]) -> int: - import argparse - - command_table = self._get_command_table() - argument_table = self._get_argument_table() - - # Handle no args or 'help' command - if not args or args == ['help']: - self._print_usage(command_table) - return 0 - - version_string = ( - f"grn-cli/{grncli.__version__} " - f"Python/{platform.python_version()} " - f"{platform.system()}/{platform.release()}" - ) - - parser = MainArgParser( - command_table, - version_string, - self._get_cli_data().get('description', ''), - argument_table, - prog='grn', - ) - - try: - parsed_args, remaining = parser.parse_known_args(args) - except SystemExit as e: - if e.code == 0: - raise - return 255 - except argparse.ArgumentError: - return 255 - - self._handle_top_level_args(parsed_args) - - command_name = getattr(parsed_args, 'command', None) - if not command_name or command_name not in command_table: - return 255 - - return command_table[command_name](remaining, parsed_args) - - def _print_usage(self, command_table: OrderedDict) -> None: - cli_data = self._get_cli_data() - sys.stdout.write( - f"\nusage: {cli_data.get('synopsis', 'grn [options] [parameters]')}\n\n" - ) - sys.stdout.write(f"{cli_data.get('description', '')}\n\n") - if cli_data.get('help_usage'): - sys.stdout.write(f"{cli_data['help_usage']}\n\n") - - # Command descriptions - command_help = { - 'configure': 'Configure credentials and settings', - 'vks': 'VKS (VNG Kubernetes Service) commands', - } - - sys.stdout.write("Available commands:\n\n") - for cmd_name in command_table: - desc = command_help.get(cmd_name, '') - sys.stdout.write(f" {cmd_name:<16s}{desc}\n") - sys.stdout.write('\n') - - def _get_cli_data(self) -> dict: - if self._cli_data is None: - cli_json_path = os.path.join(grncli._grncli_data_path, 'cli.json') - with open(cli_json_path) as f: - self._cli_data = json.load(f) - return self._cli_data - - def _get_command_table(self) -> OrderedDict: - if self._command_table is None: - self._command_table = self._build_command_table() - return self._command_table - - def _build_command_table(self) -> OrderedDict: - command_table = OrderedDict() - self.session.emit( - 'building-command-table.main', - command_table=command_table, - session=self.session, - ) - return command_table - - def _get_argument_table(self) -> OrderedDict: - if self._argument_table is None: - self._argument_table = self._build_argument_table() - return self._argument_table - - def _build_argument_table(self) -> OrderedDict: - argument_table = OrderedDict() - cli_data = self._get_cli_data() - for option_name, option_params in cli_data.get('options', {}).items(): - params = dict(option_params) - params['name'] = option_name - if 'help' in params: - params['help_text'] = params.pop('help') - arg = CustomArgument(**params) - arg.add_to_arg_table(argument_table) - return argument_table - - def _handle_top_level_args(self, parsed_args) -> None: - if getattr(parsed_args, 'debug', False): - logging.basicConfig(level=logging.DEBUG) - - if getattr(parsed_args, 'profile', None): - self.session.profile = parsed_args.profile - - if getattr(parsed_args, 'region', None): - self.session.set_region_override(parsed_args.region) - - if getattr(parsed_args, 'endpoint_url', None): - self.session.set_endpoint_override(parsed_args.endpoint_url) - - # SSL verification - verify_ssl = getattr(parsed_args, 'verify_ssl', None) - if verify_ssl is not None: - self.session.verify_ssl = verify_ssl - if not verify_ssl: - sys.stderr.write( - "Warning: SSL certificate verification is disabled. " - "This is insecure and should only be used for testing.\n" - ) - - # Timeouts - read_timeout = getattr(parsed_args, 'read_timeout', None) - connect_timeout = getattr(parsed_args, 'connect_timeout', None) - if read_timeout is not None or connect_timeout is not None: - self.session.set_timeouts( - read=read_timeout, - connect=connect_timeout, - ) - - query_str = getattr(parsed_args, 'query', None) - if query_str: - parsed_args.query = jmespath.compile(query_str) - else: - parsed_args.query = None - - if not getattr(parsed_args, 'output', None): - parsed_args.output = self.session.get_config_variable('output') or 'json' - - -class ServiceCommand(CLICommand): - """Represents a service (e.g., 'grn vks ...').""" - - def __init__(self, name: str, session: Session): - self._name = name - self._session = session - self._command_table: OrderedDict | None = None - - @property - def name(self) -> str: - return self._name - - def __call__(self, args: list[str], parsed_globals) -> int: - command_table = self._get_command_table() - - if not args or args == ['help']: - self._print_usage(command_table) - return 0 - - parser = ServiceArgParser(command_table, self._name) - parsed_args, remaining = parser.parse_known_args(args) - return command_table[parsed_args.operation](remaining, parsed_globals) - - def _print_usage(self, command_table: OrderedDict) -> None: - sys.stdout.write(f"\nusage: grn {self._name} [parameters]\n\n") - sys.stdout.write("Available commands:\n\n") - for cmd_name in command_table: - desc = command_table[cmd_name].DESCRIPTION if hasattr(command_table[cmd_name], 'DESCRIPTION') else '' - sys.stdout.write(f" {cmd_name:<24s}{desc}\n") - sys.stdout.write('\n') - - def _get_command_table(self) -> OrderedDict: - if self._command_table is None: - self._command_table = OrderedDict() - self._session.emit( - f'building-command-table.{self._name}', - command_table=self._command_table, - session=self._session, - ) - return self._command_table diff --git a/grncli/client.py b/grncli/client.py deleted file mode 100644 index a7a6fcb..0000000 --- a/grncli/client.py +++ /dev/null @@ -1,177 +0,0 @@ -from __future__ import annotations - -import logging -import time -from typing import TYPE_CHECKING, Any - -import httpx - -if TYPE_CHECKING: - from grncli.session import Session - -LOG = logging.getLogger(__name__) - -_STATUS_MESSAGES = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 409: 'Conflict', - 429: 'Too Many Requests', - 500: 'Internal Server Error', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - 504: 'Gateway Timeout', -} - - -def _format_error(response: httpx.Response) -> str: - status = response.status_code - status_text = _STATUS_MESSAGES.get(status, 'Error') - - # Try to extract message from JSON response - detail = '' - try: - data = response.json() - if isinstance(data, dict): - detail = ( - data.get('message') - or data.get('error') - or data.get('detail') - or '' - ) - # VKS API sometimes returns errors in 'errors' array - if not detail and 'errors' in data: - errors = data['errors'] - if isinstance(errors, list) and errors: - detail = errors[0].get('message', '') - except Exception: - detail = response.text.strip() if response.text else '' - - if detail: - return f"API error (HTTP {status} {status_text}): {detail}" - return f"API error (HTTP {status} {status_text})" - - -# Retry config -MAX_RETRIES = 3 -RETRY_BASE_DELAY = 1 # seconds -RETRYABLE_STATUS_CODES = {500, 502, 503, 504} - - -class GreenodeClient: - """HTTP client for Greenode APIs with auto token refresh and retry.""" - - def __init__(self, session: Session, service_name: str): - self._session = session - self._base_url = session.get_endpoint(service_name) - self._token_manager = session.get_token_manager() - self._timeout = session.get_timeout() - self._verify = session.verify_ssl - - def _headers(self, token: str) -> dict[str, str]: - return { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - - def _request( - self, - method: str, - path: str, - raw: bool = False, - **kwargs: Any, - ) -> Any: - url = f"{self._base_url}{path}" - token = self._token_manager.get_token() - last_error = None - - for attempt in range(MAX_RETRIES + 1): - try: - response = httpx.request( - method, url, headers=self._headers(token), - timeout=self._timeout, verify=self._verify, **kwargs - ) - except (httpx.ConnectTimeout, httpx.ReadTimeout, - httpx.ConnectError) as e: - last_error = e - if attempt < MAX_RETRIES: - delay = RETRY_BASE_DELAY * (2 ** attempt) - LOG.debug( - "Request timeout/error (attempt %d/%d), " - "retrying in %ds: %s", - attempt + 1, MAX_RETRIES + 1, delay, e, - ) - time.sleep(delay) - continue - raise RuntimeError( - f"Request failed after {MAX_RETRIES + 1} attempts: {e}" - ) from e - - # 401 — refresh token and retry once - if response.status_code == 401: - token = self._token_manager.refresh_token() - response = httpx.request( - method, url, headers=self._headers(token), - timeout=self._timeout, verify=self._verify, **kwargs - ) - - # Retryable server errors (5xx) - if response.status_code in RETRYABLE_STATUS_CODES: - if attempt < MAX_RETRIES: - delay = RETRY_BASE_DELAY * (2 ** attempt) - LOG.debug( - "Server error %d (attempt %d/%d), " - "retrying in %ds", - response.status_code, - attempt + 1, MAX_RETRIES + 1, delay, - ) - time.sleep(delay) - continue - - # Non-retryable errors - if response.status_code >= 400: - raise RuntimeError(_format_error(response)) - - if raw: - return response.text - return response.json() - - # Should not reach here, but just in case - raise RuntimeError( - f"Request failed after {MAX_RETRIES + 1} attempts" - ) - - def get(self, path: str, **kwargs: Any) -> Any: - return self._request("GET", path, **kwargs) - - def post(self, path: str, **kwargs: Any) -> Any: - return self._request("POST", path, **kwargs) - - def put(self, path: str, **kwargs: Any) -> Any: - return self._request("PUT", path, **kwargs) - - def delete(self, path: str, **kwargs: Any) -> Any: - return self._request("DELETE", path, **kwargs) - - def get_raw(self, path: str, **kwargs: Any) -> str: - return self._request("GET", path, raw=True, **kwargs) - - def get_all_pages( - self, path: str, page_size: int = 50, **kwargs: Any, - ) -> dict[str, Any]: - """Fetch all pages and merge items into a single result.""" - all_items = [] - page = 0 - while True: - params = kwargs.pop('params', {}) if 'params' in kwargs else {} - params['page'] = page - params['pageSize'] = page_size - result = self.get(path, params=params, **kwargs) - items = result.get('items', []) - all_items.extend(items) - total = result.get('total', 0) - if len(all_items) >= total or not items: - break - page += 1 - return {'items': all_items, 'total': len(all_items)} diff --git a/grncli/commands.py b/grncli/commands.py deleted file mode 100644 index a38afba..0000000 --- a/grncli/commands.py +++ /dev/null @@ -1,25 +0,0 @@ -# greenode-cli/grncli/commands.py -from __future__ import annotations - - -class CLICommand: - """Interface all commands must implement.""" - - @property - def name(self) -> str: - raise NotImplementedError - - @property - def lineage(self) -> list[CLICommand]: - return [self] - - @property - def lineage_names(self) -> list[str]: - return [cmd.name for cmd in self.lineage] - - def __call__(self, args: list[str], parsed_globals) -> int: - raise NotImplementedError - - @property - def arg_table(self) -> dict: - return {} diff --git a/grncli/customizations/__init__.py b/grncli/customizations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/grncli/customizations/commands.py b/grncli/customizations/commands.py deleted file mode 100644 index ca55f6f..0000000 --- a/grncli/customizations/commands.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -import sys -from collections import OrderedDict -from typing import Any - -from grncli.arguments import CustomArgument -from grncli.argparser import ArgTableArgParser -from grncli.commands import CLICommand - - -class BasicCommand(CLICommand): - """Base class for hand-written commands (similar to AWS CLI BasicCommand).""" - - NAME = '' - DESCRIPTION = '' - ARG_TABLE: list[dict[str, Any]] = [] - SUBCOMMANDS: list[dict[str, Any]] = [] - - def __init__(self, session): - self._session = session - self._arg_table: OrderedDict | None = None - self._subcommand_table: OrderedDict | None = None - - @property - def name(self) -> str: - return self.NAME - - @property - def arg_table(self) -> OrderedDict: - if self._arg_table is None: - self._arg_table = self._build_arg_table() - return self._arg_table - - @property - def subcommand_table(self) -> OrderedDict: - if self._subcommand_table is None: - self._subcommand_table = self._build_subcommand_table() - return self._subcommand_table - - def __call__(self, args: list[str], parsed_globals) -> int: - self._arg_table = self._build_arg_table() - self._subcommand_table = self._build_subcommand_table() - - # Handle help before parsing - if args == ['help'] or args == ['--help'] or args == ['-h']: - self._print_help() - return 0 - - parser = ArgTableArgParser( - self._arg_table, self._subcommand_table, - description=self.DESCRIPTION, - ) - parsed_args, remaining = parser.parse_known_args(args) - - if getattr(parsed_args, 'subcommand', None) is not None: - return self._subcommand_table[parsed_args.subcommand]( - remaining, parsed_globals - ) - - if remaining: - raise ValueError(f"Unknown options: {', '.join(remaining)}") - - return self._run_main(parsed_args, parsed_globals) - - def _print_help(self) -> None: - sys.stdout.write(f"\nDESCRIPTION\n {self.DESCRIPTION}\n\nSYNOPSIS\n {self.NAME}\n") - - required = [] - optional = [] - for arg_data in self.ARG_TABLE: - name = arg_data['name'] - is_flag = arg_data.get('action') in ('store_true', 'store_false') - is_required = arg_data.get('required', False) - - if is_flag: - display = f"--{name}" - else: - display = f"--{name} " - - if is_required: - required.append((display, arg_data.get('help_text', ''))) - else: - optional.append((display, arg_data.get('help_text', ''))) - - if required: - for display, _ in required: - sys.stdout.write(f" {display}\n") - if optional: - for display, _ in optional: - sys.stdout.write(f" [{display}]\n") - - sys.stdout.write("\nREQUIRED OPTIONS\n") - if required: - for display, help_text in required: - sys.stdout.write(f" {display}\n") - if help_text: - sys.stdout.write(f" {help_text}\n\n") - else: - sys.stdout.write(" None\n\n") - - sys.stdout.write("OPTIONAL OPTIONS\n") - if optional: - for display, help_text in optional: - sys.stdout.write(f" {display}\n") - if help_text: - sys.stdout.write(f" {help_text}\n\n") - else: - sys.stdout.write(" None\n\n") - - def _run_main(self, parsed_args, parsed_globals) -> int: - raise NotImplementedError - - def _build_arg_table(self) -> OrderedDict: - arg_table = OrderedDict() - for arg_data in self.ARG_TABLE: - data = dict(arg_data) - custom_arg = CustomArgument(**data) - arg_table[data['name']] = custom_arg - return arg_table - - def _build_subcommand_table(self) -> OrderedDict: - table = OrderedDict() - for sub in self.SUBCOMMANDS: - table[sub['name']] = sub['command_class'](self._session) - return table - - @classmethod - def add_command(cls, command_table, session, **kwargs): - command_table[cls.NAME] = cls(session) - - -def display_output(response, parsed_globals): - """Format and display API response based on --output flag.""" - from grncli.formatter import get_formatter - output_format = getattr(parsed_globals, 'output', 'json') or 'json' - formatter = get_formatter(output_format, parsed_globals) - formatter('', response) diff --git a/grncli/customizations/configure/__init__.py b/grncli/customizations/configure/__init__.py deleted file mode 100644 index 8f75f2d..0000000 --- a/grncli/customizations/configure/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -# greenode-cli/grncli/customizations/configure/__init__.py -from __future__ import annotations - - -def register_configure_cmd(event_hooks): - event_hooks.register( - 'building-command-table.main', - _inject_configure, - ) - - -def _inject_configure(command_table, session, **kwargs): - from grncli.customizations.configure.configure import ConfigureCommand - command_table['configure'] = ConfigureCommand(session) diff --git a/grncli/customizations/configure/configure.py b/grncli/customizations/configure/configure.py deleted file mode 100644 index ed782ad..0000000 --- a/grncli/customizations/configure/configure.py +++ /dev/null @@ -1,121 +0,0 @@ -# greenode-cli/grncli/customizations/configure/configure.py -from __future__ import annotations - -from grncli.customizations.commands import BasicCommand -from grncli.customizations.configure.writer import ConfigFileWriter - - -class InteractivePrompter: - VALID_REGIONS = {'HCM-3', 'HAN'} - VALID_OUTPUTS = {'json', 'text', 'table'} - - VALIDATORS = { - 'region': lambda v: v in InteractivePrompter.VALID_REGIONS, - 'output': lambda v: v in InteractivePrompter.VALID_OUTPUTS, - } - - def get_value(self, current_value, config_name, prompt_text, - default=None): - # Use current value for display, but fall back to default - # if current value is invalid - validator = self.VALIDATORS.get(config_name) - current_is_valid = ( - current_value and (not validator or validator(current_value)) - ) - - if current_is_valid: - if config_name in ('client_id', 'client_secret'): - display = '*' * 16 + current_value[-4:] - else: - display = current_value - elif default: - display = default - else: - display = 'None' - - response = input(f'{prompt_text} [{display}]: ') - if not response: - if current_is_valid: - return None # Keep current value - if default: - return default # Reset to default - return None - return response - - -class ConfigureCommand(BasicCommand): - NAME = 'configure' - DESCRIPTION = 'Configure credentials and settings for Greenode CLI' - - SUBCOMMANDS = [] # Set in __init__ - - VALUES_TO_PROMPT = [ - ('client_id', 'GRN Client ID'), - ('client_secret', 'GRN Client Secret'), - ('region', 'Default region name'), - ('output', 'Default output format'), - ] - - # Default values used when no current value exists and user presses Enter - DEFAULTS = { - 'region': 'HCM-3', - 'output': 'json', - } - - CREDS_KEYS = {'client_id', 'client_secret'} - - def __init__(self, session, prompter=None, config_writer=None): - super().__init__(session) - self._prompter = prompter or InteractivePrompter() - self._config_writer = config_writer or ConfigFileWriter() - from grncli.customizations.configure.list import ConfigureListCommand - from grncli.customizations.configure.get import ConfigureGetCommand - from grncli.customizations.configure.set import ConfigureSetCommand - self.SUBCOMMANDS = [ - {'name': 'list', 'command_class': ConfigureListCommand}, - {'name': 'get', 'command_class': ConfigureGetCommand}, - {'name': 'set', 'command_class': ConfigureSetCommand}, - ] - - def _run_main(self, parsed_args, parsed_globals): - try: - creds = self._session.get_credentials() - except Exception: - creds = {} - try: - config = self._session.get_scoped_config() - except Exception: - config = {} - - current = {**creds, **config} - new_creds = {} - new_config = {} - - for config_name, prompt_text in self.VALUES_TO_PROMPT: - current_value = current.get(config_name) - default = self.DEFAULTS.get(config_name) - new_value = self._prompter.get_value( - current_value, config_name, prompt_text, default=default, - ) - if new_value is not None: - if config_name in self.CREDS_KEYS: - new_creds[config_name] = new_value - else: - new_config[config_name] = new_value - - profile = getattr(parsed_globals, 'profile', None) or self._session.profile - - if new_creds: - section = profile if profile != 'default' else 'default' - new_creds['__section__'] = section - self._config_writer.update_config(new_creds, self._session.credentials_file) - - if new_config: - if profile and profile != 'default': - section = f'profile {profile}' - else: - section = 'default' - new_config['__section__'] = section - self._config_writer.update_config(new_config, self._session.config_file) - - return 0 diff --git a/grncli/customizations/configure/get.py b/grncli/customizations/configure/get.py deleted file mode 100644 index 3dc78f3..0000000 --- a/grncli/customizations/configure/get.py +++ /dev/null @@ -1,62 +0,0 @@ -# greenode-cli/grncli/customizations/configure/get.py -from __future__ import annotations - -import sys - -from grncli.customizations.commands import BasicCommand - - -class ConfigureGetCommand(BasicCommand): - NAME = 'get' - DESCRIPTION = 'Get a config value' - ARG_TABLE = [ - {'name': 'varname', 'positional_arg': True, - 'help_text': 'Config variable name (e.g. region, profile.staging.region)'}, - ] - - def _run_main(self, parsed_args, parsed_globals): - varname = parsed_args.varname - if '.' not in varname: - value = self._get_simple(varname) - else: - value = self._get_dotted(varname) - if value is None: - return 1 - - # Mask sensitive values - if varname.endswith(('client_id', 'client_secret')): - value = '*' * 16 + str(value)[-4:] - - sys.stdout.write(str(value) + '\n') - return 0 - - def _get_simple(self, varname): - try: - config = self._session.get_scoped_config() - if varname in config: - return config[varname] - except Exception: - pass - try: - creds = self._session.get_credentials() - if varname in creds: - return creds[varname] - except Exception: - pass - return None - - def _get_dotted(self, varname): - parts = varname.split('.') - if len(parts) == 2 and parts[0] == 'default': - return self._get_from_profile('default', parts[1]) - if len(parts) == 3 and parts[0] == 'profile': - return self._get_from_profile(parts[1], parts[2]) - return None - - def _get_from_profile(self, profile, key): - original_profile = self._session.profile - try: - self._session.profile = profile - return self._get_simple(key) - finally: - self._session.profile = original_profile diff --git a/grncli/customizations/configure/list.py b/grncli/customizations/configure/list.py deleted file mode 100644 index 4c903e7..0000000 --- a/grncli/customizations/configure/list.py +++ /dev/null @@ -1,68 +0,0 @@ -# greenode-cli/grncli/customizations/configure/list.py -from __future__ import annotations - -import os -import sys - -from grncli.customizations.commands import BasicCommand - - -class ConfigureListCommand(BasicCommand): - NAME = 'list' - DESCRIPTION = 'Show config values and their sources' - - def _run_main(self, parsed_args, parsed_globals): - profile = self._session.profile - entries = [] - entries.append(self._resolve_profile(profile)) - entries.append(self._resolve_credential('client_id')) - entries.append(self._resolve_credential('client_secret')) - entries.append(self._resolve_config('region', 'GRN_DEFAULT_REGION')) - entries.append(self._resolve_config('output', 'GRN_DEFAULT_OUTPUT')) - - header = f"{'Name':>14s}{'Value':>24s}{'Type':>16s} {'Location'}" - sep = f"{'----':>14s}{'-----':>24s}{'----':>16s} {'--------'}" - sys.stdout.write(header + '\n') - sys.stdout.write(sep + '\n') - for name, value, source_type, location in entries: - sys.stdout.write( - f'{name:>14s}{value:>24s}{str(source_type):>16s}' - f' {str(location)}\n' - ) - return 0 - - def _mask_value(self, value): - if len(value) <= 4: - return value - return '*' * 16 + value[-4:] - - def _resolve_profile(self, profile): - env_profile = os.environ.get('GRN_PROFILE') - if env_profile: - return ('profile', env_profile, 'env', 'GRN_PROFILE') - if profile and profile != 'default': - return ('profile', profile, 'manual', '--profile') - return ('profile', '', 'None', 'None') - - def _resolve_credential(self, key): - try: - creds = self._session.get_credentials() - value = creds.get(key) - if value: - return (key, self._mask_value(value), 'config-file', self._session.credentials_file) - except Exception: - pass - return (key, '', 'None', 'None') - - def _resolve_config(self, key, env_var): - env_val = os.environ.get(env_var) - if env_val: - return (key, env_val, 'env', env_var) - try: - config = self._session.get_scoped_config() - value = config.get(key) - if value: - return (key, value, 'config-file', self._session.config_file) - except Exception: - pass - return (key, '', 'None', 'None') diff --git a/grncli/customizations/configure/set.py b/grncli/customizations/configure/set.py deleted file mode 100644 index a628324..0000000 --- a/grncli/customizations/configure/set.py +++ /dev/null @@ -1,48 +0,0 @@ -# greenode-cli/grncli/customizations/configure/set.py -from __future__ import annotations - -from grncli.customizations.commands import BasicCommand -from grncli.customizations.configure.writer import ConfigFileWriter - -CREDS_KEYS = {'client_id', 'client_secret'} - - -class ConfigureSetCommand(BasicCommand): - NAME = 'set' - DESCRIPTION = 'Set a config value' - ARG_TABLE = [ - {'name': 'varname', 'positional_arg': True, 'help_text': 'Config variable name'}, - {'name': 'value', 'positional_arg': True, 'help_text': 'New value'}, - ] - - def __init__(self, session, config_writer=None): - super().__init__(session) - self._config_writer = config_writer or ConfigFileWriter() - - def _run_main(self, parsed_args, parsed_globals): - varname = parsed_args.varname - value = parsed_args.value - profile, key = self._parse_varname(varname) - - if key in CREDS_KEYS: - filename = self._session.credentials_file - section = profile - else: - filename = self._session.config_file - if profile == 'default': - section = 'default' - else: - section = f'profile {profile}' - - self._config_writer.update_config({'__section__': section, key: value}, filename) - return 0 - - def _parse_varname(self, varname): - if '.' not in varname: - return (self._session.profile, varname) - parts = varname.split('.') - if parts[0] == 'default': - return ('default', parts[1]) - if parts[0] == 'profile' and len(parts) >= 3: - return (parts[1], parts[2]) - return (self._session.profile, varname) diff --git a/grncli/customizations/configure/writer.py b/grncli/customizations/configure/writer.py deleted file mode 100644 index 6b60279..0000000 --- a/grncli/customizations/configure/writer.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -import os -import re - - -class ConfigFileWriter: - """Read/write INI config files (similar to AWS CLI ConfigFileWriter).""" - - SECTION_REGEX = re.compile(r'^\s*\[(?P
[^]]+)\]') - OPTION_REGEX = re.compile( - r'(?P