From 678404d32e9926ac1f7b564198aa7ef95a076352 Mon Sep 17 00:00:00 2001 From: Dan Manor Date: Sun, 5 Jul 2026 13:03:34 +0300 Subject: [PATCH 1/8] OSAC-1484: add ExternalIP table rendering definitions Assisted-by: Claude Code Signed-off-by: Dan Manor --- .../tables/osac.private.v1.ExternalIP.yaml | 41 +++++++++++++++++++ .../tables/osac.public.v1.ExternalIP.yaml | 36 ++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 internal/rendering/tables/osac.private.v1.ExternalIP.yaml create mode 100644 internal/rendering/tables/osac.public.v1.ExternalIP.yaml diff --git a/internal/rendering/tables/osac.private.v1.ExternalIP.yaml b/internal/rendering/tables/osac.private.v1.ExternalIP.yaml new file mode 100644 index 000000000..8215a7282 --- /dev/null +++ b/internal/rendering/tables/osac.private.v1.ExternalIP.yaml @@ -0,0 +1,41 @@ +# +# Copyright (c) 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +columns: + +- header: PROJECT + value: "has(this.metadata.project)? this.metadata.project: '-'" + +- header: ID + value: this.id + +- header: NAME + value: "has(this.metadata.name)? this.metadata.name: '-'" + +- header: POOL + value: this.spec.pool + +- header: STATE + value: this.status.state + type: osac.private.v1.ExternalIPState + +- header: ADDRESS + value: "this.status.address != ''? this.status.address: '-'" + +- header: HUB + value: this.status.hub + type: osac.private.v1.Hub + lookup: true + +- header: ATTACHED + value: "has(this.status) && this.status.attached? 'true': 'false'" diff --git a/internal/rendering/tables/osac.public.v1.ExternalIP.yaml b/internal/rendering/tables/osac.public.v1.ExternalIP.yaml new file mode 100644 index 000000000..1c03644d1 --- /dev/null +++ b/internal/rendering/tables/osac.public.v1.ExternalIP.yaml @@ -0,0 +1,36 @@ +# +# Copyright (c) 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +columns: + +- header: PROJECT + value: "has(this.metadata.project)? this.metadata.project: '-'" + +- header: ID + value: this.id + +- header: NAME + value: "has(this.metadata.name)? this.metadata.name: '-'" + +- header: POOL + value: this.spec.pool + +- header: STATE + value: this.status.state + type: osac.public.v1.ExternalIPState + +- header: ADDRESS + value: "this.status.address != ''? this.status.address: '-'" + +- header: ATTACHED + value: "has(this.status) && this.status.attached? 'true': 'false'" From 55db092f600e024d284e8e68d925a517eef13220 Mon Sep 17 00:00:00 2001 From: Dan Manor Date: Sun, 5 Jul 2026 13:03:57 +0300 Subject: [PATCH 2/8] OSAC-1484: add ExternalIPPool table rendering definitions Assisted-by: Claude Code Signed-off-by: Dan Manor --- .../osac.private.v1.ExternalIPPool.yaml | 51 +++++++++++++++++++ .../tables/osac.public.v1.ExternalIPPool.yaml | 27 ++++++++++ 2 files changed, 78 insertions(+) create mode 100644 internal/rendering/tables/osac.private.v1.ExternalIPPool.yaml create mode 100644 internal/rendering/tables/osac.public.v1.ExternalIPPool.yaml diff --git a/internal/rendering/tables/osac.private.v1.ExternalIPPool.yaml b/internal/rendering/tables/osac.private.v1.ExternalIPPool.yaml new file mode 100644 index 000000000..8a8a9ee67 --- /dev/null +++ b/internal/rendering/tables/osac.private.v1.ExternalIPPool.yaml @@ -0,0 +1,51 @@ +# +# Copyright (c) 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +columns: + +- header: PROJECT + value: "has(this.metadata.project)? this.metadata.project: '-'" + +- header: ID + value: this.id + +- header: NAME + value: "has(this.metadata.name)? this.metadata.name: '-'" + +- header: CIDRS + value: "size(this.spec.cidrs) > 0? this.spec.cidrs.join(', '): '-'" + +- header: IP-FAMILY + value: this.spec.ip_family + type: osac.private.v1.IPFamily + +- header: STRATEGY + value: this.spec.implementation_strategy + +- header: STATE + value: this.status.state + type: osac.private.v1.ExternalIPPoolState + +- header: TOTAL + value: '"%d".format([this.status.total])' + +- header: ALLOCATED + value: '"%d".format([this.status.allocated])' + +- header: FREE + value: '"%d".format([this.status.available])' + +- header: HUB + value: this.status.hub + type: osac.private.v1.Hub + lookup: true diff --git a/internal/rendering/tables/osac.public.v1.ExternalIPPool.yaml b/internal/rendering/tables/osac.public.v1.ExternalIPPool.yaml new file mode 100644 index 000000000..421a6e784 --- /dev/null +++ b/internal/rendering/tables/osac.public.v1.ExternalIPPool.yaml @@ -0,0 +1,27 @@ +# +# Copyright (c) 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +columns: + +- header: ID + value: this.id + +- header: NAME + value: "has(this.metadata.name)? this.metadata.name: '-'" + +- header: IP-FAMILY + value: this.spec.ip_family + type: osac.public.v1.IPFamily + +- header: AVAILABLE + value: '"%d".format([this.status.available])' From 4e9983dcd8e6a1703f6a9412b53f36cf09cdf811 Mon Sep 17 00:00:00 2001 From: Dan Manor Date: Sun, 5 Jul 2026 13:04:22 +0300 Subject: [PATCH 3/8] OSAC-1484: add ExternalIPAttachment table rendering definitions Assisted-by: Claude Code Signed-off-by: Dan Manor --- .../osac.private.v1.ExternalIPAttachment.yaml | 38 +++++++++++++++++++ .../osac.public.v1.ExternalIPAttachment.yaml | 33 ++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 internal/rendering/tables/osac.private.v1.ExternalIPAttachment.yaml create mode 100644 internal/rendering/tables/osac.public.v1.ExternalIPAttachment.yaml diff --git a/internal/rendering/tables/osac.private.v1.ExternalIPAttachment.yaml b/internal/rendering/tables/osac.private.v1.ExternalIPAttachment.yaml new file mode 100644 index 000000000..de0dfa4e1 --- /dev/null +++ b/internal/rendering/tables/osac.private.v1.ExternalIPAttachment.yaml @@ -0,0 +1,38 @@ +# +# Copyright (c) 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +columns: + +- header: ID + value: this.id + +- header: NAME + value: "has(this.metadata.name)? this.metadata.name: '-'" + +- header: EXTERNAL-IP + value: this.spec.external_ip + +- header: TARGET + value: "has(this.spec.compute_instance)? this.spec.compute_instance: has(this.spec.cluster)? this.spec.cluster: has(this.spec.baremetal_instance)? this.spec.baremetal_instance: '-'" + +- header: TARGET-TYPE + value: "has(this.spec.compute_instance)? 'ComputeInstance': has(this.spec.cluster)? 'Cluster': has(this.spec.baremetal_instance)? 'BaremetalInstance': '-'" + +- header: STATE + value: this.status.state + type: osac.private.v1.ExternalIPAttachmentState + +- header: HUB + value: this.status.hub + type: osac.private.v1.Hub + lookup: true diff --git a/internal/rendering/tables/osac.public.v1.ExternalIPAttachment.yaml b/internal/rendering/tables/osac.public.v1.ExternalIPAttachment.yaml new file mode 100644 index 000000000..815148cd7 --- /dev/null +++ b/internal/rendering/tables/osac.public.v1.ExternalIPAttachment.yaml @@ -0,0 +1,33 @@ +# +# Copyright (c) 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +columns: + +- header: ID + value: this.id + +- header: NAME + value: "has(this.metadata.name)? this.metadata.name: '-'" + +- header: EXTERNAL-IP + value: this.spec.external_ip + +- header: TARGET + value: "has(this.spec.compute_instance)? this.spec.compute_instance: has(this.spec.cluster)? this.spec.cluster: has(this.spec.baremetal_instance)? this.spec.baremetal_instance: '-'" + +- header: TARGET-TYPE + value: "has(this.spec.compute_instance)? 'ComputeInstance': has(this.spec.cluster)? 'Cluster': has(this.spec.baremetal_instance)? 'BaremetalInstance': '-'" + +- header: STATE + value: this.status.state + type: osac.public.v1.ExternalIPAttachmentState From f9e4dfdabc94d6300132aeb53bb630218ef4f634 Mon Sep 17 00:00:00 2001 From: Dan Manor Date: Sun, 5 Jul 2026 13:06:05 +0300 Subject: [PATCH 4/8] OSAC-1484: add create externalip CLI command Assisted-by: Claude Code Signed-off-by: Dan Manor --- internal/cmd/cli/create/create_cmd.go | 2 + .../externalip/create_externalip_cmd.go | 147 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 internal/cmd/cli/create/externalip/create_externalip_cmd.go diff --git a/internal/cmd/cli/create/create_cmd.go b/internal/cmd/cli/create/create_cmd.go index ac51a7464..088844b8a 100644 --- a/internal/cmd/cli/create/create_cmd.go +++ b/internal/cmd/cli/create/create_cmd.go @@ -34,6 +34,7 @@ import ( "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/clustercatalogitem" "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/computeinstance" "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/computeinstancecatalogitem" + "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/externalip" "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/hub" "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/instancetype" "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/publicip" @@ -62,6 +63,7 @@ func Cmd() *cobra.Command { result.AddCommand(clustercatalogitem.Cmd()) result.AddCommand(computeinstance.Cmd()) result.AddCommand(computeinstancecatalogitem.Cmd()) + result.AddCommand(externalip.Cmd()) result.AddCommand(hub.Cmd()) result.AddCommand(instancetype.Cmd()) result.AddCommand(publicip.Cmd()) diff --git a/internal/cmd/cli/create/externalip/create_externalip_cmd.go b/internal/cmd/cli/create/externalip/create_externalip_cmd.go new file mode 100644 index 000000000..5541f4f62 --- /dev/null +++ b/internal/cmd/cli/create/externalip/create_externalip_cmd.go @@ -0,0 +1,147 @@ +/* +Copyright (c) 2026 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package externalip + +import ( + "fmt" + "log/slog" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/proto" + + publicv1 "github.com/osac-project/fulfillment-service/internal/api/osac/public/v1" + "github.com/osac-project/fulfillment-service/internal/cmd/cli/lookup" + "github.com/osac-project/fulfillment-service/internal/config" + "github.com/osac-project/fulfillment-service/internal/logging" + "github.com/osac-project/fulfillment-service/internal/terminal" +) + +func Cmd() *cobra.Command { + runner := &runnerContext{} + result := &cobra.Command{ + Use: "externalip [FLAG...]", + Aliases: []string{string(proto.MessageName((*publicv1.ExternalIP)(nil)))}, + Short: shortHelp, + Long: longHelp, + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + RunE: runner.run, + } + flags := result.Flags() + flags.StringVarP( + &runner.args.name, + "name", + "n", + "", + nameFlagHelp, + ) + flags.StringVar( + &runner.args.pool, + "pool", + "", + poolFlagHelp, + ) + result.MarkFlagRequired("pool") //nolint:errcheck + return result +} + +type runnerContext struct { + args struct { + name string + pool string + } + logger *slog.Logger + console *terminal.Console + settings *config.Settings +} + +func (c *runnerContext) run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + c.logger = logging.LoggerFromContext(ctx) + c.console = terminal.ConsoleFromContext(ctx) + + c.settings = config.SettingsFromContext(ctx) + if !c.settings.Armed() { + return fmt.Errorf("there is no configuration, run the 'login' command") + } + + conn, err := c.settings.Connect(ctx, cmd.Flags()) + if err != nil { + return fmt.Errorf("failed to create gRPC connection: %w", err) + } + defer conn.Close() + + poolClient := publicv1.NewExternalIPPoolsClient(conn) + pool, err := lookup.Find(c.args.pool, "external IP pool", func(filter string, limit int32) ([]*publicv1.ExternalIPPool, error) { + resp, err := poolClient.List(ctx, publicv1.ExternalIPPoolsListRequest_builder{ + Filter: proto.String(filter), + Limit: proto.Int32(limit), + }.Build()) + if err != nil { + return nil, fmt.Errorf("failed to resolve external IP pool %q: %w", c.args.pool, err) + } + return resp.GetItems(), nil + }) + if err != nil { + return err + } + + client := publicv1.NewExternalIPsClient(conn) + + externalIP := publicv1.ExternalIP_builder{ + Metadata: publicv1.Metadata_builder{ + Name: c.args.name, + Tenant: c.settings.Tenant(), + }.Build(), + Spec: publicv1.ExternalIPSpec_builder{ + Pool: pool.GetId(), + }.Build(), + }.Build() + + response, err := client.Create(ctx, publicv1.ExternalIPsCreateRequest_builder{Object: externalIP}.Build()) + if err != nil { + return fmt.Errorf("failed to create external IP: %w", err) + } + + c.console.Infof(ctx, "Created external IP '%s' (ID: %s).\n", response.Object.GetMetadata().GetName(), response.Object.GetId()) + + return nil +} + +const shortHelp = `Create an external IP.` + +const longHelp = ` +Allocate an external IP address from an ExternalIPPool. + +The {{ bt }}--pool{{ bt }} flag is required and specifies the ExternalIPPool to allocate from. + +Examples: + +{{ bt 3 }}shell +# Create an external IP from a specific pool +{{ binary }} create externalip --name my-ip --pool pool-abc123 + +# Create an external IP using pool name +{{ binary }} create externalip --name my-ip --pool external-pool-1 +{{ bt 3 }} +` + +const nameFlagHelp = ` +_NAME_ - Name of the external IP. +` + +const poolFlagHelp = ` +_ID|NAME_ - ID or name of the parent ExternalIPPool to allocate the address from. Required. +` From 43340c28f01169d422243ef3ddf9460110defff0 Mon Sep 17 00:00:00 2001 From: Dan Manor Date: Sun, 5 Jul 2026 13:07:47 +0300 Subject: [PATCH 5/8] OSAC-1484: add create externalipattachment CLI command Supports three target types (--compute-instance, --cluster, --baremetal-instance) and --target-endpoint for cluster targets. Assisted-by: Claude Code Signed-off-by: Dan Manor --- internal/cmd/cli/create/create_cmd.go | 2 + .../create_externalipattachment_cmd.go | 294 ++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 internal/cmd/cli/create/externalipattachment/create_externalipattachment_cmd.go diff --git a/internal/cmd/cli/create/create_cmd.go b/internal/cmd/cli/create/create_cmd.go index 088844b8a..df38b1581 100644 --- a/internal/cmd/cli/create/create_cmd.go +++ b/internal/cmd/cli/create/create_cmd.go @@ -35,6 +35,7 @@ import ( "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/computeinstance" "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/computeinstancecatalogitem" "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/externalip" + "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/externalipattachment" "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/hub" "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/instancetype" "github.com/osac-project/fulfillment-service/internal/cmd/cli/create/publicip" @@ -64,6 +65,7 @@ func Cmd() *cobra.Command { result.AddCommand(computeinstance.Cmd()) result.AddCommand(computeinstancecatalogitem.Cmd()) result.AddCommand(externalip.Cmd()) + result.AddCommand(externalipattachment.Cmd()) result.AddCommand(hub.Cmd()) result.AddCommand(instancetype.Cmd()) result.AddCommand(publicip.Cmd()) diff --git a/internal/cmd/cli/create/externalipattachment/create_externalipattachment_cmd.go b/internal/cmd/cli/create/externalipattachment/create_externalipattachment_cmd.go new file mode 100644 index 000000000..46b0182ef --- /dev/null +++ b/internal/cmd/cli/create/externalipattachment/create_externalipattachment_cmd.go @@ -0,0 +1,294 @@ +/* +Copyright (c) 2026 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package externalipattachment + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/proto" + + publicv1 "github.com/osac-project/fulfillment-service/internal/api/osac/public/v1" + "github.com/osac-project/fulfillment-service/internal/cmd/cli/lookup" + "github.com/osac-project/fulfillment-service/internal/config" + "github.com/osac-project/fulfillment-service/internal/logging" + "github.com/osac-project/fulfillment-service/internal/terminal" +) + +func Cmd() *cobra.Command { + runner := &runnerContext{} + result := &cobra.Command{ + Use: "externalipattachment [FLAG...]", + Short: shortHelp, + Long: longHelp, + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + RunE: runner.run, + } + flags := result.Flags() + flags.StringVarP( + &runner.args.name, + "name", + "n", + "", + nameFlagHelp, + ) + flags.StringVar( + &runner.args.externalIP, + "externalip", + "", + externalIPFlagHelp, + ) + flags.StringVar( + &runner.args.computeInstance, + "compute-instance", + "", + computeInstanceFlagHelp, + ) + flags.StringVar( + &runner.args.cluster, + "cluster", + "", + clusterFlagHelp, + ) + flags.StringVar( + &runner.args.baremetalInstance, + "baremetal-instance", + "", + baremetalInstanceFlagHelp, + ) + flags.StringVar( + &runner.args.targetEndpoint, + "target-endpoint", + "", + targetEndpointFlagHelp, + ) + result.MarkFlagRequired("externalip") //nolint:errcheck + result.MarkFlagsMutuallyExclusive("compute-instance", "cluster", "baremetal-instance") + return result +} + +type runnerContext struct { + args struct { + name string + externalIP string + computeInstance string + cluster string + baremetalInstance string + targetEndpoint string + } + logger *slog.Logger + console *terminal.Console +} + +func (c *runnerContext) run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + c.logger = logging.LoggerFromContext(ctx) + c.console = terminal.ConsoleFromContext(ctx) + + cfg := config.SettingsFromContext(ctx) + if !cfg.Armed() { + return fmt.Errorf("there is no configuration, run the 'login' command") + } + + conn, err := cfg.Connect(ctx, cmd.Flags()) + if err != nil { + return fmt.Errorf("failed to create gRPC connection: %w", err) + } + defer conn.Close() + + if c.args.computeInstance == "" && c.args.cluster == "" && c.args.baremetalInstance == "" { + return fmt.Errorf("exactly one target is required: --compute-instance, --cluster, or --baremetal-instance") + } + + eipClient := publicv1.NewExternalIPsClient(conn) + eip, err := lookup.Find(c.args.externalIP, "external IP", func(filter string, limit int32) ([]*publicv1.ExternalIP, error) { + resp, err := eipClient.List(ctx, publicv1.ExternalIPsListRequest_builder{ + Filter: proto.String(filter), + Limit: proto.Int32(limit), + }.Build()) + if err != nil { + return nil, fmt.Errorf("failed to resolve external IP %q: %w", c.args.externalIP, err) + } + return resp.GetItems(), nil + }) + if err != nil { + return err + } + + spec := publicv1.ExternalIPAttachmentSpec_builder{ + ExternalIp: eip.GetId(), + } + + var targetDesc string + + switch { + case c.args.computeInstance != "": + ciClient := publicv1.NewComputeInstancesClient(conn) + ci, err := lookup.Find(c.args.computeInstance, "compute instance", func(filter string, limit int32) ([]*publicv1.ComputeInstance, error) { + resp, err := ciClient.List(ctx, publicv1.ComputeInstancesListRequest_builder{ + Filter: proto.String(filter), + Limit: proto.Int32(limit), + }.Build()) + if err != nil { + return nil, fmt.Errorf("failed to resolve compute instance %q: %w", c.args.computeInstance, err) + } + return resp.GetItems(), nil + }) + if err != nil { + return err + } + spec.ComputeInstance = proto.String(ci.GetId()) + targetDesc = fmt.Sprintf("compute instance '%s'", ci.GetId()) + + case c.args.cluster != "": + clClient := publicv1.NewClustersClient(conn) + cl, err := lookup.Find(c.args.cluster, "cluster", func(filter string, limit int32) ([]*publicv1.Cluster, error) { + resp, err := clClient.List(ctx, publicv1.ClustersListRequest_builder{ + Filter: proto.String(filter), + Limit: proto.Int32(limit), + }.Build()) + if err != nil { + return nil, fmt.Errorf("failed to resolve cluster %q: %w", c.args.cluster, err) + } + return resp.GetItems(), nil + }) + if err != nil { + return err + } + spec.Cluster = proto.String(cl.GetId()) + targetDesc = fmt.Sprintf("cluster '%s'", cl.GetId()) + + if c.args.targetEndpoint != "" { + endpoint, err := parseTargetEndpoint(c.args.targetEndpoint) + if err != nil { + return err + } + spec.TargetEndpoint = endpoint + } + + case c.args.baremetalInstance != "": + bmClient := publicv1.NewBareMetalInstancesClient(conn) + bm, err := lookup.Find(c.args.baremetalInstance, "bare metal instance", func(filter string, limit int32) ([]*publicv1.BareMetalInstance, error) { + resp, err := bmClient.List(ctx, publicv1.BareMetalInstancesListRequest_builder{ + Filter: proto.String(filter), + Limit: proto.Int32(limit), + }.Build()) + if err != nil { + return nil, fmt.Errorf("failed to resolve bare metal instance %q: %w", c.args.baremetalInstance, err) + } + return resp.GetItems(), nil + }) + if err != nil { + return err + } + spec.BaremetalInstance = proto.String(bm.GetId()) + targetDesc = fmt.Sprintf("bare metal instance '%s'", bm.GetId()) + } + + attachClient := publicv1.NewExternalIPAttachmentsClient(conn) + + attachment := publicv1.ExternalIPAttachment_builder{ + Metadata: publicv1.Metadata_builder{ + Name: c.args.name, + Tenant: cfg.Tenant(), + }.Build(), + Spec: spec.Build(), + }.Build() + + response, err := attachClient.Create(ctx, publicv1.ExternalIPAttachmentsCreateRequest_builder{ + Object: attachment, + }.Build()) + if err != nil { + return fmt.Errorf("failed to create external IP attachment: %w", err) + } + + c.console.Infof(ctx, "Created external IP attachment '%s' (external IP '%s' -> %s).\n", + response.GetObject().GetId(), eip.GetId(), targetDesc) + + return nil +} + +func parseTargetEndpoint(value string) (publicv1.ExternalIPAttachmentEndpoint, error) { + switch strings.ToLower(value) { + case "api": + return publicv1.ExternalIPAttachmentEndpoint_EXTERNAL_IP_ATTACHMENT_ENDPOINT_API, nil + case "ingress": + return publicv1.ExternalIPAttachmentEndpoint_EXTERNAL_IP_ATTACHMENT_ENDPOINT_INGRESS, nil + default: + return publicv1.ExternalIPAttachmentEndpoint_EXTERNAL_IP_ATTACHMENT_ENDPOINT_UNSPECIFIED, + fmt.Errorf("invalid target endpoint %q: must be 'api' or 'ingress'", value) + } +} + +const shortHelp = `Attach an external IP to a resource.` + +const longHelp = ` +Create an external IP attachment to bind an external IP to a target resource. + +The {{ bt }}--externalip{{ bt }} flag is required. Exactly one target flag must be provided: +{{ bt }}--compute-instance{{ bt }}, {{ bt }}--cluster{{ bt }}, or {{ bt }}--baremetal-instance{{ bt }}. + +When targeting a cluster, use {{ bt }}--target-endpoint{{ bt }} to specify whether the external IP +should be routed to the cluster's API server ({{ bt }}api{{ bt }}) or ingress controller +({{ bt }}ingress{{ bt }}). + +Examples: + +{{ bt 3 }}shell +# Attach to a compute instance +{{ binary }} create externalipattachment --externalip my-ip --compute-instance my-vm --name vm-att + +# Attach to a cluster API server +{{ binary }} create externalipattachment --externalip my-ip --cluster my-cluster --target-endpoint api --name api-att + +# Attach to a cluster ingress +{{ binary }} create externalipattachment --externalip my-ip --cluster my-cluster --target-endpoint ingress --name ingress-att + +# Attach to a bare metal instance +{{ binary }} create externalipattachment --externalip my-ip --baremetal-instance my-server --name bm-att +{{ bt 3 }} +` + +const nameFlagHelp = ` +_NAME_ - Name of the external IP attachment. +` + +const externalIPFlagHelp = ` +_ID|NAME_ - Identifier or name of the external IP to attach. Required. +` + +const computeInstanceFlagHelp = ` +_ID|NAME_ - Identifier or name of the compute instance to attach the external IP to. +Mutually exclusive with {{ bt }}--cluster{{ bt }} and {{ bt }}--baremetal-instance{{ bt }}. +` + +const clusterFlagHelp = ` +_ID|NAME_ - Identifier or name of the cluster to attach the external IP to. +Use {{ bt }}--target-endpoint{{ bt }} to specify the cluster endpoint (api or ingress). +Mutually exclusive with {{ bt }}--compute-instance{{ bt }} and {{ bt }}--baremetal-instance{{ bt }}. +` + +const baremetalInstanceFlagHelp = ` +_ID|NAME_ - Identifier or name of the bare metal instance to attach the external IP to. +Mutually exclusive with {{ bt }}--compute-instance{{ bt }} and {{ bt }}--cluster{{ bt }}. +` + +const targetEndpointFlagHelp = ` +_api|ingress_ - Cluster endpoint to route the external IP to. Only applicable when targeting a +cluster with {{ bt }}--cluster{{ bt }}. +` From 0a9af9b2cf077f903d3700f78f712f87c6cb7e12 Mon Sep 17 00:00:00 2001 From: Dan Manor Date: Sun, 5 Jul 2026 13:08:37 +0300 Subject: [PATCH 6/8] OSAC-1484: add describe externalip CLI command Assisted-by: Claude Code Signed-off-by: Dan Manor --- internal/cmd/cli/describe/describe_cmd.go | 2 + .../externalip/describe_externalip_cmd.go | 149 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 internal/cmd/cli/describe/externalip/describe_externalip_cmd.go diff --git a/internal/cmd/cli/describe/describe_cmd.go b/internal/cmd/cli/describe/describe_cmd.go index e407f2564..6afa700eb 100644 --- a/internal/cmd/cli/describe/describe_cmd.go +++ b/internal/cmd/cli/describe/describe_cmd.go @@ -19,6 +19,7 @@ import ( "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/baremetalinstance" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/cluster" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/computeinstance" + "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/externalip" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/instancetype" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/networkclass" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/publicip" @@ -37,6 +38,7 @@ func Cmd() *cobra.Command { result.AddCommand(baremetalinstance.Cmd()) result.AddCommand(cluster.Cmd()) result.AddCommand(computeinstance.Cmd()) + result.AddCommand(externalip.Cmd()) result.AddCommand(instancetype.Cmd()) result.AddCommand(networkclass.Cmd()) result.AddCommand(publicip.Cmd()) diff --git a/internal/cmd/cli/describe/externalip/describe_externalip_cmd.go b/internal/cmd/cli/describe/externalip/describe_externalip_cmd.go new file mode 100644 index 000000000..24bdff2a3 --- /dev/null +++ b/internal/cmd/cli/describe/externalip/describe_externalip_cmd.go @@ -0,0 +1,149 @@ +/* +Copyright (c) 2026 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package externalip + +import ( + "fmt" + "io" + "log/slog" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/proto" + + publicv1 "github.com/osac-project/fulfillment-service/internal/api/osac/public/v1" + "github.com/osac-project/fulfillment-service/internal/cmd/cli/lookup" + "github.com/osac-project/fulfillment-service/internal/config" + "github.com/osac-project/fulfillment-service/internal/logging" + "github.com/osac-project/fulfillment-service/internal/terminal" +) + +func Cmd() *cobra.Command { + runner := &runnerContext{} + result := &cobra.Command{ + Use: "externalip [FLAG...] ID|NAME", + Aliases: []string{"externalips"}, + Short: shortHelp, + Long: longHelp, + DisableFlagsInUseLine: true, + Args: cobra.ExactArgs(1), + RunE: runner.run, + } + return result +} + +type runnerContext struct { + logger *slog.Logger + console *terminal.Console +} + +func (c *runnerContext) run(cmd *cobra.Command, args []string) error { + ref := args[0] + + ctx := cmd.Context() + + c.logger = logging.LoggerFromContext(ctx) + c.console = terminal.ConsoleFromContext(ctx) + + cfg := config.SettingsFromContext(ctx) + if !cfg.Armed() { + return fmt.Errorf("there is no configuration, run the 'login' command") + } + + conn, err := cfg.Connect(ctx, cmd.Flags()) + if err != nil { + return fmt.Errorf("failed to create gRPC connection: %w", err) + } + defer conn.Close() + + client := publicv1.NewExternalIPsClient(conn) + + matched, err := lookup.Find(ref, "external IP", func(filter string, limit int32) ([]*publicv1.ExternalIP, error) { + resp, err := client.List(ctx, publicv1.ExternalIPsListRequest_builder{ + Filter: proto.String(filter), + Limit: proto.Int32(limit), + }.Build()) + if err != nil { + return nil, fmt.Errorf("failed to describe external IP: %w", err) + } + return resp.GetItems(), nil + }) + if err != nil { + return err + } + + RenderExternalIP(c.console, matched) + + return nil +} + +func RenderExternalIP(w io.Writer, eip *publicv1.ExternalIP) { + writer := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + + name := "-" + if v := eip.GetMetadata().GetName(); v != "" { + name = v + } + + pool := "-" + if v := eip.GetSpec().GetPool(); v != "" { + pool = v + } + + attached := "false" + if eip.GetStatus() != nil && eip.GetStatus().GetAttached() { + attached = "true" + } + + address := "-" + state := "-" + message := "-" + if eip.GetStatus() != nil { + state = strings.TrimPrefix(eip.GetStatus().GetState().String(), "EXTERNAL_IP_STATE_") + if v := eip.GetStatus().GetAddress(); v != "" { + address = v + } + if v := eip.GetStatus().GetMessage(); v != "" { + message = v + } + } + + fmt.Fprintf(writer, "ID:\t%s\n", eip.GetId()) + fmt.Fprintf(writer, "Name:\t%s\n", name) + fmt.Fprintf(writer, "Pool:\t%s\n", pool) + fmt.Fprintf(writer, "Attached:\t%s\n", attached) + fmt.Fprintf(writer, "Address:\t%s\n", address) + fmt.Fprintf(writer, "State:\t%s\n", state) + fmt.Fprintf(writer, "Message:\t%s\n", message) + writer.Flush() +} + +const shortHelp = `Describe an external IP` + +const longHelp = ` +Display detailed information about an external IP, referenced by identifier or name. + +Examples: + +{{ bt 3 }}shell +# Describe an external IP by identifier: +{{ binary }} describe externalip 019e5fee-0742-78b7-8c4a-e2501f44783a +{{ bt 3 }} + +{{ bt 3 }}shell +# Describe an external IP by name: +{{ binary }} describe externalip my-ip +{{ bt 3 }} +` From a676343baafe7b2a5b6b037aa6e9804b7b049a05 Mon Sep 17 00:00:00 2001 From: Dan Manor Date: Sun, 5 Jul 2026 13:09:30 +0300 Subject: [PATCH 7/8] OSAC-1484: add describe externalipattachment CLI command Assisted-by: Claude Code Signed-off-by: Dan Manor --- internal/cmd/cli/describe/describe_cmd.go | 2 + .../describe_externalipattachment_cmd.go | 162 ++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 internal/cmd/cli/describe/externalipattachment/describe_externalipattachment_cmd.go diff --git a/internal/cmd/cli/describe/describe_cmd.go b/internal/cmd/cli/describe/describe_cmd.go index 6afa700eb..6a9c35b38 100644 --- a/internal/cmd/cli/describe/describe_cmd.go +++ b/internal/cmd/cli/describe/describe_cmd.go @@ -20,6 +20,7 @@ import ( "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/cluster" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/computeinstance" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/externalip" + "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/externalipattachment" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/instancetype" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/networkclass" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/publicip" @@ -39,6 +40,7 @@ func Cmd() *cobra.Command { result.AddCommand(cluster.Cmd()) result.AddCommand(computeinstance.Cmd()) result.AddCommand(externalip.Cmd()) + result.AddCommand(externalipattachment.Cmd()) result.AddCommand(instancetype.Cmd()) result.AddCommand(networkclass.Cmd()) result.AddCommand(publicip.Cmd()) diff --git a/internal/cmd/cli/describe/externalipattachment/describe_externalipattachment_cmd.go b/internal/cmd/cli/describe/externalipattachment/describe_externalipattachment_cmd.go new file mode 100644 index 000000000..f00a15b18 --- /dev/null +++ b/internal/cmd/cli/describe/externalipattachment/describe_externalipattachment_cmd.go @@ -0,0 +1,162 @@ +/* +Copyright (c) 2026 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package externalipattachment + +import ( + "fmt" + "io" + "log/slog" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/proto" + + publicv1 "github.com/osac-project/fulfillment-service/internal/api/osac/public/v1" + "github.com/osac-project/fulfillment-service/internal/cmd/cli/lookup" + "github.com/osac-project/fulfillment-service/internal/config" + "github.com/osac-project/fulfillment-service/internal/logging" + "github.com/osac-project/fulfillment-service/internal/terminal" +) + +func Cmd() *cobra.Command { + runner := &runnerContext{} + result := &cobra.Command{ + Use: "externalipattachment [FLAG...] ID|NAME", + Aliases: []string{"externalipattachments"}, + Short: shortHelp, + Long: longHelp, + DisableFlagsInUseLine: true, + Args: cobra.ExactArgs(1), + RunE: runner.run, + } + return result +} + +type runnerContext struct { + logger *slog.Logger + console *terminal.Console +} + +func (c *runnerContext) run(cmd *cobra.Command, args []string) error { + ref := args[0] + + ctx := cmd.Context() + + c.logger = logging.LoggerFromContext(ctx) + c.console = terminal.ConsoleFromContext(ctx) + + cfg := config.SettingsFromContext(ctx) + if !cfg.Armed() { + return fmt.Errorf("there is no configuration, run the 'login' command") + } + + conn, err := cfg.Connect(ctx, cmd.Flags()) + if err != nil { + return fmt.Errorf("failed to create gRPC connection: %w", err) + } + defer conn.Close() + + client := publicv1.NewExternalIPAttachmentsClient(conn) + + matched, err := lookup.Find(ref, "external IP attachment", func(filter string, limit int32) ([]*publicv1.ExternalIPAttachment, error) { + resp, err := client.List(ctx, publicv1.ExternalIPAttachmentsListRequest_builder{ + Filter: proto.String(filter), + Limit: proto.Int32(limit), + }.Build()) + if err != nil { + return nil, fmt.Errorf("failed to describe external IP attachment: %w", err) + } + return resp.GetItems(), nil + }) + if err != nil { + return err + } + + RenderExternalIPAttachment(c.console, matched) + + return nil +} + +func RenderExternalIPAttachment(w io.Writer, a *publicv1.ExternalIPAttachment) { + writer := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + + name := "-" + if v := a.GetMetadata().GetName(); v != "" { + name = v + } + + externalIP := "-" + if v := a.GetSpec().GetExternalIp(); v != "" { + externalIP = v + } + + targetType := "-" + targetID := "-" + if v := a.GetSpec().GetComputeInstance(); v != "" { + targetType = "ComputeInstance" + targetID = v + } else if v := a.GetSpec().GetCluster(); v != "" { + targetType = "Cluster" + targetID = v + } else if v := a.GetSpec().GetBaremetalInstance(); v != "" { + targetType = "BaremetalInstance" + targetID = v + } + + state := "-" + externalIPAddress := "-" + message := "-" + if a.GetStatus() != nil { + state = strings.TrimPrefix(a.GetStatus().GetState().String(), "EXTERNAL_IP_ATTACHMENT_STATE_") + if v := a.GetStatus().GetExternalIpAddress(); v != "" { + externalIPAddress = v + } + if v := a.GetStatus().GetMessage(); v != "" { + message = v + } + } + + fmt.Fprintf(writer, "ID:\t%s\n", a.GetId()) + fmt.Fprintf(writer, "Name:\t%s\n", name) + fmt.Fprintf(writer, "External IP:\t%s\n", externalIP) + fmt.Fprintf(writer, "Target Type:\t%s\n", targetType) + fmt.Fprintf(writer, "Target:\t%s\n", targetID) + if a.GetSpec().GetCluster() != "" { + endpoint := strings.TrimPrefix(a.GetSpec().GetTargetEndpoint().String(), "EXTERNAL_IP_ATTACHMENT_ENDPOINT_") + fmt.Fprintf(writer, "Target Endpoint:\t%s\n", endpoint) + } + fmt.Fprintf(writer, "External IP Address:\t%s\n", externalIPAddress) + fmt.Fprintf(writer, "State:\t%s\n", state) + fmt.Fprintf(writer, "Message:\t%s\n", message) + writer.Flush() +} + +const shortHelp = `Describe an external IP attachment` + +const longHelp = ` +Display detailed information about an external IP attachment, referenced by identifier or name. + +Examples: + +{{ bt 3 }}shell +# Describe an external IP attachment by identifier: +{{ binary }} describe externalipattachment 019e5fee-0742-78b7-8c4a-e2501f44783a +{{ bt 3 }} + +{{ bt 3 }}shell +# Describe an external IP attachment by name: +{{ binary }} describe externalipattachment my-attachment +{{ bt 3 }} +` From 9cac53e9f0908eb241f51d7f6d2b24f60bc881f4 Mon Sep 17 00:00:00 2001 From: Dan Manor Date: Sun, 5 Jul 2026 13:10:23 +0300 Subject: [PATCH 8/8] OSAC-1484: add get externalippool CLI subcommand Assisted-by: Claude Code Signed-off-by: Dan Manor --- .../create_externalipattachment_cmd.go | 4 +- .../externalippool/get_externalippool_cmd.go | 138 ++++++++++++++++++ internal/cmd/cli/get/get_cmd.go | 2 + 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/cli/get/externalippool/get_externalippool_cmd.go diff --git a/internal/cmd/cli/create/externalipattachment/create_externalipattachment_cmd.go b/internal/cmd/cli/create/externalipattachment/create_externalipattachment_cmd.go index 46b0182ef..11811efba 100644 --- a/internal/cmd/cli/create/externalipattachment/create_externalipattachment_cmd.go +++ b/internal/cmd/cli/create/externalipattachment/create_externalipattachment_cmd.go @@ -76,7 +76,7 @@ func Cmd() *cobra.Command { "", targetEndpointFlagHelp, ) - result.MarkFlagRequired("externalip") //nolint:errcheck + result.MarkFlagRequired("externalip") //nolint:errcheck result.MarkFlagsMutuallyExclusive("compute-instance", "cluster", "baremetal-instance") return result } @@ -87,7 +87,7 @@ type runnerContext struct { externalIP string computeInstance string cluster string - baremetalInstance string + baremetalInstance string targetEndpoint string } logger *slog.Logger diff --git a/internal/cmd/cli/get/externalippool/get_externalippool_cmd.go b/internal/cmd/cli/get/externalippool/get_externalippool_cmd.go new file mode 100644 index 000000000..ac362cc64 --- /dev/null +++ b/internal/cmd/cli/get/externalippool/get_externalippool_cmd.go @@ -0,0 +1,138 @@ +/* +Copyright (c) 2026 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package externalippool + +import ( + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/proto" + + publicv1 "github.com/osac-project/fulfillment-service/internal/api/osac/public/v1" + "github.com/osac-project/fulfillment-service/internal/cmd/cli/lookup" + "github.com/osac-project/fulfillment-service/internal/config" + "github.com/osac-project/fulfillment-service/internal/terminal" +) + +// Cmd creates the command to list or get external IP pools. +func Cmd() *cobra.Command { + runner := &runnerContext{} + result := &cobra.Command{ + Use: "externalippool [ID_OR_NAME]", + Aliases: []string{"externalippools"}, + Short: "List or get external IP pools", + Long: "List all available external IP pools, or display details for a specific pool by ID or name.", + Example: ` # List all available pools + osac get externalippool + + # Get a specific pool by name + osac get externalippool pool-ipv4-prod + + # Get a specific pool by ID + osac get externalippool pool-abc123`, + Args: cobra.MaximumNArgs(1), + RunE: runner.run, + } + return result +} + +type runnerContext struct { + console *terminal.Console +} + +func (c *runnerContext) run(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + c.console = terminal.ConsoleFromContext(ctx) + + cfg := config.SettingsFromContext(ctx) + if !cfg.Armed() { + return fmt.Errorf("there is no configuration, run the 'login' command") + } + + conn, err := cfg.Connect(ctx, cmd.Flags()) + if err != nil { + return fmt.Errorf("failed to create gRPC connection: %w", err) + } + defer conn.Close() + + client := publicv1.NewExternalIPPoolsClient(conn) + + if len(args) == 0 { + resp, err := client.List(ctx, publicv1.ExternalIPPoolsListRequest_builder{}.Build()) + if err != nil { + return fmt.Errorf("failed to list external IP pools: %w", err) + } + if len(resp.GetItems()) == 0 { + c.console.Infof(ctx, "No external IP pools found.\n") + return nil + } + renderPoolTable(c.console, resp.GetItems()) + return nil + } + + ref := args[0] + pool, err := lookup.Find(ref, "external IP pool", func(filter string, limit int32) ([]*publicv1.ExternalIPPool, error) { + resp, err := client.List(ctx, publicv1.ExternalIPPoolsListRequest_builder{ + Filter: proto.String(filter), + Limit: proto.Int32(limit), + }.Build()) + if err != nil { + return nil, fmt.Errorf("failed to list external IP pools: %w", err) + } + return resp.GetItems(), nil + }) + if err != nil { + return err + } + + renderPoolDetail(c.console, pool) + return nil +} + +// renderPoolTable writes a compact table of pools — used when listing all pools. +func renderPoolTable(w *terminal.Console, pools []*publicv1.ExternalIPPool) { + writer := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(writer, "ID\tNAME\tIP-FAMILY\tAVAILABLE") + for _, p := range pools { + name := p.GetMetadata().GetName() + if name == "" { + name = "-" + } + ipFamily := strings.TrimPrefix(p.GetSpec().GetIpFamily().String(), "IP_FAMILY_") + available := fmt.Sprintf("%d", p.GetStatus().GetAvailable()) + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", p.GetId(), name, ipFamily, available) + } + writer.Flush() +} + +// renderPoolDetail writes a detailed key-value view of a single pool — used when getting by name/id. +func renderPoolDetail(w *terminal.Console, p *publicv1.ExternalIPPool) { + writer := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + + name := "-" + if v := p.GetMetadata().GetName(); v != "" { + name = v + } + + ipFamily := strings.TrimPrefix(p.GetSpec().GetIpFamily().String(), "IP_FAMILY_") + + fmt.Fprintf(writer, "ID:\t%s\n", p.GetId()) + fmt.Fprintf(writer, "Name:\t%s\n", name) + fmt.Fprintf(writer, "IP Family:\t%s\n", ipFamily) + fmt.Fprintf(writer, "Available:\t%d\n", p.GetStatus().GetAvailable()) + writer.Flush() +} diff --git a/internal/cmd/cli/get/get_cmd.go b/internal/cmd/cli/get/get_cmd.go index 3c4665f51..fc82d42a1 100644 --- a/internal/cmd/cli/get/get_cmd.go +++ b/internal/cmd/cli/get/get_cmd.go @@ -28,6 +28,7 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" + "github.com/osac-project/fulfillment-service/internal/cmd/cli/get/externalippool" "github.com/osac-project/fulfillment-service/internal/cmd/cli/get/kubeconfig" "github.com/osac-project/fulfillment-service/internal/cmd/cli/get/password" "github.com/osac-project/fulfillment-service/internal/cmd/cli/get/publicippool" @@ -62,6 +63,7 @@ func Cmd() *cobra.Command { Long: longHelp, RunE: runner.run, } + result.AddCommand(externalippool.Cmd()) result.AddCommand(kubeconfig.Cmd()) result.AddCommand(password.Cmd()) result.AddCommand(publicippool.Cmd())