scripts: Add metrics_atoms protoc plugin

Generate trusty-friendly log helpers to serialize
metrics atoms into a VendorAtom as defined by VendorAtom.aidl

Bug: 259511922
Change-Id: Id080ae4241a335c2fc060b155073181372c0b05a
diff --git a/scripts/metrics_atoms_protoc_plugin/metrics_atoms_protoc_debug.py b/scripts/metrics_atoms_protoc_plugin/metrics_atoms_protoc_debug.py
new file mode 100755
index 0000000..6617a7d
--- /dev/null
+++ b/scripts/metrics_atoms_protoc_plugin/metrics_atoms_protoc_debug.py
@@ -0,0 +1,38 @@
+#!/bin/sh
+# Copyright (C) 2023 The Android Open Source Project
+#
+# 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.
+#
+""":" # Shell script (in docstring to appease pylint)
+# Find and invoke hermetic python3 interpreter
+. "`dirname $0`"/../../../../../"trusty/vendor/google/aosp/scripts/envsetup.sh"
+exec "$PY3" "$0" "$@"
+# Shell script end
+
+"""
+
+import sys
+import metrics_atoms_protoc_plugin
+
+
+def main() -> None:
+    in_file = sys.argv[1]
+    pkg = sys.argv[2]
+    with open(in_file, "rb") as f_data:
+        data = f_data.read()
+
+    metrics_atoms_protoc_plugin.process_data(data, pkg)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/metrics_atoms_protoc_plugin/metrics_atoms_protoc_plugin.py b/scripts/metrics_atoms_protoc_plugin/metrics_atoms_protoc_plugin.py
new file mode 100755
index 0000000..42cdd92
--- /dev/null
+++ b/scripts/metrics_atoms_protoc_plugin/metrics_atoms_protoc_plugin.py
@@ -0,0 +1,404 @@
+#!/bin/sh
+# Copyright (C) 2022 The Android Open Source Project
+#
+# 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.
+#
+""":" # Shell script (in docstring to appease pylint)
+# Find and invoke hermetic python3 interpreter
+. "`dirname $0`"/../../../../../"trusty/vendor/google/aosp/scripts/envsetup.sh"
+exec "$PY3" "$0" "$@"
+# Shell script end
+
+Generate metrics stats functions for all messages defined in a .proto file
+
+Command line (per protoc requirements):
+    $ROOT_DIR/prebuilts/libprotobuf/bin/protoc \
+        --proto_path=$SCRIPT_DIR \
+        --plugin=metrics_atoms=metrics_atoms_protoc_plugin.py \
+        --metrics_atoms_out=out \
+        --metrics_atoms_opt=pkg:android/trusty/stats \
+        test/atoms.proto
+
+Important:
+* For debugging purposes: set option `dump-input` as show below:
+        --stats-log_opt=pkg:android/trusty/stats,dump-input:/tmp/dump.pb
+
+* with `debug` option, Invoke the protoc command line and observe
+  the creation of `/tmp/dump.pf`.
+
+* Then invoke the debugging script
+  `metrics_atoms_protoc_debug.py /tmp/dump.pb android/trusty/stats`,
+  and hook the debugger at your convenience.
+
+This is the easiest debugging approach.
+It still is possible to debug when protoc is invoking the plugin,
+it requires enabling remote debugging, which is slightly more tedious.
+
+"""
+
+import functools
+import os
+import re
+import sys
+from pathlib import Path
+from dataclasses import dataclass
+from enum import Enum
+from typing import Dict, List
+
+# mypy: disable-error-code="attr-defined,valid-type"
+from google.protobuf.compiler import plugin_pb2 as plugin
+from google.protobuf.descriptor_pb2 import FileDescriptorProto
+from google.protobuf.descriptor_pb2 import DescriptorProto
+from google.protobuf.descriptor_pb2 import FieldDescriptorProto
+from jinja2 import FileSystemLoader, Environment
+
+DEBUG_READ_PB = False
+
+SCRIPT_DIR = Path(__file__).parent
+ROOT_DIR = SCRIPT_DIR.parents[4]
+
+jinja_template_loader = FileSystemLoader(searchpath=SCRIPT_DIR / "templates")
+jinja_template_env = Environment(
+    loader=jinja_template_loader,
+    trim_blocks=True,
+    lstrip_blocks=True,
+    keep_trailing_newline=True,
+    line_comment_prefix="###",
+)
+
+
+def snake_case(s: str):
+    """Convert impl name from camelCase to snake_case"""
+    return re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower()
+
+
+class StatsLogGenerationError(Exception):
+    """"Error preventing the log API generation"""
+
+
+@dataclass
+class VendorAtomEnumValue:
+    name: str
+    value: int
+
+    @functools.cached_property
+    def name_len(self):
+        return len(self.name)
+
+
+@dataclass
+class VendorAtomEnum:
+    name: str
+    values: List[VendorAtomEnumValue]
+
+    @functools.cached_property
+    def values_name_len(self):
+        return max(len(v.name) for v in self.values)
+
+    @functools.cached_property
+    def c_name(self):
+        return f"stats_{snake_case(self.name)}"
+
+
+class VendorAtomValueTag(Enum):
+    intValue = 0
+    longValue = 1
+    floatValue = 2
+    stringValue = 3
+
+    @classmethod
+    def get_tag(cls, label: FieldDescriptorProto,
+                type_: FieldDescriptorProto,
+                type_name: str) -> 'VendorAtomValueTag':
+        if label == FieldDescriptorProto.LABEL_REPEATED:
+            raise StatsLogGenerationError(
+                f"repeated fields are not supported in Android"
+                f" please fix {type_name}({type_})")
+        if type_ in [
+                FieldDescriptorProto.TYPE_DOUBLE,
+                FieldDescriptorProto.TYPE_FLOAT
+        ]:
+            return VendorAtomValueTag.floatValue
+        if type_ in [
+                FieldDescriptorProto.TYPE_INT32,
+                FieldDescriptorProto.TYPE_SINT32,
+                FieldDescriptorProto.TYPE_UINT32,
+                FieldDescriptorProto.TYPE_FIXED32,
+                FieldDescriptorProto.TYPE_ENUM,
+        ]:
+            return VendorAtomValueTag.intValue
+        if type_ in [
+                FieldDescriptorProto.TYPE_INT64,
+                FieldDescriptorProto.TYPE_SINT64,
+                FieldDescriptorProto.TYPE_UINT64,
+                FieldDescriptorProto.TYPE_FIXED64
+        ]:
+            return VendorAtomValueTag.longValue
+        if type_ in [
+                FieldDescriptorProto.TYPE_BOOL,
+        ]:
+            raise StatsLogGenerationError(
+                f"boolean fields are not supported in Android"
+                f" please fix {type_name}({type_})")
+        if type_ in [
+                FieldDescriptorProto.TYPE_STRING,
+        ]:
+            return VendorAtomValueTag.stringValue
+        if type_ in [
+                FieldDescriptorProto.TYPE_BYTES,
+        ]:
+            raise StatsLogGenerationError(
+                f"byte[] fields are not supported in Android"
+                f" please fix {type_name}({type_})")
+        raise StatsLogGenerationError(
+            f"field type {type_name}({type_}) cannot be an atom field")
+
+
+@dataclass
+class VendorAtomValue:
+    name: str
+    tag: VendorAtomValueTag
+    enum: VendorAtomEnum
+    idx: int
+
+    @functools.cached_property
+    def c_name(self):
+        return snake_case(self.name)
+
+    @functools.cached_property
+    def is_string(self):
+        return self.tag in [
+            VendorAtomValueTag.stringValue,
+        ]
+
+    @functools.cached_property
+    def c_type(self):
+        if self.enum:
+            return f"enum {self.enum.c_name} "
+        match self.tag:
+            case VendorAtomValueTag.intValue:
+                return 'int32_t '
+            case VendorAtomValueTag.longValue:
+                return 'int64_t '
+            case VendorAtomValueTag.floatValue:
+                return 'float '
+            case VendorAtomValueTag.stringValue:
+                return 'const char *'
+            case _:
+                raise StatsLogGenerationError(f"unknown tag {self.tag}")
+
+    @functools.cached_property
+    def default_value(self):
+        if self.enum:
+            try:
+                default = [
+                    v.name
+                    for v in self.enum.values
+                    if v.name.lower().find("invalid") > -1 or
+                    v.name.lower().find("unknown") > -1
+                ][0]
+            except IndexError:
+                default = '0'
+            return default
+        match self.tag:
+            case VendorAtomValueTag.intValue:
+                return '0'
+            case VendorAtomValueTag.longValue:
+                return '0L'
+            case VendorAtomValueTag.floatValue:
+                return '0.'
+            case VendorAtomValueTag.stringValue:
+                return '"", 0UL'
+            case _:
+                raise StatsLogGenerationError(f"unknown tag {self.tag}")
+
+    @functools.cached_property
+    def stats_setter_name(self):
+        match self.tag:
+            case VendorAtomValueTag.intValue:
+                return 'set_int_value_at'
+            case VendorAtomValueTag.longValue:
+                return 'set_long_value_at'
+            case VendorAtomValueTag.floatValue:
+                return 'set_float_value_at'
+            case VendorAtomValueTag.stringValue:
+                return 'set_string_value_at'
+            case _:
+                raise StatsLogGenerationError(f"unknown tag {self.tag}")
+
+
+@dataclass
+class VendorAtom:
+    name: str
+    atom_id: int
+    values: List[VendorAtomValue]
+
+    @functools.cached_property
+    def c_name(self):
+        return snake_case(self.name)
+
+
+class VendorAtomEnv:
+    """Static class gathering all enums and atoms required for code generation
+    """
+    enums: Dict[str, VendorAtomEnum]
+    atoms: List[VendorAtom]
+
+    @classmethod
+    def len(cls, ll: List):
+        return len(ll)
+
+    @classmethod
+    def snake_case(cls, s: str):
+        return snake_case(s)
+
+
+def assert_reverse_domain_name_field(msg_dict: Dict[str, DescriptorProto],
+                                     atom: DescriptorProto):
+    """verify the assumption that reverse_domain_name is also an atom.field
+    of type FieldDescriptorProto.TYPE_MESSAGE which we can exclude
+    (see make_atom) from the VendorAtomValue list
+    """
+    reverse_domain_name_idx = [[
+        idx
+        for idx, ff in enumerate(msg_dict[f.type_name].field)
+        if ff.name == "reverse_domain_name"
+    ]
+                               for f in atom.field
+                               if f.type == FieldDescriptorProto.TYPE_MESSAGE]
+    for idx_list in reverse_domain_name_idx:
+        assert (len(idx_list) == 1 and idx_list[0] == 0)
+
+
+def get_enum(f: FieldDescriptorProto):
+    if f.type == FieldDescriptorProto.TYPE_ENUM:
+        return VendorAtomEnv.enums[f.type_name]
+    return None
+
+
+def make_atom(msg_dict: Dict[str, DescriptorProto],
+              field: FieldDescriptorProto):
+    """Each field in the Atom message, pointing to
+    a message, are atoms for which we need to generate the
+    stats_log function.
+    The `field.number` here is the atomId uniquely
+    identifying the VendorAtom
+    All fields except the reverse_domain_name field are
+    added as VendorAtomValue.
+    """
+    assert field.type == FieldDescriptorProto.TYPE_MESSAGE
+    return VendorAtom(msg_dict[field.type_name].name, field.number, [
+        VendorAtomValue(name=ff.name,
+                        tag=VendorAtomValueTag.get_tag(ff.label, ff.type,
+                                                       ff.type_name),
+                        enum=get_enum(ff),
+                        idx=idx - 1)
+        for idx, ff in enumerate(msg_dict[field.type_name].field)
+        if ff.name != "reverse_domain_name"
+    ])
+
+
+def process_file(proto_file: FileDescriptorProto,
+                 response: plugin.CodeGeneratorResponse,
+                 pkg: str = '') -> None:
+
+    def get_uri(type_name: str):
+        paths = [x for x in [proto_file.package, type_name] if len(x) > 0]
+        return f".{'.'.join(paths)}"
+
+    msg_list = list(proto_file.message_type)
+    msg_dict = {get_uri(msg.name): msg for msg in msg_list}
+
+    # Get Atom message and parse its atom fields
+    # recording the atomId in the process
+    try:
+        atom = [msg for msg in msg_list if msg.name == "Atom"][0]
+    except IndexError as e:
+        raise StatsLogGenerationError(
+            f"the Atom message is missing from {proto_file.name}") from e
+
+    VendorAtomEnv.enums = {
+        get_uri(e.name): VendorAtomEnum(name=e.name,
+                                        values=[
+                                            VendorAtomEnumValue(name=ee.name,
+                                                                value=ee.number)
+                                            for ee in e.value
+                                        ]) for e in proto_file.enum_type
+    }
+
+    assert_reverse_domain_name_field(msg_dict, atom)
+    VendorAtomEnv.atoms = [
+        make_atom(msg_dict, field)
+        for field in atom.field
+        if field.type == FieldDescriptorProto.TYPE_MESSAGE
+    ]
+    proto_name = Path(proto_file.name).stem
+    for item in [
+            dict(tpl="metrics_atoms.c.j2", ext='c'),
+            dict(tpl="metrics_atoms.h.j2", ext='h')
+    ]:
+        tm = jinja_template_env.get_template(item["tpl"])
+        tm_env = dict(env=VendorAtomEnv)
+        rendered = tm.render(**tm_env)
+        file = response.file.add()
+        file_path = pkg.split('/')
+        if item['ext'] == 'h':
+            file_path.insert(0, 'include')
+        file_path.append(f"{proto_name}.{item['ext']}")
+        file.name = os.path.join(*file_path)
+        file.content = rendered
+
+
+def process_data(data: bytes, pkg: str = '') -> None:
+    request = plugin.CodeGeneratorRequest()
+    request.ParseFromString(data)
+
+    dump_input_file = None
+    options = request.parameter.split(',') if request.parameter else []
+    for opt in options:
+        match opt.split(':'):
+            case ["pkg", value]:
+                pkg = value
+            case ["dump-input", value]:
+                dump_input_file = value
+            case [""]:
+                pass
+            case other:
+                raise ValueError(f"unknown parameter {other}")
+
+    if dump_input_file:
+        # store the pb file for easy debug
+        with open(dump_input_file, "wb") as f_data:
+            f_data.write(data)
+
+    # Create a response
+    response = plugin.CodeGeneratorResponse()
+
+    for proto_file in request.proto_file:
+        process_file(proto_file, response, pkg)
+
+    # Serialize response and write to stdout
+    output = response.SerializeToString()
+
+    # Write to stdout per the protoc plugin expectation
+    # (protoc consumes this output)
+    sys.stdout.buffer.write(output)
+
+
+def main() -> None:
+    data = sys.stdin.buffer.read()
+    process_data(data)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/metrics_atoms_protoc_plugin/templates/metrics_atoms.c.j2 b/scripts/metrics_atoms_protoc_plugin/templates/metrics_atoms.c.j2
new file mode 100644
index 0000000..cb18212
--- /dev/null
+++ b/scripts/metrics_atoms_protoc_plugin/templates/metrics_atoms.c.j2
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * 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.
+ */
+
+#define TLOG_TAG "atoms"
+
+#include <assert.h>
+#include <string.h>
+#include <trusty_log.h>
+#include <uapi/err.h>
+#include <lib/stats/stats.h>
+#include <android/frameworks/stats/atoms.h>
+
+
+{% for atom in env.atoms %}
+int stats_{{atom.c_name}}_report(const char* port_name, size_t port_name_len, struct stats_{{atom.c_name}} {{atom.c_name}}) {
+    struct stats_istats* istats = NULL;
+    struct stats_vendor_atom* vendor_atom = NULL;
+    int rc;
+    if ((rc = stats_vendor_atom_create_parcel(&vendor_atom)) != NO_ERROR) {
+      goto exit;
+    }
+    assert(vendor_atom);
+    if ((rc = stats_vendor_atom_set_atom_id(vendor_atom, {{atom.atom_id}})) != NO_ERROR) {
+      goto exit;
+    }
+    if ((rc = stats_vendor_atom_set_reverse_domain_name(vendor_atom,
+        {{atom.c_name}}.reverse_domain_name,
+        {{atom.c_name}}.reverse_domain_name_len)) != NO_ERROR) {
+        goto exit;
+    }
+{% for value in atom.values %}
+    if ((rc = stats_vendor_atom_{{value.stats_setter_name}}(vendor_atom, {{value.idx}}, {{atom.c_name}}.{{value.c_name}}
+{%- if value.is_string -%}
+, {{atom.c_name}}.{{value.c_name}}_len
+{%- endif -%}
+)) != NO_ERROR) {
+        goto exit;
+    }
+{% endfor %}
+    /* report vendor atom */
+    if ((rc = stats_istats_get_service(port_name, port_name_len, &istats)) != NO_ERROR) {
+        goto exit;
+    }
+    rc = stats_istats_report_vendor_atom(istats, vendor_atom);
+exit:
+    if (vendor_atom) {
+        stats_vendor_atom_release(&vendor_atom);
+    }
+    if (istats) {
+        stats_istats_release(&istats);
+    }
+    return rc;
+}
+{{''}}
+{{''}}
+{% endfor %}
diff --git a/scripts/metrics_atoms_protoc_plugin/templates/metrics_atoms.h.j2 b/scripts/metrics_atoms_protoc_plugin/templates/metrics_atoms.h.j2
new file mode 100644
index 0000000..d141506
--- /dev/null
+++ b/scripts/metrics_atoms_protoc_plugin/templates/metrics_atoms.h.j2
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <stddef.h>
+#include <stdint.h>
+#include <string.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+{% for enum in env.enums.values() %}
+enum {{enum.c_name}} {
+{% for value in enum.values %}
+    {{value.name}}{{" "*(enum.values_name_len-value.name_len+1)}}= {{value.value}},
+{% endfor %}
+};
+{{''}}
+{{''}}
+{%- endfor %}
+
+{% for atom in env.atoms %}
+struct stats_{{atom.c_name}} {
+    const char* reverse_domain_name;
+    size_t reverse_domain_name_len;
+{% for value in atom.values %}
+    {{value.c_type}}{{value.c_name}};
+{% if value.is_string %}
+    size_t {{value.c_name}}_len;
+{% endif %}
+{% endfor %}
+};
+{{''}}
+{% endfor %}
+
+{% for atom in env.atoms %}
+int stats_{{atom.c_name}}_report(const char* port_name, size_t port_name_len, struct stats_{{atom.c_name}} {{atom.c_name}});
+{{''}}
+{% endfor %}
+
+#ifdef __cplusplus
+}  // extern "C"
+#endif