Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 37 additions & 88 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-*
24 changes: 14 additions & 10 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Go
go/grn
go/grn-*
*.exe

# Python (legacy)
__pycache__/
*.pyc
*.pyo
Expand Down
148 changes: 92 additions & 56 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,100 +2,136 @@

## 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/<command_name>.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/<command_name>.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/<service>/`
2. Write commands extending `BasicCommand`
3. Register in `grncli/handlers.py`
4. See `grncli/customizations/vks/` for reference
1. Create `cmd/<service>/` 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/<command>.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.

## Key files

| 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 |
Loading
Loading