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
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-configure-uooh8xep.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "configure",
"description": "Auto-detect project_id during grn configure by calling vServer /v1/projects with the given credentials and region"
}
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ GRN Client ID [None]: <your-client-id>
GRN Client Secret [None]: <your-client-secret>
Default region name [HCM-3]:
Default output format [json]:
Project ID (leave blank to auto-detect at runtime) [None]: pro-xxxxxxxx
Project ID (leave blank to auto-detect) [None]:
Fetching project_id from HCM-3...
Auto-detected project_id: pro-xxxxxxxx
```

**Method 3: Credentials file (manual)**
Expand Down
15 changes: 11 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ GRN Client ID [None]: <your-client-id>
GRN Client Secret [None]: <your-client-secret>
Default region name [HCM-3]:
Default output format [json]:
Project ID (leave blank to auto-detect at runtime) [None]: pro-xxxxxxxx
Project ID (leave blank to auto-detect) [None]:
Fetching project_id from HCM-3...
Auto-detected project_id: pro-xxxxxxxx
```

`Project ID` is the VNG Cloud project UUID (e.g. `pro-e28d4501-...`). Each user may
have multiple projects; pick the one you work with. Leave blank to let downstream
tools (such as the GreenNode MCP Server) auto-detect at first call.
`Project ID` is the VNG Cloud project UUID for the selected region (e.g.
`pro-e28d4501-...`). Leave blank and the wizard calls the vServer API with
your credentials to detect and save it. Each user has one project per region,
so the detection is unambiguous.

If auto-detect fails (network or auth error), the wizard prints a warning and
leaves the field blank — downstream tools (such as the GreenNode MCP Server)
can still auto-detect at first call.

Credentials are obtained from the [VNG Cloud IAM Portal](https://hcm-3.console.vngcloud.vn/iam/) under Service Accounts.

Expand Down
18 changes: 17 additions & 1 deletion go/cmd/configure/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func runConfigure(cmd *cobra.Command, args []string) {
clientSecret := promptWithDefault(reader, "Client Secret", maskCred(cfg.ClientSecret))
region := promptWithDefault(reader, "Default region name", cfg.Region)
output := promptWithDefault(reader, "Default output format", cfg.Output)
projectID := promptWithDefault(reader, "Project ID (leave blank to auto-detect at runtime)", cfg.ProjectID)
projectID := promptWithDefault(reader, "Project ID (leave blank to auto-detect)", cfg.ProjectID)

// If user entered masked value or empty, keep original
if clientID == maskCred(cfg.ClientID) || clientID == "" {
Expand All @@ -70,6 +70,22 @@ func runConfigure(cmd *cobra.Command, args []string) {
output = "json"
}

// Auto-detect project_id when left blank
if projectID == "" && clientID != "" && clientSecret != "" {
if endpoint, err := vserverEndpointForRegion(region); err != nil {
fmt.Fprintf(os.Stderr, "Warning: cannot determine vServer endpoint for region %q; leaving project_id blank.\n", region)
} else {
fmt.Printf("Fetching project_id from %s...\n", region)
detected, derr := detectProjectID(clientID, clientSecret, endpoint)
if derr != nil {
fmt.Fprintf(os.Stderr, "Warning: auto-detect failed: %v\nLeaving project_id blank.\n", derr)
} else {
fmt.Printf("Auto-detected project_id: %s\n", detected)
projectID = detected
}
}
}

writer := config.NewConfigFileWriter()

if err := writer.WriteCredentials(profile, clientID, clientSecret); err != nil {
Expand Down
77 changes: 77 additions & 0 deletions go/cmd/configure/detect_project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package configure

import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"github.com/vngcloud/greennode-cli/internal/auth"
"github.com/vngcloud/greennode-cli/internal/config"
)

// vserverEndpointForRegion returns the vServer base URL for a region,
// looking it up in the REGIONS map.
func vserverEndpointForRegion(region string) (string, error) {
r, ok := config.REGIONS[region]
if !ok {
return "", fmt.Errorf("unknown region: %s", region)
}
ep, ok := r["vserver_endpoint"]
if !ok {
return "", fmt.Errorf("no vserver_endpoint configured for region %s", region)
}
return ep, nil
}

// detectProjectTimeout is short — configure should fail fast, not hang the wizard.
const detectProjectTimeout = 10 * time.Second

type projectsResponse struct {
Projects []struct {
ProjectID string `json:"projectId"`
} `json:"projects"`
}

// detectProjectID fetches the caller's project from vServer /v1/projects
// using the given credentials and region's vServer endpoint.
//
// Returns the first projectId. Each user is expected to have exactly one
// project per region; returning the first is safe by that contract.
func detectProjectID(clientID, clientSecret, vserverEndpoint string) (string, error) {
tm := auth.NewTokenManager(clientID, clientSecret)
token, err := tm.GetToken()
if err != nil {
return "", fmt.Errorf("authentication failed: %w", err)
}

req, err := http.NewRequest("GET", vserverEndpoint+"/v1/projects", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+token)

httpClient := &http.Client{Timeout: detectProjectTimeout}
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch projects: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}

var parsed projectsResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}

if len(parsed.Projects) == 0 {
return "", fmt.Errorf("account has no project in this region")
}

return parsed.Projects[0].ProjectID, nil
}
Loading