diff --git a/internal/cmd/cli/describe/describe_cmd.go b/internal/cmd/cli/describe/describe_cmd.go index cc6e5f661..4817b0aca 100644 --- a/internal/cmd/cli/describe/describe_cmd.go +++ b/internal/cmd/cli/describe/describe_cmd.go @@ -18,6 +18,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/networkclass" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/publicip" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/publicipattachment" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/securitygroup" @@ -33,6 +34,7 @@ func Cmd() *cobra.Command { } result.AddCommand(cluster.Cmd()) result.AddCommand(computeinstance.Cmd()) + result.AddCommand(networkclass.Cmd()) result.AddCommand(publicip.Cmd()) result.AddCommand(publicipattachment.Cmd()) result.AddCommand(virtualnetwork.Cmd()) diff --git a/internal/cmd/cli/describe/describe_cmd_test.go b/internal/cmd/cli/describe/describe_cmd_test.go index f53d70e88..ae72c8ac0 100644 --- a/internal/cmd/cli/describe/describe_cmd_test.go +++ b/internal/cmd/cli/describe/describe_cmd_test.go @@ -23,6 +23,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/networkclass" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/publicip" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/securitygroup" "github.com/osac-project/fulfillment-service/internal/cmd/cli/describe/subnet" @@ -37,6 +38,7 @@ var _ = Describe("Describe command", func() { }, Entry("cluster", cluster.Cmd, "clusters"), Entry("computeinstance", computeinstance.Cmd, "computeinstances"), + Entry("networkclass", networkclass.Cmd, "networkclasses"), Entry("publicip", publicip.Cmd, "publicips"), Entry("virtualnetwork", virtualnetwork.Cmd, "virtualnetworks"), Entry("subnet", subnet.Cmd, "subnets"), @@ -53,7 +55,7 @@ var _ = Describe("Describe command", func() { subcommandNames = append(subcommandNames, subcmd.Name()) } - Expect(subcommandNames).To(ContainElements("cluster", "computeinstance", "publicip", "virtualnetwork", "subnet", "securitygroup")) + Expect(subcommandNames).To(ContainElements("cluster", "computeinstance", "networkclass", "publicip", "virtualnetwork", "subnet", "securitygroup")) }) }) diff --git a/internal/cmd/cli/describe/networkclass/describe_networkclass_cmd.go b/internal/cmd/cli/describe/networkclass/describe_networkclass_cmd.go new file mode 100644 index 000000000..da05a03be --- /dev/null +++ b/internal/cmd/cli/describe/networkclass/describe_networkclass_cmd.go @@ -0,0 +1,181 @@ +/* +Copyright (c) 2025 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 networkclass + +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: "networkclass [FLAG...] ID|NAME", + Aliases: []string{"networkclasses"}, + Short: shortHelp, + Long: longHelp, + DisableFlagsInUseLine: true, + Args: cobra.ExactArgs(1), + RunE: runner.run, + } + result.Flags().BoolVar( + &runner.includeDeleted, + "include-deleted", + false, + "Include soft-deleted objects in resolution.", + ) + return result +} + +type runnerContext struct { + logger *slog.Logger + console *terminal.Console + includeDeleted bool +} + +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.NewNetworkClassesClient(conn) + + matched, err := lookup.Find( + ref, "network class", + lookup.FindOptions{IncludeDeleted: c.includeDeleted}, + func(filter string, limit int32) ([]*publicv1.NetworkClass, error) { + resp, err := client.List(ctx, publicv1.NetworkClassesListRequest_builder{ + Filter: proto.String(filter), + Limit: proto.Int32(limit), + }.Build()) + if err != nil { + return nil, fmt.Errorf("failed to describe network class: %w", err) + } + return resp.GetItems(), nil + }, + ) + if err != nil { + return err + } + + RenderNetworkClass(c.console, matched) + + return nil +} + +func boolYesNo(v bool) string { + if v { + return "yes" + } + return "no" +} + +// RenderNetworkClass writes a formatted description of nc to w. +func RenderNetworkClass(w io.Writer, nc *publicv1.NetworkClass) { + writer := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + + name := "-" + if v := nc.GetMetadata().GetName(); v != "" { + name = v + } + + title := "-" + if v := nc.GetTitle(); v != "" { + title = v + } + + description := "-" + if v := nc.GetDescription(); v != "" { + description = v + } + + supportsIPv4 := "-" + supportsIPv6 := "-" + supportsDualStack := "-" + if caps := nc.GetCapabilities(); caps != nil { + supportsIPv4 = boolYesNo(caps.GetSupportsIpv4()) + supportsIPv6 = boolYesNo(caps.GetSupportsIpv6()) + supportsDualStack = boolYesNo(caps.GetSupportsDualStack()) + } + + isDefault := "-" + if nc.HasIsDefault() { + isDefault = boolYesNo(nc.GetIsDefault()) + } + + state := "-" + message := "-" + if nc.GetStatus() != nil { + state = strings.TrimPrefix(nc.GetStatus().GetState().String(), "NETWORK_CLASS_STATE_") + if v := nc.GetStatus().GetMessage(); v != "" { + message = v + } + } + + fmt.Fprintf(writer, "ID:\t%s\n", nc.GetId()) + fmt.Fprintf(writer, "Name:\t%s\n", name) + fmt.Fprintf(writer, "Title:\t%s\n", title) + fmt.Fprintf(writer, "Description:\t%s\n", description) + fmt.Fprintf(writer, "Supports IPv4:\t%s\n", supportsIPv4) + fmt.Fprintf(writer, "Supports IPv6:\t%s\n", supportsIPv6) + fmt.Fprintf(writer, "Supports Dual Stack:\t%s\n", supportsDualStack) + fmt.Fprintf(writer, "Default:\t%s\n", isDefault) + fmt.Fprintf(writer, "State:\t%s\n", state) + fmt.Fprintf(writer, "Message:\t%s\n", message) + writer.Flush() +} + +const shortHelp = "Describe a network class" + +const longHelp = ` +Display detailed information about a network class, referenced by identifier or name. + +Examples: + +{{ bt 3 }}shell +# Describe a network class by identifier: +{{ binary }} describe networkclass 019e5ff0-6266-7310-acf3-94e99a3786c9 +{{ bt 3 }} + +{{ bt 3 }}shell +# Describe a network class by name: +{{ binary }} describe networkclass udn-net +{{ bt 3 }} +` diff --git a/internal/cmd/cli/describe/networkclass/describe_networkclass_suite_test.go b/internal/cmd/cli/describe/networkclass/describe_networkclass_suite_test.go new file mode 100644 index 000000000..fe0594367 --- /dev/null +++ b/internal/cmd/cli/describe/networkclass/describe_networkclass_suite_test.go @@ -0,0 +1,26 @@ +/* +Copyright (c) 2025 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 networkclass + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2/dsl/core" + . "github.com/onsi/gomega" +) + +func TestDescribeNetworkClass(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Describe NetworkClass Suite") +} diff --git a/internal/cmd/cli/describe/networkclass/describe_networkclass_test.go b/internal/cmd/cli/describe/networkclass/describe_networkclass_test.go new file mode 100644 index 000000000..ddcb8b97d --- /dev/null +++ b/internal/cmd/cli/describe/networkclass/describe_networkclass_test.go @@ -0,0 +1,155 @@ +/* +Copyright (c) 2025 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 networkclass + +import ( + "bytes" + + . "github.com/onsi/ginkgo/v2/dsl/core" + . "github.com/onsi/gomega" + + publicv1 "github.com/osac-project/fulfillment-service/internal/api/osac/public/v1" +) + +func formatNetworkClass(nc *publicv1.NetworkClass) string { + var buf bytes.Buffer + RenderNetworkClass(&buf, nc) + return buf.String() +} + +func boolPtr(v bool) *bool { + return &v +} + +var _ = Describe("Describe NetworkClass", func() { + Describe("Rendering tests", func() { + It("should display all fields when set", func() { + msg := "All backends healthy" + nc := publicv1.NetworkClass_builder{ + Id: "nc-001", + Metadata: publicv1.Metadata_builder{ + Name: "udn-net", + }.Build(), + Title: "UDN Network", + Description: "User-Defined Network backed by OVN-Kubernetes", + Capabilities: publicv1.NetworkClassCapabilities_builder{ + SupportsIpv4: true, + SupportsIpv6: true, + SupportsDualStack: true, + }.Build(), + Status: publicv1.NetworkClassStatus_builder{ + State: publicv1.NetworkClassState_NETWORK_CLASS_STATE_READY, + Message: &msg, + }.Build(), + IsDefault: boolPtr(true), + }.Build() + + output := formatNetworkClass(nc) + Expect(output).To(ContainSubstring("nc-001")) + Expect(output).To(ContainSubstring("udn-net")) + Expect(output).To(ContainSubstring("UDN Network")) + Expect(output).To(ContainSubstring("User-Defined Network backed by OVN-Kubernetes")) + Expect(output).To(MatchRegexp(`Supports IPv4:\s+yes`)) + Expect(output).To(MatchRegexp(`Supports IPv6:\s+yes`)) + Expect(output).To(MatchRegexp(`Supports Dual Stack:\s+yes`)) + Expect(output).To(MatchRegexp(`Default:\s+yes`)) + Expect(output).To(ContainSubstring("READY")) + Expect(output).To(ContainSubstring("All backends healthy")) + }) + + It("should show '-' for state and message when status is nil", func() { + nc := publicv1.NetworkClass_builder{ + Id: "nc-002", + }.Build() + + output := formatNetworkClass(nc) + Expect(output).To(MatchRegexp(`State:\s+-`)) + Expect(output).To(MatchRegexp(`Message:\s+-`)) + }) + + It("should show '-' for capabilities when not set", func() { + nc := publicv1.NetworkClass_builder{ + Id: "nc-003", + }.Build() + + output := formatNetworkClass(nc) + Expect(output).To(MatchRegexp(`Supports IPv4:\s+-`)) + Expect(output).To(MatchRegexp(`Supports IPv6:\s+-`)) + Expect(output).To(MatchRegexp(`Supports Dual Stack:\s+-`)) + }) + + It("should strip NETWORK_CLASS_STATE_ prefix from state", func() { + nc := publicv1.NetworkClass_builder{ + Id: "nc-004", + Status: publicv1.NetworkClassStatus_builder{ + State: publicv1.NetworkClassState_NETWORK_CLASS_STATE_READY, + }.Build(), + }.Build() + + output := formatNetworkClass(nc) + Expect(output).To(ContainSubstring("READY")) + Expect(output).NotTo(ContainSubstring("NETWORK_CLASS_STATE_")) + }) + + It("should show '-' for optional fields when not set", func() { + nc := publicv1.NetworkClass_builder{ + Id: "nc-005", + }.Build() + + output := formatNetworkClass(nc) + Expect(output).To(MatchRegexp(`Name:\s+-`)) + Expect(output).To(MatchRegexp(`Title:\s+-`)) + Expect(output).To(MatchRegexp(`Description:\s+-`)) + Expect(output).To(MatchRegexp(`Default:\s+-`)) + }) + + It("should show 'no' for unsupported capabilities", func() { + nc := publicv1.NetworkClass_builder{ + Id: "nc-006", + Capabilities: publicv1.NetworkClassCapabilities_builder{ + SupportsIpv4: true, + SupportsIpv6: false, + SupportsDualStack: false, + }.Build(), + }.Build() + + output := formatNetworkClass(nc) + Expect(output).To(MatchRegexp(`Supports IPv4:\s+yes`)) + Expect(output).To(MatchRegexp(`Supports IPv6:\s+no`)) + Expect(output).To(MatchRegexp(`Supports Dual Stack:\s+no`)) + }) + + It("should show 'no' for is_default when explicitly false", func() { + nc := publicv1.NetworkClass_builder{ + Id: "nc-007", + IsDefault: boolPtr(false), + }.Build() + + output := formatNetworkClass(nc) + Expect(output).To(MatchRegexp(`Default:\s+no`)) + }) + + It("should show '-' for message when status has no message", func() { + nc := publicv1.NetworkClass_builder{ + Id: "nc-008", + Status: publicv1.NetworkClassStatus_builder{ + State: publicv1.NetworkClassState_NETWORK_CLASS_STATE_PENDING, + }.Build(), + }.Build() + + output := formatNetworkClass(nc) + Expect(output).To(MatchRegexp(`Message:\s+-`)) + }) + }) +}) diff --git a/internal/rendering/tables/osac.public.v1.NetworkClass.yaml b/internal/rendering/tables/osac.public.v1.NetworkClass.yaml index 540ddd3c7..ab421e486 100644 --- a/internal/rendering/tables/osac.public.v1.NetworkClass.yaml +++ b/internal/rendering/tables/osac.public.v1.NetworkClass.yaml @@ -22,5 +22,17 @@ columns: - header: TITLE value: this.title +- header: DESCRIPTION + value: "this.description != ''? this.description: '-'" + +- header: IPV4 + value: "has(this.capabilities) && this.capabilities.supports_ipv4? 'yes': 'no'" + +- header: IPV6 + value: "has(this.capabilities) && this.capabilities.supports_ipv6? 'yes': 'no'" + +- header: DUAL STACK + value: "has(this.capabilities) && this.capabilities.supports_dual_stack? 'yes': 'no'" + - header: DEFAULT value: "has(this.is_default) && this.is_default? 'yes': '-'"