Architecture & Adding a Service
GreenNode CLI (grn) is a single Go binary built with cobra.
It is designed so multiple product teams can add their own service CLI in the
same repo without conflicting with each other.
Layout
go/
├── cmd/
│ ├── root.go # Root command, global flags, mounts services from the registry
│ ├── register.go # Blank-imports each product package (the one file to touch per service)
│ ├── configure/ # `grn configure` (built-in, credentials)
│ └── vks/ # VKS product CLI (one file per command + vks.go parent)
└── internal/
├── cli/ # SHARED infrastructure used by every product
│ ├── client.go # NewClient(cmd, serviceName) — per-service HTTP client
│ ├── output.go # Output(cmd, data) — JSON/table/text rendering
│ ├── parse.go # ParseCommaSeparated, BuildEventsQuery, ...
│ ├── registry.go # RegisterService / Services — product self-registration
│ └── completion.go # Value-completion framework + resource registry
├── resources/vserver/ # Cross-service completion providers (platform-owned)
├── config/ # Config + credentials (INI), region/endpoint resolution
├── auth/ # OAuth2 client-credentials token manager
├── client/ # Low-level HTTP client (retry, 401 refresh, typed APIError)
├── formatter/ # JSON/table/text + JMESPath
└── validator/ # ID validation
Rule of thumb: product-specific code lives in cmd/<service>/; anything shared
goes in internal/cli (or another internal/* package). A product package must
not import another product package.
Adding a new service
A new product (e.g. vserver) is mounted without touching root.go:
- Create
cmd/<service>/with a parentcobra.Command:
package vserver
import (
"github.com/spf13/cobra"
"github.com/vngcloud/greennode-cli/internal/cli"
)
var VserverCmd = &cobra.Command{
Use: "vserver",
Short: "VNG Cloud vServer commands",
Run: func(cmd *cobra.Command, args []string) { cmd.Help() },
}
func init() {
// ... VserverCmd.AddCommand(...) for each subcommand
cli.RegisterService(VserverCmd) // self-register; root mounts it
}
- Blank-import the package in
cmd/register.go— the only shared file you edit:
import (
_ "github.com/vngcloud/greennode-cli/cmd/vks"
_ "github.com/vngcloud/greennode-cli/internal/resources/vserver"
_ "github.com/vngcloud/greennode-cli/cmd/vserver" // add this line
)
- Declare the service endpoint per region in
internal/config/config.goREGIONS:
"HCM-3": {
"vks_endpoint": "https://vks.api.vngcloud.vn",
"vserver_endpoint": "https://hcm-3.api.vngcloud.vn/vserver/vserver-gateway",
},
root.go iterates cli.Services() and never needs editing per service.
Writing a command
Follow the existing cmd/vks/*.go files. Each command:
- builds its client with
cli.NewClient(cmd, "<service>")(resolves the<service>_endpointfor the active region), - prints results with
cli.Output(cmd, data)(honours--output/--query), - validates any ID used in a URL with
validator.ValidateID(...), - adds
--dry-runfor create/update/delete and--force+ confirmation for delete.
func runGetThing(cmd *cobra.Command, args []string) error {
id, _ := cmd.Flags().GetString("id")
if err := validator.ValidateID(id, "id"); err != nil {
return err
}
c, err := cli.NewClient(cmd, "vserver")
if err != nil {
return err
}
res, err := c.Get(fmt.Sprintf("/v2/%s/things/%s", projectID, id), nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return cli.Output(cmd, res)
}
Shell completion
Static command/flag completion is automatic (grn completion <shell>). For flag
value completion:
- Enum:
cmd.RegisterFlagCompletionFunc(name, cli.FlagValues("a", "b")) - Config-derived:
cli.FlagValuesFrom(fn func() []string) - API-backed:
cli.FlagFromAPI(func(ctx, cmd) ([]string, error))— bounded timeout, fails silently, prefix-filtered. Usecli.ExtractIDs(resp, "id", "uuid")to pull IDs out of a list response. - Cross-service resource: a consumer uses
cli.ResourceCompletion("<svc>:<resource>"); the owning service registers the provider withcli.RegisterResourceCompleter("<svc>:<resource>", ...). Seeinternal/resources/vserver/for the pattern.
Ownership
.github/CODEOWNERS routes review by path: internal/, cmd/root.go,
cmd/register.go and cmd/configure/ are platform-owned; cmd/<service>/ is owned
by its product team. Add a CODEOWNERS line for a new service directory.
Running tests
On macOS 26 (Darwin 25) with Go 1.22,
go testmay abort withdyld: missing LC_UUID. Use the external linker:CGO_ENABLED=1 go test -ldflags='-linkmode=external' ./...