diff --git a/cmd/npv/app/helper.go b/cmd/npv/app/helper.go index e0025ac..dc53b57 100644 --- a/cmd/npv/app/helper.go +++ b/cmd/npv/app/helper.go @@ -45,48 +45,6 @@ func createK8sClients() (*kubernetes.Clientset, *dynamic.DynamicClient, error) { return clientset, dynamicClient, nil } -func createCiliumClient(ctx context.Context, clientset *kubernetes.Clientset, namespace, name string) (*client.Client, error) { - endpoint, err := getProxyEndpoint(ctx, clientset, namespace, name) - if err != nil { - return nil, err - } - - if cached, ok := cachedCiliumClients[endpoint]; ok { - return cached, nil - } - - ciliumClient, err := client.NewClient(endpoint) - if err != nil { - return nil, err - } - cachedCiliumClients[endpoint] = ciliumClient - - return ciliumClient, err -} - -func getProxyEndpoint(ctx context.Context, c *kubernetes.Clientset, namespace, name string) (string, error) { - targetPod, err := c.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return "", err - } - targetNode := targetPod.Spec.NodeName - - pods, err := c.CoreV1().Pods("kube-system").List(ctx, metav1.ListOptions{ - FieldSelector: "spec.nodeName=" + targetNode, - LabelSelector: rootOptions.proxySelector, - }) - if err != nil { - return "", err - } - if num := len(pods.Items); num != 1 { - err := fmt.Errorf("failed to find cilium-agent-proxy. found %d pods", num) - return "", err - } - - podIP := pods.Items[0].Status.PodIP - return fmt.Sprintf("http://%s:%d", podIP, rootOptions.proxyPort), nil -} - func getPodEndpointID(ctx context.Context, d *dynamic.DynamicClient, namespace, name string) (int64, error) { ep, err := d.Resource(gvrEndpoint).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { diff --git a/cmd/npv/app/helper_proxy.go b/cmd/npv/app/helper_proxy.go new file mode 100644 index 0000000..444412b --- /dev/null +++ b/cmd/npv/app/helper_proxy.go @@ -0,0 +1,123 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/cilium/cilium/pkg/client" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" +) + +func getProxyEndpoint(ctx context.Context, c *kubernetes.Clientset, namespace, name string) (string, error) { + targetPod, err := c.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", err + } + targetNode := targetPod.Spec.NodeName + + pods, err := c.CoreV1().Pods("kube-system").List(ctx, metav1.ListOptions{ + FieldSelector: "spec.nodeName=" + targetNode, + LabelSelector: rootOptions.proxySelector, + }) + if err != nil { + return "", err + } + if num := len(pods.Items); num != 1 { + err := fmt.Errorf("failed to find cilium-agent-proxy. found %d pods", num) + return "", err + } + + podIP := pods.Items[0].Status.PodIP + return fmt.Sprintf("http://%s:%d", podIP, rootOptions.proxyPort), nil +} + +func createCiliumClient(ctx context.Context, clientset *kubernetes.Clientset, namespace, name string) (*client.Client, error) { + endpoint, err := getProxyEndpoint(ctx, clientset, namespace, name) + if err != nil { + return nil, err + } + + if cached, ok := cachedCiliumClients[endpoint]; ok { + return cached, nil + } + + ciliumClient, err := client.NewClient(endpoint) + if err != nil { + return nil, err + } + cachedCiliumClients[endpoint] = ciliumClient + + return ciliumClient, err +} + +type policyEntryKey struct { + Identity int `json:"Identity"` + Direction int `json:"TrafficDirection"` + Protocol int `json:"Nexthdr"` + BigPort int `json:"DestPortNetwork"` // big endian +} + +func (p policyEntryKey) Port() int { + return ((p.BigPort & 0xFF) << 8) + ((p.BigPort & 0xFF00) >> 8) +} + +// For the meanings of the flags, see: +// https://github.com/cilium/cilium/blob/v1.16.3/bpf/lib/common.h#L394 +type policyEntry struct { + Flags int `json:"Flags"` + Packets int `json:"Packets"` + Bytes int `json:"Bytes"` + Key policyEntryKey `json:"Key"` +} + +func (p policyEntry) IsDenyRule() bool { + return (p.Flags & 1) > 0 +} + +func (p policyEntry) IsEgressRule() bool { + return p.Key.Direction > 0 +} + +func (p policyEntry) IsWildcardProtocol() bool { + return (p.Flags & 2) > 0 +} + +func (p policyEntry) IsWildcardPort() bool { + return (p.Flags & 4) > 0 +} + +func queryPolicyMap(ctx context.Context, clientset *kubernetes.Clientset, dynamicClient *dynamic.DynamicClient, namespace, name string) ([]policyEntry, error) { + endpointID, err := getPodEndpointID(ctx, dynamicClient, namespace, name) + if err != nil { + return nil, fmt.Errorf("failed to get pod endpoint ID: %w", err) + } + + url, err := getProxyEndpoint(ctx, clientset, namespace, name) + if err != nil { + return nil, fmt.Errorf("failed to get proxy endpoint: %w", err) + } + + url = fmt.Sprintf("%s/policy/%d", url, endpointID) + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to request policy: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + policies := make([]policyEntry, 0) + if err = json.Unmarshal(data, &policies); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return policies, nil +} diff --git a/cmd/npv/app/inspect.go b/cmd/npv/app/inspect.go index 1d8253f..a8caf52 100644 --- a/cmd/npv/app/inspect.go +++ b/cmd/npv/app/inspect.go @@ -2,10 +2,8 @@ package app import ( "context" - "encoding/json" "fmt" "io" - "net/http" "slices" "strconv" "strings" @@ -16,8 +14,6 @@ import ( "github.com/spf13/cobra" "golang.org/x/exp/rand" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" ) func init() { @@ -36,38 +32,6 @@ var inspectCmd = &cobra.Command{ ValidArgsFunction: completePods, } -type policyEntryKey struct { - Identity int `json:"Identity"` - Direction int `json:"TrafficDirection"` - Protocol int `json:"Nexthdr"` - BigPort int `json:"DestPortNetwork"` // big endian -} - -// For the meanings of the flags, see: -// https://github.com/cilium/cilium/blob/v1.16.3/bpf/lib/common.h#L394 -type policyEntry struct { - Flags int `json:"Flags"` - Packets int `json:"Packets"` - Bytes int `json:"Bytes"` - Key policyEntryKey `json:"Key"` -} - -func (p policyEntry) IsDenyRule() bool { - return (p.Flags & 1) > 0 -} - -func (p policyEntry) IsEgressRule() bool { - return p.Key.Direction > 0 -} - -func (p policyEntry) IsWildcardProtocol() bool { - return (p.Flags & 2) > 0 -} - -func (p policyEntry) IsWildcardPort() bool { - return (p.Flags & 4) > 0 -} - // This command aims to show the result of "cilium bpf policy get" from a remote pod. // https://github.com/cilium/cilium/blob/v1.16.3/cilium-dbg/cmd/bpf_policy_get.go type inspectEntry struct { @@ -84,37 +48,6 @@ type inspectEntry struct { Packets int `json:"packets"` } -func queryPolicyMap(ctx context.Context, clientset *kubernetes.Clientset, dynamicClient *dynamic.DynamicClient, namespace, name string) ([]policyEntry, error) { - endpointID, err := getPodEndpointID(ctx, dynamicClient, namespace, name) - if err != nil { - return nil, fmt.Errorf("failed to get pod endpoint ID: %w", err) - } - - url, err := getProxyEndpoint(ctx, clientset, namespace, name) - if err != nil { - return nil, fmt.Errorf("failed to get proxy endpoint: %w", err) - } - - url = fmt.Sprintf("%s/policy/%d", url, endpointID) - resp, err := http.Get(url) - if err != nil { - return nil, fmt.Errorf("failed to request policy: %w", err) - } - defer resp.Body.Close() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - policies := make([]policyEntry, 0) - if err = json.Unmarshal(data, &policies); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - return policies, nil -} - func runInspect(ctx context.Context, w io.Writer, name string) error { clientset, dynamicClient, err := createK8sClients() if err != nil { @@ -204,7 +137,7 @@ func runInspect(ctx context.Context, w io.Writer, name string) error { entry.WildcardProtocol = p.IsWildcardProtocol() entry.WildcardPort = p.IsWildcardPort() entry.Protocol = p.Key.Protocol - entry.Port = ((p.Key.BigPort & 0xFF) << 8) + ((p.Key.BigPort & 0xFF00) >> 8) + entry.Port = p.Key.Port() entry.Bytes = p.Bytes entry.Packets = p.Packets arr[i] = entry diff --git a/cmd/npv/app/reach.go b/cmd/npv/app/reach.go new file mode 100644 index 0000000..39a648d --- /dev/null +++ b/cmd/npv/app/reach.go @@ -0,0 +1,159 @@ +package app + +import ( + "context" + "errors" + "io" + "strconv" + + "github.com/cilium/cilium/pkg/u8proto" + "github.com/spf13/cobra" +) + +var reachOptions struct { + from string + to string +} + +func init() { + reachCmd.Flags().StringVar(&reachOptions.from, "from", "", "egress pod") + reachCmd.Flags().StringVar(&reachOptions.to, "to", "", "ingress pod") + reachCmd.RegisterFlagCompletionFunc("from", completeNamespacePods) + reachCmd.RegisterFlagCompletionFunc("to", completeNamespacePods) + rootCmd.AddCommand(reachCmd) +} + +var reachCmd = &cobra.Command{ + Use: "reach", + Short: "List traffic policies between pod pair", + Long: `List traffic policies between pod pair`, + + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + return runReach(context.Background(), cmd.OutOrStdout()) + }, +} + +type reachEntry struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Direction string `json:"direction"` + Policy string `json:"policy"` + Identity int `json:"identity"` + WildcardProtocol bool `json:"wildcard_protocol"` + WildcardPort bool `json:"wildcard_port"` + Protocol int `json:"protocol"` + Port int `json:"port"` + Bytes int `json:"bytes"` + Packets int `json:"packets"` +} + +func runReach(ctx context.Context, w io.Writer) error { + if reachOptions.from == "" || reachOptions.to == "" { + return errors.New("--from and --to options are required") + } + + from, err := parseNamespacedName(reachOptions.from) + if err != nil { + return errors.New("--from and --to should be specified as NAMESPACE/POD") + } + + to, err := parseNamespacedName(reachOptions.to) + if err != nil { + return errors.New("--from and --to should be specified as NAMESPACE/POD") + } + + clientset, dynamicClient, err := createK8sClients() + if err != nil { + return err + } + + fromIdentity, err := getPodIdentity(ctx, dynamicClient, from.Namespace, from.Name) + if err != nil { + return err + } + + toIdentity, err := getPodIdentity(ctx, dynamicClient, to.Namespace, to.Name) + if err != nil { + return err + } + + fromPolicies, err := queryPolicyMap(ctx, clientset, dynamicClient, from.Namespace, from.Name) + if err != nil { + return err + } + + toPolicies, err := queryPolicyMap(ctx, clientset, dynamicClient, to.Namespace, to.Name) + if err != nil { + return err + } + + arr := make([]reachEntry, 0) + for _, p := range fromPolicies { + if (p.Key.Identity != 0) && (p.Key.Identity != int(toIdentity)) { + continue + } + if !p.IsEgressRule() { + continue + } + var entry reachEntry + entry.Namespace = from.Namespace + entry.Name = from.Name + entry.Direction = directionEgress + if p.IsDenyRule() { + entry.Policy = policyDeny + } else { + entry.Policy = policyAllow + } + entry.Identity = p.Key.Identity + entry.WildcardProtocol = p.IsWildcardProtocol() + entry.WildcardPort = p.IsWildcardPort() + entry.Protocol = p.Key.Protocol + entry.Port = p.Key.Port() + entry.Bytes = p.Bytes + entry.Packets = p.Packets + arr = append(arr, entry) + } + for _, p := range toPolicies { + if (p.Key.Identity != 0) && (p.Key.Identity != int(fromIdentity)) { + continue + } + if p.IsEgressRule() { + continue + } + var entry reachEntry + entry.Namespace = to.Namespace + entry.Name = to.Name + entry.Direction = directionIngress + if p.IsDenyRule() { + entry.Policy = policyDeny + } else { + entry.Policy = policyAllow + } + entry.Identity = p.Key.Identity + entry.WildcardProtocol = p.IsWildcardProtocol() + entry.WildcardPort = p.IsWildcardPort() + entry.Protocol = p.Key.Protocol + entry.Port = p.Key.Port() + entry.Bytes = p.Bytes + entry.Packets = p.Packets + arr = append(arr, entry) + } + + header := []string{"NAMESPACE", "NAME", "DIRECTION", "POLICY", "IDENTITY", "PROTOCOL", "PORT", "BYTES", "PACKETS"} + return writeSimpleOrJson(w, arr, header, len(arr), func(index int) []any { + p := arr[index] + var protocol, port string + if p.WildcardProtocol { + protocol = "ANY" + } else { + protocol = u8proto.U8proto(p.Protocol).String() + } + if p.WildcardPort { + port = "ANY" + } else { + port = strconv.Itoa(p.Port) + } + return []any{p.Namespace, p.Name, p.Direction, p.Policy, p.Identity, protocol, port, p.Bytes, p.Packets} + }) +} diff --git a/e2e/Makefile b/e2e/Makefile index 8d92e56..3dffb79 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -63,6 +63,7 @@ install-test-pod: $(MAKE) --no-print-directory run-test-pod-l4-ingress-explicit-deny-udp $(MAKE) --no-print-directory run-test-pod-l4-egress-explicit-deny-any $(MAKE) --no-print-directory run-test-pod-l4-egress-explicit-deny-tcp + $(MAKE) --no-print-directory run-test-pod-l4-ingress-all-allow-tcp $(MAKE) --no-print-directory wait-for-workloads kubectl apply -f testdata/policy/l3.yaml diff --git a/e2e/id_test.go b/e2e/id_test.go index 340a7ea..bb729d9 100644 --- a/e2e/id_test.go +++ b/e2e/id_test.go @@ -21,6 +21,7 @@ func testIdLabel() { "l3-ingress-implicit-deny-all", "l4-egress-explicit-deny-any", "l4-egress-explicit-deny-tcp", + "l4-ingress-all-allow-tcp", "l4-ingress-explicit-allow-any", "l4-ingress-explicit-allow-tcp", "l4-ingress-explicit-deny-any", @@ -36,10 +37,12 @@ func testIdLabel() { } func testIdSummary() { - expected := `{"default":1,"kube-system":2,"local-path-storage":1,"test":12}` + // The number of CiliumIdentity is unstable for the test namespace, because cilium-agents on different nodes + // try creating multiple CiliumIdentities for the same set of security-relevant labels when nothing found. + expected := `{"default":1,"kube-system":2,"local-path-storage":1}` It("should show ID summary", func() { result := runViewerSafe(Default, nil, "id", "summary", "-o=json") - result = jqSafe(Default, result, "-c") + result = jqSafe(Default, result, "-c", "del(.test)") Expect(string(result)).To(Equal(expected), "compare failed.\nactual: %s\nexpected: %s", string(result), expected) }) } diff --git a/e2e/inspect_test.go b/e2e/inspect_test.go index 61c314f..da2b70b 100644 --- a/e2e/inspect_test.go +++ b/e2e/inspect_test.go @@ -92,6 +92,12 @@ Deny,Ingress,self,false,false,17,161`, Selector: "test=l4-egress-explicit-deny-tcp", Expected: `Allow,Ingress,reserved:host,true,true,0,0`, }, + { + Selector: "test=l4-ingress-all-allow-tcp", + Expected: `Allow,Ingress,reserved:host,false,false,6,8080 +Allow,Ingress,reserved:host,true,true,0,0 +Allow,Ingress,reserved:unknown,false,false,6,8080`, + }, } It("should inspect policy configuration", func() { diff --git a/e2e/list_test.go b/e2e/list_test.go index 591c558..ec15535 100644 --- a/e2e/list_test.go +++ b/e2e/list_test.go @@ -80,6 +80,12 @@ Ingress,CiliumClusterwideNetworkPolicy,-,l3-baseline`, Expected: `Egress,CiliumClusterwideNetworkPolicy,-,l3-baseline Ingress,CiliumClusterwideNetworkPolicy,-,l3-baseline`, }, + { + Selector: "test=l4-ingress-all-allow-tcp", + Expected: `Egress,CiliumClusterwideNetworkPolicy,-,l3-baseline +Ingress,CiliumClusterwideNetworkPolicy,-,l3-baseline +Ingress,CiliumNetworkPolicy,test,l4-ingress-all-allow-tcp`, + }, } It("should list applied policies", func() { diff --git a/e2e/reach_test.go b/e2e/reach_test.go new file mode 100644 index 0000000..3f326ac --- /dev/null +++ b/e2e/reach_test.go @@ -0,0 +1,96 @@ +package e2e + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func testReach() { + cases := []struct { + Selector string + Expected string + }{ + { + Selector: "test=l3-ingress-explicit-allow-all", + Expected: `test,l3-ingress-explicit-allow-all,Ingress,Allow,true,true,0,0 +test,self,Egress,Allow,true,true,0,0`, + }, + { + Selector: "test=l3-ingress-implicit-deny-all", + Expected: `test,self,Egress,Allow,true,true,0,0`, + }, + { + Selector: "test=l3-ingress-explicit-deny-all", + Expected: `test,l3-ingress-explicit-deny-all,Ingress,Deny,true,true,0,0 +test,self,Egress,Allow,true,true,0,0`, + }, + { + Selector: "test=l3-egress-implicit-deny-all", + Expected: ``, + }, + { + Selector: "test=l3-egress-explicit-deny-all", + Expected: `test,self,Egress,Deny,true,true,0,0`, + }, + { + Selector: "test=l4-ingress-explicit-allow-any", + Expected: `test,l4-ingress-explicit-allow-any,Ingress,Allow,false,false,6,53 +test,l4-ingress-explicit-allow-any,Ingress,Allow,false,false,17,53 +test,l4-ingress-explicit-allow-any,Ingress,Allow,false,false,132,53 +test,self,Egress,Allow,false,false,6,53 +test,self,Egress,Allow,false,false,17,53 +test,self,Egress,Allow,false,false,132,53`, + }, + { + Selector: "test=l4-ingress-explicit-allow-tcp", + Expected: `test,l4-ingress-explicit-allow-tcp,Ingress,Allow,false,false,6,8080 +test,self,Egress,Allow,false,false,6,8080`, + }, + { + Selector: "test=l4-ingress-explicit-deny-any", + Expected: `test,l4-ingress-explicit-deny-any,Ingress,Deny,false,false,6,53 +test,l4-ingress-explicit-deny-any,Ingress,Deny,false,false,17,53 +test,l4-ingress-explicit-deny-any,Ingress,Deny,false,false,132,53 +test,self,Egress,Allow,false,false,6,53 +test,self,Egress,Allow,false,false,17,53 +test,self,Egress,Allow,false,false,132,53`, + }, + { + Selector: "test=l4-ingress-explicit-deny-udp", + Expected: `test,l4-ingress-explicit-deny-udp,Ingress,Deny,false,false,17,161 +test,self,Egress,Allow,false,false,17,161`, + }, + { + Selector: "test=l4-egress-explicit-deny-any", + Expected: `test,self,Egress,Deny,false,false,6,53 +test,self,Egress,Deny,false,false,17,53 +test,self,Egress,Deny,false,false,132,53`, + }, + { + Selector: "test=l4-egress-explicit-deny-tcp", + Expected: `test,self,Egress,Deny,false,false,6,8080`, + }, + { + Selector: "test=l4-ingress-all-allow-tcp", + Expected: `test,l4-ingress-all-allow-tcp,Ingress,Allow,false,false,6,8080`, + }, + } + + It("should list traffic policy", func() { + for _, c := range cases { + fromOption := "--from=test/" + onePodByLabelSelector(Default, "test", "test=self") + toOption := "--to=test/" + onePodByLabelSelector(Default, "test", c.Selector) + + result := runViewerSafe(Default, nil, "reach", "-o=json", fromOption, toOption) + result = jqSafe(Default, result, "-r", `[.[] | .name = (.name | split("-") | .[0:5] | join("-"))]`) + result = jqSafe(Default, result, "-r", `[.[] | .name = (.name | if startswith("self") then "self" else . end)]`) + // "npv reach" returns a unstable result, so we need to sort it in test + result = jqSafe(Default, result, "-r", `sort_by(.namespace, .name, .direction, .policy, .wildcard_protocol, .wildcard_port, .protocol, .port)`) + result = jqSafe(Default, result, "-r", `.[] | [.namespace, .name, .direction, .policy, .wildcard_protocol, .wildcard_port, .protocol, .port] | @csv`) + resultString := strings.Replace(string(result), `"`, "", -1) + Expect(resultString).To(Equal(c.Expected), "compare failed. selector: %s\nactual: %s\nexpected: %s", c.Selector, resultString, c.Expected) + } + }) +} diff --git a/e2e/suite_test.go b/e2e/suite_test.go index aafaa33..bbb009d 100644 --- a/e2e/suite_test.go +++ b/e2e/suite_test.go @@ -32,4 +32,5 @@ func runTest() { Context("summary", testSummary) Context("manifest-generate", testManifestGenerate) Context("manifest-range", testManifestRange) + Context("reach", testReach) } diff --git a/e2e/summary_test.go b/e2e/summary_test.go index d3faf8d..a56027e 100644 --- a/e2e/summary_test.go +++ b/e2e/summary_test.go @@ -16,6 +16,7 @@ l3-ingress-explicit-deny-all,1,1,0,0 l3-ingress-implicit-deny-all,1,0,0,0 l4-egress-explicit-deny-any,1,0,0,0 l4-egress-explicit-deny-tcp,1,0,0,0 +l4-ingress-all-allow-tcp,3,0,0,0 l4-ingress-explicit-allow-any,4,0,0,0 l4-ingress-explicit-allow-tcp,2,0,0,0 l4-ingress-explicit-deny-any,1,3,0,0 diff --git a/e2e/testdata/policy/README.md b/e2e/testdata/policy/README.md index a14106c..58c065c 100644 --- a/e2e/testdata/policy/README.md +++ b/e2e/testdata/policy/README.md @@ -14,5 +14,6 @@ | l4-ingress-explicit-deny-udp | allow (L4) | deny (L4) | | l4-egress-explicit-deny-any | deny (L4) | - | | l4-egress-explicit-deny-tcp | deny (L4) | - | +| l4-ingress-all-allow-tcp | - | allow (L4-only) | | 8.8.8.8 (Google Public DNS) | allow (L4) | - | | 8.8.4.4 (Google Public DNS) | deny (L4) | - | diff --git a/e2e/testdata/policy/l4.yaml b/e2e/testdata/policy/l4.yaml index f1d3051..9fcd6af 100644 --- a/e2e/testdata/policy/l4.yaml +++ b/e2e/testdata/policy/l4.yaml @@ -128,3 +128,18 @@ spec: - ports: - port: "161" # SNMP (UDP) protocol: UDP +--- +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + namespace: test + name: l4-ingress-all-allow-tcp +spec: + endpointSelector: + matchLabels: + k8s:test: l4-ingress-all-allow-tcp + ingress: + - toPorts: + - ports: + - port: "8080" + protocol: TCP