Skip to content

Commit 3c476eb

Browse files
spiffyy99zeck-ops
authored andcommitted
[processor/k8sattributes] Support name:tag@digest image name format (open-telemetry#36145)
<!--Ex. Fixing a bug - Describe the bug and how this fixes the issue. Ex. Adding a feature - Explain what this achieves.--> #### Description Fixed issue with `k8sattributesprocessor` where digest is not properly separated from tag if both are present. used official docker library to perform parsing. <!-- Issue number (e.g. open-telemetry#1234) or full URL to issue, if applicable. --> #### Link to tracking issue Fixes open-telemetry#36131 <!--Describe what testing was performed and which tests were added.--> #### Testing unit tests integration/e2e tests <!--Describe the documentation added.--> #### Documentation N/A. Fields are already described correctly, this is simply fixing parsing logic <!--Please delete paragraphs that you did not use before submitting.-->
1 parent e104aae commit 3c476eb

File tree

11 files changed

+140
-44
lines changed

11 files changed

+140
-44
lines changed

.chloggen/fix-k8s-image-parsing.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: bug_fix
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver)
7+
component: processor/k8sattribute
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: fixes parsing of k8s image names to support images with tags and digests.
11+
12+
# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
13+
issues: [36131]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext:
19+
20+
# If your change doesn't affect end users or the exported elements of any package,
21+
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
22+
# Optional: The change log or logs in which this entry should be included.
23+
# e.g. '[user]' or '[user, api]'
24+
# Include 'user' if the change is relevant to end users.
25+
# Include 'api' if there is a change to a library API.
26+
# Default: '[user]'
27+
change_logs: [user]

