Repository: cloud-custodian/cloud-custodian

Issue:
aws - kinesisvideo tagging action
### Describe the bug

1) Looks like resourcegroupstagging is not supported for kinesis-video - We could fall back on the service boto3 apis for [tagging](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/kinesisvideo/client/tag_stream.html)
```
>>> response = client.tag_resources(ResourceARNList=['arn:aws:kinesisvideo:us-east-1:644160558196:stream/test/1680202478363'], Tags={'c7n': 'test'})
>>> response
{'FailedResourcesMap': {'arn:aws:kinesisvideo:us-east-1:644160558196:stream/test/1680202478363': {'StatusCode': 400, 'ErrorCode': 'InvalidParameterException', 'ErrorMessage': 'Unrecognized service or resource type for tagging'}}, 'ResponseMetadata': {'RequestId': '804b8286-4a5e-4bf4-8678-6b329bfd53ca', 'HTTPStatusCode': 200, 'HTTPHeaders': {'x-amzn-requestid': '804b8286-4a5e-4bf4-8678-6b329bfd53ca', 'content-type': 'application/x-amz-json-1.1', 'content-length': '222', 'date': 'Thu, 30 Mar 2023 19:13:22 GMT'}, 'RetryAttempts': 0}}
```

2) The retry function currently targets `LimitExceededException` instead of `ClientLimitExceededException`. We're getting throttled on ListStreams
```
botocore.errorfactory.ClientLimitExceededException: An error occurred (ClientLimitExceededException) when calling the ListStreams operation: Limit exceeded on number of requests for ListStreams API
```

### What did you expect to happen?

I expected 2 + 2 to equal 4, but instead 2 + 2 equaled 6!

### Cloud Provider

Amazon Web Services (AWS)

### Cloud Custodian version and dependency information

_No response_

### Policy

```shell
policies:
  - name: kinesisv2-filter
    resource: kinesis-video
    actions:
      - type: tag
        key: Owner
        value: c7n
```


### Relevant log/traceback output

```shell
2023-03-30 06:36:58,683 - custodian.actions - ERROR - Exception with tags: {'X': 'Y'}  Resource Tag Errors {'arn:aws:kinesisvideo:us-east-1:123456789123:stream/test/xyz': 'InvalidParameterException', 'arn:aws:kinesisvideo:us-east-1:123456789123:stream/test/xyz': 'InvalidParameterException', 'arn:aws:kinesisvideo:us-east-1:123456789123:stream/test/xyz'


botocore.errorfactory.ClientLimitExceededException: An error occurred (ClientLimitExceededException) when calling the ListStreams operation: Limit exceeded on number of requests for ListStreams API
```


### Extra information or context

_No response_


Discussion:

Resolving patch:
diff --git a/c7n/resources/kinesis.py b/c7n/resources/kinesis.py
--- a/c7n/resources/kinesis.py
+++ b/c7n/resources/kinesis.py
@@ -10,6 +10,8 @@
     DescribeWithResourceTags, QueryResourceManager, TypeInfo)
 from c7n.filters.vpc import SubnetFilter
 from c7n.utils import local_session, type_schema, get_retry
+from c7n.tags import (
+    TagDelayedAction, RemoveTag, TagActionFilter, Tag)
 
 
 
@@ -348,7 +350,7 @@ class KinesisAnalyticsSubnetFilter(SubnetFilter):
 class KinesisVideoStream(QueryResourceManager):
     retry = staticmethod(
         get_retry((
-            'LimitExceededException',)))
+            'ClientLimitExceededException',)))
 
     class resource_type(TypeInfo):
         service = 'kinesisvideo'
@@ -357,13 +359,14 @@ class resource_type(TypeInfo):
         name = id = 'StreamName'
         arn = 'StreamARN'
         dimension = 'StreamName'
-        universal_taggable = True
 
     source_mapping = {
         'describe': DescribeWithResourceTags,
         'config': ConfigSource
     }
 
+KinesisVideoStream.action_registry.register('mark-for-op', TagDelayedAction)
+KinesisVideoStream.filter_registry.register('marked-for-op', TagActionFilter)
 
 @KinesisVideoStream.action_registry.register('delete')
 class DeleteVideoStream(Action):
@@ -397,3 +400,60 @@ def process(self, resources):
 class KmsFilterVideoStream(KmsRelatedFilter):
 
     RelatedIdsExpression = 'KmsKeyId'
