diff --git a/.changes/next-release/enhancement-configure-uooh8xep.json b/.changes/next-release/enhancement-configure-uooh8xep.json new file mode 100644 index 0000000..05b6dbd --- /dev/null +++ b/.changes/next-release/enhancement-configure-uooh8xep.json @@ -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" +} diff --git a/README.md b/README.md index 1907aa3..ec39787 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ GRN Client ID [None]: GRN Client Secret [None]: 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)** diff --git a/docs/configuration.md b/docs/configuration.md index 9822b31..60eea29 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,12 +13,19 @@ GRN Client ID [None]: GRN Client Secret [None]: 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. diff --git a/go/cmd/configure/configure.go b/go/cmd/configure/configure.go index 5d7e69e..a8afb74 100644 --- a/go/cmd/configure/configure.go +++ b/go/cmd/configure/configure.go @@ -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 == "" { @@ -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 { diff --git a/go/cmd/configure/detect_project.go b/go/cmd/configure/detect_project.go new file mode 100644 index 0000000..f806c59 --- /dev/null +++ b/go/cmd/configure/detect_project.go @@ -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 +}