processor/k8sattributesprocessor/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ spec:
170170
- --duration=10s
171171
- --rate=1
172172
- --otlp-attributes=k8s.container.name="telemetrygen"
173-
image: ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest
173+
image: ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:0.112.0@sha256:b248ef911f93ae27cbbc85056d1ffacc87fd941bbdc2ffd951b6df8df72b8096
174174
name: telemetrygen
175175
status:
176176
podIP: 10.244.0.11
@@ -193,7 +193,8 @@ the processor associates the received trace to the pod, based on the connection
193193
"k8s.pod.name": "telemetrygen-pod",
194194
"k8s.pod.uid": "038e2267-b473-489b-b48c-46bafdb852eb",
195195
"container.image.name": "telemetrygen",
196-
"container.image.tag": "latest"
196+
"container.image.tag": "0.112.0",
197+
"container.image.repo_digests": ["ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:b248ef911f93ae27cbbc85056d1ffacc87fd941bbdc2ffd951b6df8df72b8096"]
197198
}
198199
}
199200
```

processor/k8sattributesprocessor/documentation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
| container.id | Container ID. Usually a UUID, as for example used to identify Docker containers. The UUID might be abbreviated. Requires k8s.container.restart_count. | Any Str | false |
1010
| container.image.name | Name of the image the container was built on. Requires container.id or k8s.container.name. | Any Str | true |
1111
| container.image.repo_digests | Repo digests of the container image as provided by the container runtime. | Any Slice | false |
12-
| container.image.tag | Container image tag. Requires container.id or k8s.container.name. | Any Str | true |
12+
| container.image.tag | Container image tag. Defaults to "latest" if not provided (unless digest also in image path) Requires container.id or k8s.container.name. | Any Str | true |
1313
| k8s.cluster.uid | Gives cluster uid identified with kube-system namespace | Any Str | false |
1414
| k8s.container.name | The name of the Container in a Pod template. Requires container.id. | Any Str | false |
1515
| k8s.cronjob.name | The name of the CronJob. | Any Str | false |

processor/k8sattributesprocessor/e2e_test.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func newExpectedValue(mode int, value string) *expectedValue {
5454

5555
// TestE2E_ClusterRBAC tests the k8s attributes processor in a k8s cluster with the collector's service account having
5656
// cluster-wide permissions to list/watch namespaces, nodes, pods and replicasets. The config in the test does not
57-
// set filter::namespace.
57+
// set filter::namespace, and the telemetrygen image has a latest tag but no digest.
5858
// The test requires a prebuilt otelcontribcol image uploaded to a kind k8s cluster defined in
5959
// `/tmp/kube-config-otelcol-e2e-testing`. Run the following command prior to running the test locally:
6060
//
@@ -540,7 +540,8 @@ func TestE2E_ClusterRBAC(t *testing.T) {
540540
}
541541
}
542542

543-
// Test with `filter::namespace` set and only role binding to collector's SA. We can't get node and namespace labels/annotations.
543+
// Test with `filter::namespace` set and only role binding to collector's SA. We can't get node and namespace labels/annotations,
544+
// and the telemetrygen image has a digest but no tag.
544545
func TestE2E_NamespacedRBAC(t *testing.T) {
545546
testDir := filepath.Join("testdata", "e2e", "namespacedrbac")
546547

@@ -615,7 +616,7 @@ func TestE2E_NamespacedRBAC(t *testing.T) {
615616
"k8s.container.name": newExpectedValue(equal, "telemetrygen"),
616617
"container.image.name": newExpectedValue(equal, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen"),
617618
"container.image.repo_digests": newExpectedValue(regex, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:[0-9a-fA-f]{64}"),
618-
"container.image.tag": newExpectedValue(equal, "latest"),
619+
"container.image.tag": newExpectedValue(shouldnotexist, ""),
619620
"container.id": newExpectedValue(exist, ""),
620621
},
621622
},
@@ -639,7 +640,7 @@ func TestE2E_NamespacedRBAC(t *testing.T) {
639640
"k8s.container.name": newExpectedValue(equal, "telemetrygen"),
640641
"container.image.name": newExpectedValue(equal, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen"),
641642
"container.image.repo_digests": newExpectedValue(regex, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:[0-9a-fA-f]{64}"),
642-
"container.image.tag": newExpectedValue(equal, "latest"),
643+
"container.image.tag": newExpectedValue(shouldnotexist, ""),
643644
"container.id": newExpectedValue(exist, ""),
644645
},
645646
},
@@ -663,7 +664,7 @@ func TestE2E_NamespacedRBAC(t *testing.T) {
663664
"k8s.container.name": newExpectedValue(equal, "telemetrygen"),
664665
"container.image.name": newExpectedValue(equal, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen"),
665666
"container.image.repo_digests": newExpectedValue(regex, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:[0-9a-fA-f]{64}"),
666-
"container.image.tag": newExpectedValue(equal, "latest"),
667+
"container.image.tag": newExpectedValue(shouldnotexist, ""),
667668
"container.id": newExpectedValue(exist, ""),
668669
},
669670
},
@@ -712,7 +713,7 @@ func TestE2E_NamespacedRBAC(t *testing.T) {
712713
}
713714

714715
// Test with `filter::namespace` set, role binding for namespace-scoped objects (pod, replicaset) and clusterrole
715-
// binding for node and namespace objects.
716+
// binding for node and namespace objects, and the telemetrygen image has a tag and digest.
716717
func TestE2E_MixRBAC(t *testing.T) {
717718
testDir := filepath.Join("testdata", "e2e", "mixrbac")
718719

@@ -802,7 +803,7 @@ func TestE2E_MixRBAC(t *testing.T) {
802803
"k8s.container.name": newExpectedValue(equal, "telemetrygen"),
803804
"container.image.name": newExpectedValue(equal, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen"),
804805
"container.image.repo_digests": newExpectedValue(regex, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:[0-9a-fA-f]{64}"),
805-
"container.image.tag": newExpectedValue(equal, "latest"),
806+
"container.image.tag": newExpectedValue(equal, "0.112.0"),
806807
"container.id": newExpectedValue(exist, ""),
807808
"k8s.namespace.labels.foons": newExpectedValue(equal, "barns"),
808809
"k8s.node.labels.foo": newExpectedValue(equal, "too"),
@@ -829,7 +830,7 @@ func TestE2E_MixRBAC(t *testing.T) {
829830
"k8s.container.name": newExpectedValue(equal, "telemetrygen"),
830831
"container.image.name": newExpectedValue(equal, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen"),
831832
"container.image.repo_digests": newExpectedValue(regex, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:[0-9a-fA-f]{64}"),
832-
"container.image.tag": newExpectedValue(equal, "latest"),
833+
"container.image.tag": newExpectedValue(equal, "0.112.0"),
833834
"container.id": newExpectedValue(exist, ""),
834835
"k8s.namespace.labels.foons": newExpectedValue(equal, "barns"),
835836
"k8s.node.labels.foo": newExpectedValue(equal, "too"),
@@ -856,7 +857,7 @@ func TestE2E_MixRBAC(t *testing.T) {
856857
"k8s.container.name": newExpectedValue(equal, "telemetrygen"),
857858
"container.image.name": newExpectedValue(equal, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen"),
858859
"container.image.repo_digests": newExpectedValue(regex, "ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:[0-9a-fA-f]{64}"),
859-
"container.image.tag": newExpectedValue(equal, "latest"),
860+
"container.image.tag": newExpectedValue(equal, "0.112.0"),
860861
"container.id": newExpectedValue(exist, ""),
861862
"k8s.namespace.labels.foons": newExpectedValue(equal, "barns"),
862863
"k8s.node.labels.foo": newExpectedValue(equal, "too"),
@@ -914,7 +915,8 @@ func TestE2E_MixRBAC(t *testing.T) {
914915
// While `k8s.pod.ip` is not set in `k8sattributes:extract:metadata` and the `pod_association` is not `connection`
915916
// we expect that the `k8s.pod.ip` metadata is not added.
916917
// While `container.image.repo_digests` is not set in `k8sattributes::extract::metadata`, we expect
917-
// that the `container.image.repo_digests` metadata is not added
918+
// that the `container.image.repo_digests` metadata is not added.
919+
// The telemetrygen image has neither a tag nor digest (implicitly latest version)
918920
func TestE2E_NamespacedRBACNoPodIP(t *testing.T) {
919921
testDir := filepath.Join("testdata", "e2e", "namespaced_rbac_no_pod_ip")
920922

processor/k8sattributesprocessor/internal/kube/client.go

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,30 @@ func removeUnnecessaryPodData(pod *api_v1.Pod, rules ExtractionRules) *api_v1.Po
666666
return &transformedPod
667667
}
668668

669+
// parseNameAndTagFromImage parses the image name and tag for differently-formatted image names.
670+
// returns "latest" as the default if tag not present. also checks if the image contains a digest.
671+
// if it does, no latest tag is assumed.
672+
func parseNameAndTagFromImage(image string) (name, tag string, err error) {
673+
ref, err := reference.Parse(image)
674+
if err != nil {
675+
return
676+
}
677+
namedRef, ok := ref.(reference.Named)
678+
if !ok {
679+
return "", "", errors.New("cannot retrieve image name")
680+
}
681+
name = namedRef.Name()
682+
if taggedRef, ok := namedRef.(reference.Tagged); ok {
683+
tag = taggedRef.Tag()
684+
}
685+
if tag == "" {
686+
if digestedRef, ok := namedRef.(reference.Digested); !ok || digestedRef.String() == "" {
687+
tag = "latest"
688+
}
689+
}
690+
return
691+
}
692+
669693
func (c *WatchClient) extractPodContainersAttributes(pod *api_v1.Pod) PodContainers {
670694
containers := PodContainers{
671695
ByID: map[string]*Container{},
@@ -677,16 +701,14 @@ func (c *WatchClient) extractPodContainersAttributes(pod *api_v1.Pod) PodContain
677701
if c.Rules.ContainerImageName || c.Rules.ContainerImageTag {
678702
for _, spec := range append(pod.Spec.Containers, pod.Spec.InitContainers...) {
679703
container := &Container{}
680-
nameTagSep := strings.LastIndex(spec.Image, ":")
681-
if c.Rules.ContainerImageName {
682-
if nameTagSep > 0 {
683-
container.ImageName = spec.Image[:nameTagSep]
684-
} else {
685-
container.ImageName = spec.Image
704+
name, tag, err := parseNameAndTagFromImage(spec.Image)
705+
if err == nil {
706+
if c.Rules.ContainerImageName {
707+
container.ImageName = name
708+
}
709+
if c.Rules.ContainerImageTag {
710+
container.ImageTag = tag
686711
}
687-
}
688-
if c.Rules.ContainerImageTag && nameTagSep > 0 {
689-
container.ImageTag = spec.Image[nameTagSep+1:]
690712
}
691713
containers.ByName[spec.Name] = container
692714
}

processor/k8sattributesprocessor/internal/kube/client_test.go

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,17 +1495,21 @@ func Test_extractPodContainersAttributes(t *testing.T) {
14951495
Containers: []api_v1.Container{
14961496
{
14971497
Name: "container1",
1498-
Image: "test/image1:0.1.0",
1498+
Image: "example.com:5000/test/image1:0.1.0",
14991499
},
15001500
{
15011501
Name: "container2",
1502-
Image: "example.com:port1/image2:0.2.0",
1502+
Image: "example.com:81/image2@sha256:430ac608abaa332de4ce45d68534447c7a206edc5e98aaff9923ecc12f8a80d9",
1503+
},
1504+
{
1505+
Name: "container3",
1506+
Image: "example-website.com/image3:1.0@sha256:4b0b1b6f6cdd3e5b9e55f74a1e8d19ed93a3f5a04c6b6c3c57c4e6d19f6b7c4d",
15031507
},
15041508
},
15051509
InitContainers: []api_v1.Container{
15061510
{
15071511
Name: "init_container",
1508-
Image: "test/init-image:1.0.2",
1512+
Image: "test/init-image",
15091513
},
15101514
},
15111515
},
@@ -1520,7 +1524,13 @@ func Test_extractPodContainersAttributes(t *testing.T) {
15201524
{
15211525
Name: "container2",
15221526
ContainerID: "docker://container2-id-456",
1523-
ImageID: "sha256:430ac608abaa332de4ce45d68534447c7a206edc5e98aaff9923ecc12f8a80d9",
1527+
ImageID: "sha256:4b0b1b6f6cdd3e5b9e55f74a1e8d19ed93a3f5a04c6b6c3c57c4e6d19f6b7c4d",
1528+
RestartCount: 2,
1529+
},
1530+
{
1531+
Name: "container3",
1532+
ContainerID: "docker://container3-id-abc",
1533+
ImageID: "docker.io/otel/collector:2.0.0@sha256:430ac608abaa332de4ce45d68534447c7a206edc5e98aaff9923ecc12f8a80d9",
15241534
RestartCount: 2,
15251535
},
15261536
},
@@ -1564,13 +1574,15 @@ func Test_extractPodContainersAttributes(t *testing.T) {
15641574
pod: &pod,
15651575
want: PodContainers{
15661576
ByID: map[string]*Container{
1567-
"container1-id-123": {ImageName: "test/image1"},
1568-
"container2-id-456": {ImageName: "example.com:port1/image2"},
1577+
"container1-id-123": {ImageName: "example.com:5000/test/image1"},
1578+
"container2-id-456": {ImageName: "example.com:81/image2"},
1579+
"container3-id-abc": {ImageName: "example-website.com/image3"},
15691580
"init-container-id-789": {ImageName: "test/init-image"},
15701581
},
15711582
ByName: map[string]*Container{
1572-
"container1": {ImageName: "test/image1"},
1573-
"container2": {ImageName: "example.com:port1/image2"},
1583+
"container1": {ImageName: "example.com:5000/test/image1"},
1584+
"container2": {ImageName: "example.com:81/image2"},
1585+
"container3": {ImageName: "example-website.com/image3"},
15741586
"init_container": {ImageName: "test/init-image"},
15751587
},
15761588
},
@@ -1615,6 +1627,11 @@ func Test_extractPodContainersAttributes(t *testing.T) {
16151627
2: {ContainerID: "container2-id-456"},
16161628
},
16171629
},
1630+
"container3-id-abc": {
1631+
Statuses: map[int]ContainerStatus{
1632+
2: {ContainerID: "container3-id-abc"},
1633+
},
1634+
},
16181635
"init-container-id-789": {
16191636
Statuses: map[int]ContainerStatus{
16201637
0: {ContainerID: "init-container-id-789"},
@@ -1632,6 +1649,11 @@ func Test_extractPodContainersAttributes(t *testing.T) {
16321649
2: {ContainerID: "container2-id-456"},
16331650
},
16341651
},
1652+
"container3": {
1653+
Statuses: map[int]ContainerStatus{
1654+
2: {ContainerID: "container3-id-abc"},
1655+
},
1656+
},
16351657
"init_container": {
16361658
Statuses: map[int]ContainerStatus{
16371659
0: {ContainerID: "init-container-id-789"},
@@ -1658,6 +1680,11 @@ func Test_extractPodContainersAttributes(t *testing.T) {
16581680
2: {},
16591681
},
16601682
},
1683+
"container3-id-abc": {
1684+
Statuses: map[int]ContainerStatus{
1685+
2: {ImageRepoDigest: "docker.io/otel/collector:2.0.0@sha256:430ac608abaa332de4ce45d68534447c7a206edc5e98aaff9923ecc12f8a80d9"},
1686+
},
1687+
},
16611688
"init-container-id-789": {
16621689
Statuses: map[int]ContainerStatus{
16631690
0: {ImageRepoDigest: "ghcr.io/initimage1@sha256:42e8ba40f9f70d604684c3a2a0ed321206b7e2e3509fdb2c8836d34f2edfb57b"},
@@ -1675,6 +1702,11 @@ func Test_extractPodContainersAttributes(t *testing.T) {
16751702
2: {},
16761703
},
16771704
},
1705+
"container3": {
1706+
Statuses: map[int]ContainerStatus{
1707+
2: {ImageRepoDigest: "docker.io/otel/collector:2.0.0@sha256:430ac608abaa332de4ce45d68534447c7a206edc5e98aaff9923ecc12f8a80d9"},
1708+
},
1709+
},
16781710
"init_container": {
16791711
Statuses: map[int]ContainerStatus{
16801712
0: {ImageRepoDigest: "ghcr.io/initimage1@sha256:42e8ba40f9f70d604684c3a2a0ed321206b7e2e3509fdb2c8836d34f2edfb57b"},
@@ -1695,45 +1727,57 @@ func Test_extractPodContainersAttributes(t *testing.T) {
16951727
want: PodContainers{
16961728
ByID: map[string]*Container{
16971729
"container1-id-123": {
1698-
ImageName: "test/image1",
1730+
ImageName: "example.com:5000/test/image1",
16991731
ImageTag: "0.1.0",
17001732
Statuses: map[int]ContainerStatus{
17011733
0: {ContainerID: "container1-id-123", ImageRepoDigest: "docker.io/otel/collector@sha256:55d008bc28344c3178645d40e7d07df30f9d90abe4b53c3fc4e5e9c0295533da"},
17021734
},
17031735
},
17041736
"container2-id-456": {
1705-
ImageName: "example.com:port1/image2",
1706-
ImageTag: "0.2.0",
1737+
ImageName: "example.com:81/image2",
17071738
Statuses: map[int]ContainerStatus{
17081739
2: {ContainerID: "container2-id-456"},
17091740
},
17101741
},
1742+
"container3-id-abc": {
1743+
ImageName: "example-website.com/image3",
1744+
ImageTag: "1.0",
1745+
Statuses: map[int]ContainerStatus{
1746+
2: {ContainerID: "container3-id-abc", ImageRepoDigest: "docker.io/otel/collector:2.0.0@sha256:430ac608abaa332de4ce45d68534447c7a206edc5e98aaff9923ecc12f8a80d9"},
1747+
},
1748+
},
17111749
"init-container-id-789": {
17121750
ImageName: "test/init-image",
1713-
ImageTag: "1.0.2",
1751+
ImageTag: "latest",
17141752
Statuses: map[int]ContainerStatus{
17151753
0: {ContainerID: "init-container-id-789", ImageRepoDigest: "ghcr.io/initimage1@sha256:42e8ba40f9f70d604684c3a2a0ed321206b7e2e3509fdb2c8836d34f2edfb57b"},
17161754
},
17171755
},
17181756
},
17191757
ByName: map[string]*Container{
17201758
"container1": {
1721-
ImageName: "test/image1",
1759+
ImageName: "example.com:5000/test/image1",
17221760
ImageTag: "0.1.0",
17231761
Statuses: map[int]ContainerStatus{
17241762
0: {ContainerID: "container1-id-123", ImageRepoDigest: "docker.io/otel/collector@sha256:55d008bc28344c3178645d40e7d07df30f9d90abe4b53c3fc4e5e9c0295533da"},
17251763
},
17261764
},
17271765
"container2": {
1728-
ImageName: "example.com:port1/image2",
1729-
ImageTag: "0.2.0",
1766+
ImageName: "example.com:81/image2",
17301767
Statuses: map[int]ContainerStatus{
17311768
2: {ContainerID: "container2-id-456"},
17321769
},
17331770
},
1771+
"container3": {
1772+
ImageName: "example-website.com/image3",
1773+
ImageTag: "1.0",
1774+
Statuses: map[int]ContainerStatus{
1775+
2: {ContainerID: "container3-id-abc", ImageRepoDigest: "docker.io/otel/collector:2.0.0@sha256:430ac608abaa332de4ce45d68534447c7a206edc5e98aaff9923ecc12f8a80d9"},
1776+
},
1777+
},
17341778
"init_container": {
17351779
ImageName: "test/init-image",
1736-
ImageTag: "1.0.2",
1780+
ImageTag: "latest",
17371781
Statuses: map[int]ContainerStatus{
17381782
0: {ContainerID: "init-container-id-789", ImageRepoDigest: "ghcr.io/initimage1@sha256:42e8ba40f9f70d604684c3a2a0ed321206b7e2e3509fdb2c8836d34f2edfb57b"},
17391783
},

processor/k8sattributesprocessor/metadata.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ resource_attributes:
108108
type: slice
109109
enabled: false
110110
container.image.tag:
111-
description: Container image tag. Requires container.id or k8s.container.name.
111+
description: Container image tag. Defaults to "latest" if not provided (unless digest also in image path) Requires container.id or k8s.container.name.
112112
type: string
113113
enabled: true
114114

0 commit comments

Comments
 (0)