+
+@KinesisVideoStream.action_registry.register("tag")
+class TagVideoStream(Tag):
+    """Action to add tag/tags to Kinesis Video streams resource
+
+    :example:
+
+    .. code-block:: yaml
+
+            policies:
+              - name: kinesis-video-tag
+                resource: kinesis-video
+                filters:
+                  - "tag:KinesisVideoTag": absent
+                actions:
+                  - type: tag
+                    key: KinesisVideoTag
+                    value: "KinesisVideo Tag Value"
+    """
+    permissions = ('kinesisvideo:TagResource',)
+    
+    def process_resource_set(self, client, resource_set, tag_keys):
+        for r in resource_set:
+            self.manager.retry(
+                client.tag_resource, 
+                ResourceARN=r['StreamARN'], 
+                Tags=tag_keys, 
+                ignore_err_codes=("ResourceNotFoundException",))
+            
+@KinesisVideoStream.action_registry.register('remove-tag')
+class VideoStreamRemoveTag(RemoveTag):
+    """Action to remove tag/tags from a Kinesis Video streams resource
+
+    :example:
+
+    .. code-block:: yaml
+
+            policies:
+              - name: kinesisvideo-remove-tag
+                resource: kinesis-video
+                filters:
+                  - "tag:KinesisVideoTag": present
+                actions:
+                  - type: remove-tag
+                    tags: ["KinesisVideoTag"]
+    """
+    
+    permissions = ('kinesisvideo:UntagResource',)
+    
+    def process_resource_set(self, client, resource_set, tag_keys):
+        for r in resource_set:
+            self.manager.retry(
+                client.untag_resource, 
+                ResourceARN=r['StreamARN'], 
+                TagKeyList=tag_keys, 
+                ignore_err_codes=("ResourceNotFoundException",))
+


