Skip to content
Draft
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
1 change: 1 addition & 0 deletions rest-api/cli/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func AllCommands() []Command {
{Name: "vpc update", Description: "Update a VPC", Run: cmdVPCUpdate},
{Name: "vpc virtualization update", Description: "Update VPC virtualization", Run: cmdVPCVirtualizationUpdate},
{Name: "vpc delete", Description: "Delete a VPC", Run: cmdVPCDelete},
{Name: "vpc-peering create", Description: "Create VPC peerings", Run: cmdVPCPeeringCreate},

{Name: "subnet list", Description: "List all subnets", Run: cmdSubnetList},
{Name: "subnet get", Description: "Get subnet details", Run: cmdSubnetGet},
Expand Down
6 changes: 5 additions & 1 deletion rest-api/cli/tui/generated_body_form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,11 @@ func TestGeneratedTUICommandGuidedBodyResolvesNamesToIDs(t *testing.T) {
"",
)
_, err := withStdin(t, "\ny\n", func() (string, error) {
return "", requireTUICommand(t, "vpc-peering create").Run(session, nil)
return "", runGeneratedTUICommand(
session,
generatedCommandInfoByName(t, "vpc-peering create"),
nil,
)
})
require.NoError(t, err)

Expand Down
70 changes: 56 additions & 14 deletions rest-api/cli/tui/repl_pty_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,20 +324,36 @@ func TestCLIRegression_RealTerminalAndNonInteractive(t *testing.T) {
terminal.send(t, "n\r")
terminal.waitFor(t, "nico:acme")

// Guided request bodies preload site/VPC names, resolve two body IDs in
// order, and execute only after confirmation.
// Choosing exactly two VPCs preserves the original guided workflow.
terminal.send(t, "vpc-peering create\r")
terminal.waitFor(t, "Request body input")
terminal.send(t, "\r")
terminal.waitFor(t, "Site id:")
terminal.waitFor(t, "VPC peering creation requires a site")
terminal.waitFor(t, "Site:")
terminal.send(t, "site-one\r")
terminal.waitFor(t, "Vpc1id:")
terminal.waitFor(t, "VPC selection")
terminal.send(t, "Choose VPCs\r")
terminal.waitFor(t, "VPC:")
terminal.send(t, "vpc-one\r")
terminal.waitFor(t, "Vpc2id:")
terminal.waitFor(t, "VPC:")
terminal.send(t, "vpc-two\r")
terminal.waitFor(t, "Run vpc-peering create (POST)?")
terminal.waitFor(t, "Add another VPC (selected 2)?")
terminal.send(t, "n\r")
terminal.waitFor(t, "Selected VPCs (2)")
terminal.waitFor(t, "Peerings to create (1)")
terminal.waitFor(t, "Create 1 VPC peering(s)?")
terminal.send(t, "y\r")
terminal.waitFor(t, `"id": "peering-1"`)
terminal.waitFor(t, "Summary: created 1, skipped 0, failed 0")

// Selecting all same-site VPCs previews every unique pair and skips the
// peering created by the preceding two-VPC workflow.
terminal.send(t, "vpc-peering create\r")
terminal.waitFor(t, "VPC selection")
terminal.send(t, "Select all\r")
terminal.waitFor(t, "Selected VPCs (3)")
terminal.waitFor(t, "Peerings to create (2)")
terminal.waitFor(t, "Existing peerings to skip (1)")
terminal.waitFor(t, "Create 2 VPC peering(s)?")
terminal.send(t, "y\r")
terminal.waitFor(t, "Summary: created 2, skipped 1, failed 0")

// Generated enum and secret fields use the guided form. Optional
// free-form fields can be skipped, and terminal password input is not
Expand Down Expand Up @@ -422,12 +438,16 @@ func TestCLIRegression_RealTerminalAndNonInteractive(t *testing.T) {
http.MethodPost,
"/v2/org/acme/nico/vpc-peering",
)
require.Len(t, peeringRequests, 1, "cancelled mutation must not reach the API")
assert.JSONEq(
t,
require.Len(t, peeringRequests, 3, "cancelled and existing peerings must not reach the API")
peeringBodies := make([]string, len(peeringRequests))
for i, request := range peeringRequests {
peeringBodies[i] = request.Body
}
assert.ElementsMatch(t, []string{
`{"siteId":"site-1","vpc1Id":"vpc-1","vpc2Id":"vpc-2"}`,
peeringRequests[0].Body,
)
`{"siteId":"site-1","vpc1Id":"vpc-1","vpc2Id":"vpc-flat"}`,
`{"siteId":"site-1","vpc1Id":"vpc-2","vpc2Id":"vpc-flat"}`,
}, peeringBodies)

prefixRequests := recorder.matching(
http.MethodPost,
Expand Down Expand Up @@ -829,6 +849,28 @@ func newInteractiveRegressionHandler(recorder *cliRegressionRecorder) http.Handl
request.URL.Path == "/v2/org/acme/nico/vpc-peering":
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"id":"peering-1","status":"Ready"}`)
case request.Method == http.MethodGet &&
request.URL.Path == "/v2/org/acme/nico/vpc-peering":
peerings := make([]map[string]string, 0)
for i, peeringRequest := range recorder.matching(
http.MethodPost,
"/v2/org/acme/nico/vpc-peering",
) {
var peering map[string]string
if err := json.Unmarshal([]byte(peeringRequest.Body), &peering); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
peerings = append(peerings, map[string]string{
"id": fmt.Sprintf("peering-%d", i+1),
"siteId": peering["siteId"],
"vpc1Id": peering["vpc1Id"],
"vpc2Id": peering["vpc2Id"],
})
}
if err := json.NewEncoder(w).Encode(peerings); err != nil {
return
}
case request.Method == http.MethodPut &&
request.URL.Path == "/v2/org/acme/nico/credential/bmc":
w.WriteHeader(http.StatusAccepted)
Expand Down
255 changes: 255 additions & 0 deletions rest-api/cli/tui/vpc_peering_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package tui

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
)

const vpcPeeringCreateCommand = "vpc-peering create"

type vpcPeeringPair struct {
first NamedItem
second NamedItem
}

func (p vpcPeeringPair) label() string {
return fmt.Sprintf("%s (%s) <-> %s (%s)", p.first.Name, p.first.ID, p.second.Name, p.second.ID)
}

type vpcPeeringPairKey struct {
first string
second string
}

func newVPCPeeringPairKey(first, second string) vpcPeeringPairKey {
first = strings.TrimSpace(first)
second = strings.TrimSpace(second)
if first > second {
first, second = second, first
}
return vpcPeeringPairKey{first: first, second: second}
}

type vpcPeeringPlan struct {
create []vpcPeeringPair
skip []vpcPeeringPair
}

func buildVPCPeeringPlan(selected, existing []NamedItem) vpcPeeringPlan {
unique := make([]NamedItem, 0, len(selected))
seen := make(map[string]struct{}, len(selected))
for _, vpc := range selected {
vpc.ID = strings.TrimSpace(vpc.ID)
if vpc.ID == "" {
continue
}
if _, duplicate := seen[vpc.ID]; duplicate {
continue
}
seen[vpc.ID] = struct{}{}
unique = append(unique, vpc)
}

existingPairs := make(map[vpcPeeringPairKey]struct{}, len(existing))
for _, peering := range existing {
first := strings.TrimSpace(peering.Extra["vpc1Id"])
second := strings.TrimSpace(peering.Extra["vpc2Id"])
if first == "" || second == "" || first == second {
continue
}
existingPairs[newVPCPeeringPairKey(first, second)] = struct{}{}
}

plan := vpcPeeringPlan{}
for i, first := range unique {
for _, second := range unique[i+1:] {
pair := vpcPeeringPair{first: first, second: second}
if _, exists := existingPairs[newVPCPeeringPairKey(first.ID, second.ID)]; exists {
plan.skip = append(plan.skip, pair)
continue
}
plan.create = append(plan.create, pair)
}
}
return plan
}

func cmdVPCPeeringCreate(s *Session, args []string) error {
if len(args) > 0 {
info, ok := generatedAutocompleteInfo(vpcPeeringCreateCommand)
if !ok {
return fmt.Errorf("generated command %q is unavailable", vpcPeeringCreateCommand)
}
return runGeneratedTUICommand(s, info, args)
}

siteID, err := requireSiteScope(s, "VPC peering creation requires a site. Select a site.")
if err != nil {
return err
}
selected, err := promptVPCPeeringVPCs(s, siteID)
if err != nil {
return err
}
existing, err := fetchVPCPeeringsForSite(s, siteID)
if err != nil {
return fmt.Errorf("listing existing VPC peerings: %w", err)
}

plan := buildVPCPeeringPlan(selected, existing)
printVPCPeeringPlan(os.Stdout, selected, plan)
if len(plan.create) > 0 {
confirmed, confirmErr := PromptConfirm(fmt.Sprintf("Create %d VPC peering(s)?", len(plan.create)))
if confirmErr != nil {
return confirmErr
}
if !confirmed {
return nil
}
}
return executeVPCPeeringPlan(s, siteID, plan, os.Stdout)
}

func promptVPCPeeringVPCs(s *Session, siteID string) ([]NamedItem, error) {
vpcs, err := s.Resolver.Fetch(context.Background(), "vpc")
if err != nil {
return nil, fmt.Errorf("listing VPCs: %w", err)
}
siteVPCs := make([]NamedItem, 0, len(vpcs))
for _, vpc := range vpcs {
if strings.TrimSpace(vpc.Extra["siteId"]) == siteID {
siteVPCs = append(siteVPCs, vpc)
}
}
if len(siteVPCs) < 2 {
return nil, fmt.Errorf("site requires at least two VPCs to create peerings")
}

mode, err := PromptChoice(
"VPC selection",
[]string{"Choose VPCs", "Select all"},
"Choose VPCs",
)
if err != nil {
return nil, err
}
if mode == "Select all" {
return siteVPCs, nil
}

selected := make([]NamedItem, 0, len(siteVPCs))
selectedIDs := make(map[string]struct{}, len(siteVPCs))
for len(selected) < len(siteVPCs) {
available := make([]NamedItem, 0, len(siteVPCs)-len(selected))
for _, vpc := range siteVPCs {
if _, picked := selectedIDs[vpc.ID]; !picked {
available = append(available, vpc)
}
}
vpc, selectErr := s.Resolver.SelectFromItems("VPC", available)
if selectErr != nil {
return nil, selectErr
}
selected = append(selected, *vpc)
selectedIDs[vpc.ID] = struct{}{}
if len(selected) < 2 {
continue
}
if len(selected) == len(siteVPCs) {
break
}
more, promptErr := PromptConfirm(fmt.Sprintf("Add another VPC (selected %d)?", len(selected)))
if promptErr != nil {
return nil, promptErr
}
if !more {
break
}
}
return selected, nil
}

func fetchVPCPeeringsForSite(s *Session, siteID string) ([]NamedItem, error) {
items, err := s.fetchAll(apiPath(s, "vpc-peering"), map[string]string{"siteId": siteID})
if err != nil {
return nil, err
}
peerings := make([]NamedItem, 0, len(items))
for _, item := range items {
peerings = append(peerings, NamedItem{Extra: map[string]string{
"vpc1Id": str(item, "vpc1Id"),
"vpc2Id": str(item, "vpc2Id"),
}})
}
return peerings, nil
}

func printVPCPeeringPlan(w io.Writer, selected []NamedItem, plan vpcPeeringPlan) {
fmt.Fprintf(w, "Selected VPCs (%d):\n", len(selected))
for _, vpc := range selected {
fmt.Fprintf(w, " - %s (%s)\n", vpc.Name, vpc.ID)
}
fmt.Fprintf(w, "Peerings to create (%d):\n", len(plan.create))
for _, pair := range plan.create {
fmt.Fprintf(w, " - %s\n", pair.label())
}
fmt.Fprintf(w, "Existing peerings to skip (%d):\n", len(plan.skip))
for _, pair := range plan.skip {
fmt.Fprintf(w, " - %s\n", pair.label())
}
}

type vpcPeeringFailure struct {
pair vpcPeeringPair
err error
}

func executeVPCPeeringPlan(s *Session, siteID string, plan vpcPeeringPlan, w io.Writer) error {
created := make([]vpcPeeringPair, 0, len(plan.create))
failed := make([]vpcPeeringFailure, 0)
for _, pair := range plan.create {
body, _ := json.Marshal(map[string]string{
"siteId": siteID,
"vpc1Id": pair.first.ID,
"vpc2Id": pair.second.ID,
})
_, _, err := s.Client.Do("POST", apiPath(s, "vpc-peering"), nil, nil, body)
if err != nil {
failed = append(failed, vpcPeeringFailure{pair: pair, err: err})
continue
}
created = append(created, pair)
}
if len(plan.create) > 0 && s.Cache != nil {
s.Cache.Invalidate("vpc-peering")
}

fmt.Fprintln(w, "VPC peering results:")
for _, pair := range created {
fmt.Fprintf(w, " CREATED %s\n", pair.label())
}
for _, pair := range plan.skip {
fmt.Fprintf(w, " SKIPPED %s (already exists)\n", pair.label())
}
for _, failure := range failed {
fmt.Fprintf(w, " FAILED %s: %v\n", failure.pair.label(), failure.err)
}
fmt.Fprintf(
w,
"Summary: created %d, skipped %d, failed %d\n",
len(created),
len(plan.skip),
len(failed),
)
if len(failed) > 0 {
return fmt.Errorf("%d VPC peering(s) failed", len(failed))
}
return nil
}
Loading