Test patch:
diff --git a/tests/data/placebo/test_kinesis_video_remove_tag/kinesisvideo.ListStreams_1.json b/tests/data/placebo/test_kinesis_video_remove_tag/kinesisvideo.ListStreams_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_kinesis_video_remove_tag/kinesisvideo.ListStreams_1.json
@@ -0,0 +1,26 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {},
+        "StreamInfoList": [
+            {
+                "StreamName": "test",
+                "StreamARN": "arn:aws:kinesisvideo:us-east-1:644160558196:stream/test/1680634460327",
+                "KmsKeyId": "arn:aws:kms:us-east-1:644160558196:alias/aws/kinesisvideo",
+                "Version": "hGLPguNGoP5RmFe3oxqP",
+                "Status": "ACTIVE",
+                "CreationTime": {
+                    "__class__": "datetime",
+                    "year": 2023,
+                    "month": 4,
+                    "day": 4,
+                    "hour": 14,
+                    "minute": 54,
+                    "second": 20,
+                    "microsecond": 327000
+                },
+                "DataRetentionInHours": 24
+            }
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_kinesis_video_remove_tag/kinesisvideo.ListTagsForResource_1.json b/tests/data/placebo/test_kinesis_video_remove_tag/kinesisvideo.ListTagsForResource_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_kinesis_video_remove_tag/kinesisvideo.ListTagsForResource_1.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {},
+        "Tags": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_kinesis_video_remove_tag/kinesisvideo.UntagResource_1.json b/tests/data/placebo/test_kinesis_video_remove_tag/kinesisvideo.UntagResource_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_kinesis_video_remove_tag/kinesisvideo.UntagResource_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_kinesis_video_remove_tag/tagging.GetResources_1.json b/tests/data/placebo/test_kinesis_video_remove_tag/tagging.GetResources_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_kinesis_video_remove_tag/tagging.GetResources_1.json
@@ -0,0 +1,18 @@
+{
+    "status_code": 200,
+    "data": {
+        "PaginationToken": "",
+        "ResourceTagMappingList": [
+            {
+                "ResourceARN": "arn:aws:kinesisvideo:us-east-1:644160558196:stream/test/1680634460327",
+                "Tags": [
+                    {
+                        "Key": "foo",
+                        "Value": "bar"
+                    }
+                ]
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_kinesis_video_tag/kinesisvideo.ListStreams_1.json b/tests/data/placebo/test_kinesis_video_tag/kinesisvideo.ListStreams_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_kinesis_video_tag/kinesisvideo.ListStreams_1.json
@@ -0,0 +1,26 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {},
+        "StreamInfoList": [
+            {
+                "StreamName": "test",
+                "StreamARN": "arn:aws:kinesisvideo:us-east-1:644160558196:stream/test/1680634460327",
+                "KmsKeyId": "arn:aws:kms:us-east-1:644160558196:alias/aws/kinesisvideo",
+                "Version": "hGLPguNGoP5RmFe3oxqP",
+                "Status": "ACTIVE",
+                "CreationTime": {
+                    "__class__": "datetime",
+                    "year": 2023,
+                    "month": 4,
+                    "day": 4,
+                    "hour": 14,
+                    "minute": 54,
+                    "second": 20,
+                    "microsecond": 327000
+                },
+                "DataRetentionInHours": 24
+            }
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_kinesis_video_tag/kinesisvideo.ListTagsForResource_1.json b/tests/data/placebo/test_kinesis_video_tag/kinesisvideo.ListTagsForResource_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_kinesis_video_tag/kinesisvideo.ListTagsForResource_1.json
@@ -0,0 +1,9 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {},
+        "Tags": {
+            "foo": "bar"
+        }
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_kinesis_video_tag/kinesisvideo.TagResource_1.json b/tests/data/placebo/test_kinesis_video_tag/kinesisvideo.TagResource_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_kinesis_video_tag/kinesisvideo.TagResource_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_kinesis_video_tag/tagging.GetResources_1.json b/tests/data/placebo/test_kinesis_video_tag/tagging.GetResources_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_kinesis_video_tag/tagging.GetResources_1.json
@@ -0,0 +1,13 @@
+{
+    "status_code": 200,
+    "data": {
+        "PaginationToken": "",
+        "ResourceTagMappingList": [
+            {
+                "ResourceARN": "arn:aws:kinesisvideo:us-east-1:644160558196:stream/test/1680634460327",
+                "Tags": []
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/test_kinesis.py b/tests/test_kinesis.py
--- a/tests/test_kinesis.py
+++ b/tests/test_kinesis.py
@@ -3,6 +3,7 @@
 from .common import BaseTest, event_data
 
 
+
 def test_stream_config_source(test):
     p = test.load_policy({
         'name': 'stream-config',
@@ -271,8 +272,59 @@ def test_kinesis_video_kms_key(self):
         self.assertEqual(len(resources), 1)
         self.assertEqual(resources[0]['KmsKeyId'],
             'arn:aws:kms:us-east-1:123456789012:key/0d543df5-915c-42a1-afa1-c9c5f1f97955')
+        
+    def test_kinesis_video_tag(self):
+        session_factory = self.replay_flight_data('test_kinesis_video_tag')
+        p = self.load_policy(
+            {
+                'name': 'test-kinesis-video-tag',
+                'resource': 'kinesis-video',
+                'filters': [
+                    {
+                        'tag:foo': 'absent', 
+                    }
+                ],
+                'actions': [
+                    {
+                        'type': 'tag',
+                        'tags': {'foo': 'bar'}
+                    }
+                ]
+            }, session_factory=session_factory
+        )
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        client = session_factory().client('kinesisvideo')
+        tags = client.list_tags_for_resource(ResourceARN=resources[0]["StreamARN"])["Tags"]
+        self.assertEqual(len(tags), 1)
+        self.assertEqual(tags, {'foo': 'bar'})    
 
 
+    def test_kinesis_video_remove_tag(self):
+        session_factory = self.replay_flight_data('test_kinesis_video_remove_tag')
+        p = self.load_policy(
+            {
+                'name': 'test-kinesis-video-remove-tag',
+                'resource': 'kinesis-video',
+                'filters': [
+                    {
+                        'tag:foo': 'present', 
+                    }
+                ],
+                'actions': [
+                    {
+                        'type': 'remove-tag',
+                        'tags': ['foo']
+                    }
+                ]
+            }, session_factory=session_factory
+        )
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        client = session_factory().client('kinesisvideo')
+        tags = client.list_tags_for_resource(ResourceARN=resources[0]['StreamARN'])['Tags']
+        self.assertEqual(len(tags), 0)
+
 class KinesisAnalyticsAppV2(BaseTest):
 
     def test_kinesis_analyticsv2_app_delete(self):