You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
882 lines
33 KiB
882 lines
33 KiB
# coding=utf-8
|
|
#
|
|
# Copyright © Splunk, Inc. All Rights Reserved.
|
|
|
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
|
|
|
from builtins import object
|
|
from collections import Mapping # pylint: disable=no-name-in-module
|
|
|
|
import io
|
|
import json
|
|
|
|
from io import StringIO
|
|
|
|
from os import path
|
|
import os
|
|
|
|
import re
|
|
import sys
|
|
|
|
from semantic_version import Version
|
|
|
|
from . _internal import ObjectView
|
|
from . _internal import JsonSchema
|
|
from . _internal import JsonField, JsonValue
|
|
from . _internal import JsonArray, JsonObject, JsonString, JsonBoolean
|
|
from . _internal import JsonDataTypeConverter, JsonFilenameConverter, JsonVersionConverter, JsonVersionSpecConverter
|
|
|
|
from .. utils import SlimLogger, encode_filename, encode_string, slim_configuration, string, typing
|
|
from .. utils.public import SlimTargetOS, SlimTargetOSWildcard
|
|
|
|
if typing is not None:
|
|
TextIO = typing.TextIO
|
|
|
|
|
|
class AppCommonInformationModelInfo(object):
|
|
|
|
def __init__(self, iterable):
|
|
for name, versions in iterable:
|
|
setattr(self, name, tuple(versions))
|
|
|
|
# We include this pylint directive because file is recognized as a built-in even though the standard Python library
|
|
# and we commonly use file as an argument name
|
|
# pylint: disable=redefined-builtin
|
|
@classmethod
|
|
def load(cls, file=None):
|
|
if file is None:
|
|
file = path.join(slim_configuration.system_config, 'common-information-models.json')
|
|
if isinstance(file, string):
|
|
with io.open(file, encoding='utf-8') as filep:
|
|
return cls._load(filep)
|
|
return cls._load(file)
|
|
|
|
# region Protected
|
|
|
|
@classmethod
|
|
def _load(cls, istream):
|
|
return cls.schema.convert_from(json.load(istream), onerror=SlimLogger.error)
|
|
|
|
# endregion
|
|
|
|
class Converter(JsonDataTypeConverter):
|
|
|
|
def convert_from(self, data_type, value):
|
|
assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)
|
|
return AppCommonInformationModelInfo((name, value[name]) for name in value)
|
|
|
|
def convert_to(self, data_type, value):
|
|
raise NotImplementedError()
|
|
|
|
schema = JsonSchema('Common information model info', JsonValue(JsonObject(
|
|
any=JsonValue(
|
|
JsonArray(
|
|
JsonValue(JsonString(), converter=JsonVersionConverter()))
|
|
)
|
|
), converter=Converter()))
|
|
|
|
|
|
class AppCommonInformationModelSpec(ObjectView):
|
|
|
|
def __str__(self):
|
|
return ', '.join((name + string(self[name]) for name in self))
|
|
|
|
class Converter(JsonDataTypeConverter):
|
|
|
|
def __init__(self):
|
|
if self._info is None:
|
|
self._info = AppCommonInformationModelInfo.load()
|
|
|
|
def convert_from(self, data_type, value):
|
|
assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)
|
|
|
|
def convert(name, version_spec):
|
|
try:
|
|
versions = getattr(self._info, name)
|
|
except AttributeError:
|
|
raise ValueError('Expected a common information model name, not ' + encode_string(name))
|
|
if version_spec is None:
|
|
return name, None
|
|
for version in versions:
|
|
if version in version_spec:
|
|
return name, version_spec
|
|
raise ValueError(
|
|
'Version requirement includes no supported version of Splunk ' + name + ': ' + string(version_spec))
|
|
|
|
return AppCommonInformationModelInfo((convert(name, value[name]) for name in value))
|
|
|
|
def convert_to(self, data_type, value):
|
|
raise NotImplementedError()
|
|
|
|
_info = None
|
|
|
|
|
|
class AppDependency(ObjectView):
|
|
|
|
class Converter(JsonDataTypeConverter):
|
|
|
|
def convert_from(self, data_type, value):
|
|
assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)
|
|
return AppDependency(((name, value[name]) for name in value))
|
|
|
|
def convert_to(self, data_type, value):
|
|
raise NotImplementedError()
|
|
|
|
|
|
class AppDependencyPackageConverter(JsonDataTypeConverter):
|
|
|
|
def convert_from(self, data_type, value):
|
|
assert isinstance(data_type, JsonString) and isinstance(value, string) # pylint: disable=unidiomatic-typecheck
|
|
filename = os.path.basename(value)
|
|
if value != filename:
|
|
raise ValueError('Expected a filename, not a path: ' + encode_string(value))
|
|
return value
|
|
|
|
def convert_to(self, data_type, value):
|
|
raise NotImplementedError()
|
|
|
|
|
|
class AppTargetWorkloadsConverter(JsonDataTypeConverter):
|
|
|
|
def convert_from(self, data_type, value):
|
|
assert isinstance(data_type, JsonString) and isinstance(value, string) # pylint: disable=unidiomatic-typecheck
|
|
if value not in self.targets:
|
|
raise ValueError('Expected a Splunk deployment target, not ' + encode_string(value))
|
|
return value
|
|
|
|
def convert_to(self, data_type, value):
|
|
assert isinstance(data_type, JsonString) and isinstance(value, string) # pylint: disable=unidiomatic-typecheck
|
|
return str(value)
|
|
|
|
schema_version_spec = '>=2.0.0' # supportedDeployments added in version 2.0.0
|
|
targets = ['*', '_search_heads', '_indexers', '_forwarders']
|
|
|
|
|
|
class AppInputGroup(ObjectView):
|
|
|
|
def __init__(self, iterable, onerror=None):
|
|
|
|
ObjectView.__init__(self, iterable, onerror)
|
|
|
|
if self['description'] is None:
|
|
self['description'] = ''
|
|
|
|
requires = self['requires']
|
|
|
|
if requires is None:
|
|
self['requires'] = {}
|
|
else:
|
|
for name in requires:
|
|
value = requires[name]
|
|
requires[name] = tuple() if value is None else tuple(value)
|
|
|
|
inputs = self['inputs']
|
|
self['inputs'] = tuple() if inputs is None else tuple(inputs)
|
|
|
|
class Converter(JsonDataTypeConverter):
|
|
|
|
def convert_from(self, data_type, value):
|
|
assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)
|
|
return AppInputGroup(((name, value[name]) for name in value))
|
|
|
|
def convert_to(self, data_type, value):
|
|
raise NotImplementedError()
|
|
|
|
|
|
class AppInputGroupsSpec(ObjectView):
|
|
class Converter(JsonDataTypeConverter):
|
|
def convert_from(self, data_type, value):
|
|
assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)
|
|
|
|
def check(name, value):
|
|
if value is None:
|
|
raise ValueError("Expected input group " + name + " to be defined")
|
|
if not value.inputs and not value.requires:
|
|
raise ValueError("Expected input group " + name + " to have inputs or dependencies defined")
|
|
return name, value
|
|
|
|
return AppInputGroupsSpec((check(name, value[name]) for name in value))
|
|
|
|
def convert_to(self, data_type, value):
|
|
raise NotImplementedError()
|
|
|
|
|
|
class AppSplunkReleaseInfo(object):
|
|
|
|
def __init__(self, iterable):
|
|
for name, versions in iterable:
|
|
setattr(self, name, tuple(versions))
|
|
|
|
# pylint: disable=redefined-builtin
|
|
@classmethod
|
|
def load(cls, file=None):
|
|
if file is None:
|
|
file = path.join(slim_configuration.system_config, 'splunk-releases.json')
|
|
if isinstance(file, string):
|
|
with io.open(file, encoding='utf-8') as istream:
|
|
return cls._load(istream)
|
|
return cls._load(file)
|
|
|
|
# region Protected
|
|
|
|
# We include this pylint directive because file is recognized as a built-in even though the standard Python library
|
|
# and we commonly use file as an argument name
|
|
# pylint: disable=redefined-builtin
|
|
@classmethod
|
|
def _load(cls, file):
|
|
return cls.schema.convert_from(json.load(file), onerror=SlimLogger.error)
|
|
|
|
# endregion
|
|
|
|
class Converter(JsonDataTypeConverter):
|
|
|
|
def convert_from(self, data_type, value):
|
|
assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)
|
|
return AppSplunkReleaseInfo(((name, value[name]) for name in value))
|
|
|
|
def convert_to(self, data_type, value):
|
|
raise NotImplementedError()
|
|
|
|
schema = JsonSchema('Splunk release info', JsonValue(
|
|
JsonObject(
|
|
any=JsonValue(
|
|
JsonArray(JsonValue(JsonString(), converter=JsonVersionConverter()))
|
|
)
|
|
), converter=Converter()
|
|
))
|
|
|
|
|
|
class AppSplunkRequirement(ObjectView):
|
|
|
|
def __str__(self):
|
|
return ', '.join(('Splunk ' + edition + ' edition ' + string(self[edition]) for edition in self))
|
|
|
|
class Converter(JsonDataTypeConverter):
|
|
|
|
def __init__(self):
|
|
if self._info is None:
|
|
self._info = AppSplunkReleaseInfo.load()
|
|
|
|
def convert_from(self, data_type, value):
|
|
assert isinstance(data_type, JsonObject) and isinstance(value, Mapping)
|
|
|
|
def convert(name, version_spec):
|
|
try:
|
|
versions = getattr(self._info, name)
|
|
except AttributeError:
|
|
raise ValueError('Expected a Splunk edition name, not ' + encode_string(name))
|
|
if version_spec is None:
|
|
return name, None
|
|
for version in versions:
|
|
if version in version_spec:
|
|
return name, version_spec
|
|
raise ValueError(
|
|
'Version requirement includes no supported version of Splunk ' + name + ': ' + string(version_spec))
|
|
|
|
return AppSplunkRequirement((convert(name, value[name]) for name in value))
|
|
|
|
def convert_to(self, data_type, value):
|
|
raise NotImplementedError()
|
|
|
|
_info = None
|
|
|
|
|
|
class AppDeploymentConverter(JsonDataTypeConverter):
|
|
|
|
def convert_from(self, data_type, value):
|
|
assert isinstance(data_type, JsonString) and isinstance(value, string) # pylint: disable=unidiomatic-typecheck
|
|
if value not in self.deployments:
|
|
raise ValueError('Expected a Splunk deployment type, not ' + encode_string(value))
|
|
return value
|
|
|
|
def convert_to(self, data_type, value):
|
|
assert isinstance(data_type, JsonString) and isinstance(value, string) # pylint: disable=unidiomatic-typecheck
|
|
return str(value)
|
|
|
|
schema_version_spec = '>=2.0.0' # supportedDeployments added in version 2.0.0
|
|
default_deployment = ['_standalone', '_distributed']
|
|
deployments = ['*', '_standalone', '_distributed', '_search_head_clustering']
|
|
|
|
|
|
class AppOSConverter(JsonDataTypeConverter):
|
|
|
|
def convert_from(self, data_type, value):
|
|
assert isinstance(data_type, JsonString) and isinstance(value, string) # pylint: disable=unidiomatic-typecheck
|
|
if value not in self.os_values:
|
|
raise ValueError(
|
|
'Expected an OS type, not %s. Valid types are: %s, or %s.' % (
|
|
encode_string(value),
|
|
', '.join(SlimTargetOS[:-1]),
|
|
SlimTargetOS[-1]))
|
|
return value
|
|
|
|
def convert_to(self, data_type, value):
|
|
assert isinstance(data_type, JsonString) and isinstance(value, string) # pylint: disable=unidiomatic-typecheck
|
|
return str(value)
|
|
|
|
schema_version_spec = '>=2.0.0' # targetOS added in version 2.0.0
|
|
|
|
default_os = [SlimTargetOSWildcard]
|
|
os_values = SlimTargetOS
|
|
|
|
|
|
class AppManifest(ObjectView):
|
|
|
|
@property
|
|
def loaded(self):
|
|
return self._loaded
|
|
|
|
@loaded.setter
|
|
def loaded(self, value):
|
|
self._loaded = value
|
|
|
|
_loaded = False
|
|
|
|
# region Methods
|
|
|
|
# pylint disable=redefined-builtin
|
|
def amend(self, app_configuration):
|
|
""" Replaces select fields in the current app manifest with information from app.conf.
|
|
|
|
"""
|
|
# noinspection PyUnresolvedReferences
|
|
# pylint: disable=no-member
|
|
info = self.info
|
|
|
|
info.author = self._get_author(app_configuration)
|
|
info.id = self._get_id(app_configuration)
|
|
info.description = self._get_description(app_configuration)
|
|
info.title = self._get_title(app_configuration)
|
|
|
|
def ensure_documentation(name, asset):
|
|
element = info[name]
|
|
if element is None:
|
|
info[name] = ObjectView((
|
|
('name', None), ('text', self._get_text(app_configuration, asset)), ('uri', None)
|
|
))
|
|
elif element.text is None:
|
|
element.text = self._get_text(app_configuration, asset)
|
|
|
|
ensure_documentation('license', 'LICENSE')
|
|
ensure_documentation('releaseNotes', 'README')
|
|
ensure_documentation('privacyPolicy', 'privacy-policy')
|
|
|
|
# pylint: disable=too-many-locals,too-many-branches,too-many-statements
|
|
def print_description(self, ostream):
|
|
|
|
# type: (TextIO) -> None
|
|
|
|
""" Writes a description of the manifest in a pretty form
|
|
|
|
Does not do dependency checking. The caller should not expect any errors from this function.
|
|
|
|
Example:
|
|
[info]
|
|
|-- SLIM fictional test app: A SLIM app for testing Splunk extension packaging, partitioning, and operations.
|
|
| |-- by David Noble (dnoble@splunk.com) at Splunk, Inc.
|
|
| |-- packaged as com.splunk.addons-fictional@1.0.0
|
|
[dependencies]
|
|
|-- Splunk Add-on for Microsoft Windows packaged as com.splunk.addon-microsoft_windows@4.7.5
|
|
|-- Splunk Add-on for *nix Operating Systems packaged as com.splunk.addon-star_nix@5.2.1
|
|
[tasks]
|
|
[input-groups]
|
|
|-- Microsoft Windows monitoring defines inputs [input_1, input_2, input_3] and requires no dependencies
|
|
|-- *nix monitoring defines inputs [input_4, input_5] and requires [Splunk Add-on for *nix Operating Systems]
|
|
[incompatible-apps]
|
|
[platform-requirements]
|
|
|
|
"""
|
|
|
|
_ostream = StringIO()
|
|
# The original author probably meant for ostream to only be on py2 with TextIO (see their comments)
|
|
# Nowadays tho, all kinds of different io.* readers get in here as seen by regression
|
|
# testing. In the py2/3 transition, some readers that accepted 'str' now want bytes only.
|
|
# At the end of this function is handling the local _ostream and turning it into what ever makes
|
|
# the callee's ostream.write happy
|
|
|
|
# Add the manifest info/dependency/forwarderGroup sections to the payload
|
|
|
|
info = self.get('info')
|
|
dependencies = self.get('dependencies')
|
|
tasks = self.get('tasks')
|
|
input_groups = self.get('inputGroups')
|
|
incompatible_apps = self.get('incompatibleApps')
|
|
platform_requirements = self.get('platformRequirements')
|
|
|
|
# Define helper functions for formatting manifest details into a readable format
|
|
|
|
def get_title(info):
|
|
info_title = info.title if info.title else info.id.name
|
|
info_title += (': ' + info.description) if 'description' in info and info.description else ''
|
|
return info_title
|
|
|
|
def get_author(author):
|
|
info_author = author.name
|
|
info_author += (' (' + author.email + ')') if 'email' in author and author.email else ''
|
|
info_author += (' at ' + author.company) if 'company' in author and author.company else ''
|
|
return info_author
|
|
|
|
def get_definition(app_id):
|
|
info_package = app_id.name + ' version ' + string(app_id.version)
|
|
if 'group' in app_id and app_id.group:
|
|
info_package = app_id.group + '-' + info_package
|
|
return info_package
|
|
|
|
def get_dependency(app_id, info):
|
|
dependency_info = app_id + (' optionally' if info.optional else '') + ' accepting ' + string(info.version)
|
|
if info.targetOS != AppOSConverter.default_os:
|
|
if len(info.targetOS) == 1:
|
|
os_str = info.targetOS[0]
|
|
else:
|
|
os_str = '[' + ', '.join((os for os in info.targetOS)) + ']'
|
|
dependency_info += ' on ' + os_str
|
|
dependency_info += ' (packaged as ' + info.package + ')' if 'package' in info and info.package else ''
|
|
return dependency_info
|
|
|
|
def get_inputs(group, info):
|
|
text = group
|
|
|
|
if info.inputs:
|
|
text += (' defines inputs [' + ', '.join((name for name in info.inputs))) + ']'
|
|
else:
|
|
text += ' defines no inputs'
|
|
|
|
if info.requires:
|
|
text += ' and requires [' + ', '.join((name for name in info.requires)) + ']'
|
|
else:
|
|
text += ' and requires no dependencies'
|
|
|
|
return text
|
|
|
|
_ostream.write('[info]\n')
|
|
if info is not None:
|
|
_ostream.write('|-- ' + get_title(info) + '\n')
|
|
if 'author' in info and info.author:
|
|
for author in info.author:
|
|
_ostream.write('| |-- by ' + get_author(author) + '\n')
|
|
_ostream.write('| |-- defined as ' + get_definition(info.id) + '\n')
|
|
|
|
_ostream.write('[dependencies]\n')
|
|
# print('[dependencies]', file=_ostream)
|
|
|
|
if dependencies is not None:
|
|
for app_id, info in list(dependencies.items()):
|
|
_ostream.write('|-- ' + get_dependency(app_id, info) + '\n')
|
|
|
|
_ostream.write('[tasks]\n')
|
|
if tasks is not None:
|
|
for task in tasks:
|
|
s = '|-- ' + task + '\n' # pylint: disable=invalid-name
|
|
_ostream.write(s)
|
|
|
|
_ostream.write('[input-groups]\n')
|
|
if input_groups is not None:
|
|
for group, info in list(input_groups.items()):
|
|
_ostream.write('|-- ' + get_inputs(group, info) + '\n')
|
|
|
|
_ostream.write('[incompatible-apps]\n')
|
|
if incompatible_apps is not None:
|
|
for app_id in incompatible_apps:
|
|
s = '|-- ' + app_id + ' version range ' + str(incompatible_apps[app_id]) + '\n' # nopep8, pylint: disable=invalid-name
|
|
_ostream.write(s)
|
|
|
|
_ostream.write('[platform-requirements]\n')
|
|
if platform_requirements is not None:
|
|
_ostream.write('|-- ' + str(platform_requirements.splunk) + '\n')
|
|
|
|
try:
|
|
ostream.write(_ostream.getvalue())
|
|
except:
|
|
ostream.write(_ostream.getvalue().encode())
|
|
|
|
@classmethod
|
|
def generate(cls, app_configuration, ostream, add_defaults=True):
|
|
""" Generate an AppManifest object from the AppConfiguration object.
|
|
|
|
Resulting manifest is saved to the ostream. Caller is required to check for logged errors on return.
|
|
|
|
"""
|
|
app_root = app_configuration.app_root
|
|
|
|
manifest_tuple = (
|
|
('schemaVersion', cls._schema_version),
|
|
('info', ObjectView((
|
|
('title', cls._get_title(app_configuration)),
|
|
('id', cls._get_id(app_configuration)),
|
|
('author', cls._get_author(app_configuration)),
|
|
('releaseDate', None),
|
|
('description', cls._get_description(app_configuration)),
|
|
('classification', ObjectView((
|
|
('intendedAudience', None),
|
|
('categories', []),
|
|
('developmentStatus', None)
|
|
))),
|
|
('commonInformationModels', None),
|
|
('license', ObjectView((
|
|
('name', None),
|
|
('text', cls._get_text(app_configuration, 'LICENSE')),
|
|
('uri', None),
|
|
))),
|
|
('privacyPolicy', ObjectView((
|
|
('name', None),
|
|
('text', cls._get_text(app_configuration, 'privacy-policy')),
|
|
('uri', None),
|
|
))),
|
|
('releaseNotes', ObjectView((
|
|
('name', None),
|
|
('text', cls._get_text(app_configuration, 'README')),
|
|
('uri', None)
|
|
)))
|
|
)))
|
|
)
|
|
|
|
manifest_defaults = """
|
|
# The following sections can be customized and added to the manifest. For detailed information,
|
|
# see the documentation at http://dev.splunk.com/view/packaging-toolkit/SP-CAAAE9V
|
|
#
|
|
# Lists the app dependencies and version requirements
|
|
# "dependencies": {
|
|
# "<app-group>:<app-name>": {
|
|
# "version": "*",
|
|
# "package": "<source-package-name>",
|
|
# "optional": [true|false]
|
|
# }
|
|
# }
|
|
#
|
|
# Lists the inputs that belong on the search head rather than forwarders
|
|
# "tasks": []
|
|
#
|
|
# Lists the possible input groups with app dependencies, and inputs that should be included
|
|
# "inputGroups": {
|
|
# "<group-name>": {
|
|
# "requires": {
|
|
# "<app-group>:<app-name>": ["<dependent-input-groups>"]
|
|
# },
|
|
# "inputs": ["<defined-inputs>"]
|
|
# }
|
|
# }
|
|
#
|
|
# Lists the app IDs that cannot be installed on the system alongside this app
|
|
# "incompatibleApps": {
|
|
# "<app-group>:<app-name>": "<version>"
|
|
# }
|
|
#
|
|
# Specify the platform version requirements for this app
|
|
# "platformRequirements": {
|
|
# "splunk": {
|
|
# "Enterprise": "<version>"
|
|
# }
|
|
# }
|
|
#
|
|
# Lists the supported deployment types this app can be installed on
|
|
# "supportedDeployments": ["*" | "_standalone" | "_distributed" | "_search_head_clustering"]
|
|
#
|
|
# Lists the targets where app can be installed to
|
|
# "targetWorkloads": ["*" | "_search_heads" | "_indexers" | "_forwarders"]
|
|
#
|
|
"""
|
|
# Construct the manifest
|
|
|
|
current_directory = os.getcwd()
|
|
os.chdir(app_root)
|
|
|
|
try:
|
|
manifest = AppManifest(manifest_tuple)
|
|
finally:
|
|
os.chdir(current_directory)
|
|
|
|
# Optionally save the manifest
|
|
|
|
if ostream is not None:
|
|
if not SlimLogger.error_count():
|
|
with ostream:
|
|
if ostream != sys.stdout:
|
|
ostream.truncate(0) # truncate the existing manifest file
|
|
manifest.save(ostream, indent=True)
|
|
if add_defaults:
|
|
ostream.write(manifest_defaults)
|
|
elif ostream != sys.stdout:
|
|
ostream.close()
|
|
|
|
return manifest
|
|
|
|
# pylint: disable=redefined-builtin
|
|
# noinspection PyProtectedMember
|
|
@classmethod
|
|
def load(cls, file):
|
|
if isinstance(file, string):
|
|
with io.open(file, encoding='utf-8') as istream:
|
|
app_manifest = cls._load(istream)
|
|
else:
|
|
app_manifest = cls._load(file)
|
|
return app_manifest
|
|
|
|
# noinspection PyProtectedMember
|
|
@classmethod
|
|
def schema_version(cls):
|
|
""" Return the schema version being used to handle AppManifest objects. """
|
|
return cls._schema_version
|
|
|
|
# endregion
|
|
|
|
# region Protected
|
|
|
|
_schema_version = '2.0.0'
|
|
_schema_version_spec = '>=1.0.0'
|
|
|
|
schema = JsonSchema('manifest', JsonValue(required=True, data_type=JsonObject(
|
|
JsonField('schemaVersion', JsonString(), converter=JsonVersionConverter(_schema_version_spec), required=True),
|
|
JsonField('info', required=True, data_type=JsonObject(
|
|
JsonField('title', JsonString()),
|
|
JsonField('id', JsonObject(
|
|
JsonField('group', JsonString()),
|
|
JsonField('name', JsonString(), required=True),
|
|
JsonField('version', JsonString(), converter=JsonVersionConverter())
|
|
), required=True),
|
|
JsonField('author', JsonArray(JsonValue(JsonObject(
|
|
JsonField('name', JsonString(), required=True),
|
|
JsonField('email', JsonString()),
|
|
JsonField('company', JsonString()),
|
|
)))),
|
|
JsonField('releaseDate', JsonString()), # TODO: date converter
|
|
JsonField('description', JsonString()),
|
|
JsonField('classification', JsonObject( # TODO: classification class and class converter
|
|
JsonField('intendedAudience', JsonString()),
|
|
JsonField('categories', JsonArray(JsonValue(JsonString()))),
|
|
JsonField('developmentStatus', JsonString())
|
|
)),
|
|
JsonField('commonInformationModels', JsonObject(any=JsonValue(
|
|
JsonString(), converter=JsonVersionSpecConverter()
|
|
), converter=AppCommonInformationModelSpec.Converter())),
|
|
JsonField('license', JsonObject(
|
|
JsonField('name', JsonString()),
|
|
JsonField('text', JsonString(), converter=JsonFilenameConverter()),
|
|
JsonField('uri', JsonString())
|
|
)),
|
|
JsonField('privacyPolicy', JsonObject(
|
|
JsonField('name', JsonString()),
|
|
JsonField('text', JsonString(), converter=JsonFilenameConverter()),
|
|
JsonField('uri', JsonString())
|
|
)),
|
|
JsonField('releaseNotes', JsonObject(
|
|
JsonField('name', JsonString()),
|
|
JsonField('text', JsonString(), converter=JsonFilenameConverter()),
|
|
JsonField('uri', JsonString())
|
|
))
|
|
)),
|
|
JsonField('dependencies', JsonObject(any=JsonValue(
|
|
JsonObject(
|
|
JsonField(
|
|
'version',
|
|
JsonString(),
|
|
converter=JsonVersionSpecConverter(),
|
|
default=JsonVersionSpecConverter.any_version),
|
|
JsonField('package', JsonString(), converter=AppDependencyPackageConverter()),
|
|
JsonField(
|
|
'optional',
|
|
JsonBoolean(),
|
|
default=False),
|
|
JsonField(
|
|
'targetOS',
|
|
JsonArray(JsonValue(JsonString(), converter=AppOSConverter())),
|
|
default=AppOSConverter.default_os,
|
|
version=AppOSConverter.schema_version_spec
|
|
),
|
|
), converter=AppDependency.Converter()
|
|
))),
|
|
JsonField('tasks', JsonArray(JsonValue(JsonString()))),
|
|
JsonField('inputGroups', JsonObject(any=JsonValue(
|
|
JsonObject(
|
|
JsonField('requires', JsonObject(any=JsonValue(JsonArray(JsonValue(JsonString()))))),
|
|
JsonField('inputs', JsonArray(JsonValue(JsonString()))),
|
|
JsonField('description', JsonString())
|
|
), converter=AppInputGroup.Converter()
|
|
)), converter=AppInputGroupsSpec.Converter()),
|
|
JsonField('incompatibleApps', JsonObject(any=JsonValue(
|
|
JsonString(), converter=JsonVersionSpecConverter(), required=True
|
|
))),
|
|
JsonField('platformRequirements', JsonObject(
|
|
JsonField('splunk', JsonObject(any=JsonValue(
|
|
JsonString(), converter=JsonVersionSpecConverter(), required=True
|
|
)), converter=AppSplunkRequirement.Converter())
|
|
)),
|
|
JsonField(
|
|
'supportedDeployments',
|
|
JsonArray(JsonValue(JsonString(), converter=AppDeploymentConverter())),
|
|
default=AppDeploymentConverter.default_deployment,
|
|
version=AppDeploymentConverter.schema_version_spec
|
|
),
|
|
JsonField(
|
|
'targetWorkloads',
|
|
JsonArray(JsonValue(JsonString(), converter=AppTargetWorkloadsConverter())),
|
|
version=AppTargetWorkloadsConverter.schema_version_spec
|
|
)
|
|
)))
|
|
|
|
@staticmethod
|
|
def _get_author(app_configuration):
|
|
""" Construct the info.author section of the app manifest (no default)
|
|
|
|
"""
|
|
# Note that we do not complain that, if any of the author values (name, email, or company) are missing
|
|
# Note also that we do not ensure the name, email, company tuples are unique
|
|
app = app_configuration.get('app')
|
|
|
|
if app is not None:
|
|
|
|
author = [ObjectView((
|
|
('name', stanza.name[len('author='):]),
|
|
('email', stanza.get_value('email')),
|
|
('company', stanza.get_value('company'))
|
|
)) for stanza in app.stanzas() if stanza.name.startswith('author=')]
|
|
|
|
if len(author) > 0:
|
|
return author
|
|
|
|
name = app_configuration.get_value('app', 'launcher', 'author')
|
|
|
|
if not name:
|
|
author = [] # No author specified
|
|
else:
|
|
author = [ObjectView((
|
|
('name', name),
|
|
('email', None),
|
|
('company', None)
|
|
))]
|
|
|
|
return author
|
|
|
|
@staticmethod
|
|
def _get_description(app_configuration):
|
|
return app_configuration.get_value('app', 'launcher', 'description')
|
|
|
|
@staticmethod
|
|
def _get_id(app_configuration):
|
|
""" Construct the info.id section of the app manifest
|
|
|
|
"""
|
|
group, name, version = app_configuration.get_value('app', 'id', ('group', 'name', 'version'))
|
|
|
|
def normalize_version(stanza, value):
|
|
if value is None:
|
|
SlimLogger.error('A value for version in the [id] stanza of app.conf is required')
|
|
return '0.0.0'
|
|
try:
|
|
value = Version.coerce(value)
|
|
except ValueError:
|
|
SlimLogger.error(
|
|
'Expected a semantic version number as the value of version in the [', stanza, '] stanza '
|
|
'of app.conf, not ', encode_string(value))
|
|
value = '0.0.0'
|
|
else:
|
|
value = string(value)
|
|
return value
|
|
|
|
if (group, name, version) == (None, None, None):
|
|
|
|
# Legacy code path which is less strict; the [id] stanza of app.conf is absent
|
|
|
|
name = AppManifest._get_package_id(app_configuration, path.basename(app_configuration.app_root))
|
|
version = normalize_version('launcher', app_configuration.get_value('app', 'launcher', 'version'))
|
|
else:
|
|
|
|
# New code path which is more strict; the [id] stanza of app.conf is present
|
|
|
|
# Validate app ID:
|
|
# * <id.name> is
|
|
# * [<id.group>-]<id.name> must equal <package.id>, if <package.id> is specified
|
|
|
|
folder_name = path.basename(app_configuration.app_root)
|
|
|
|
if name is None:
|
|
SlimLogger.error('A value for name in the [id] stanza of app.conf is required')
|
|
name = folder_name # short-circuits a downstream error message
|
|
else:
|
|
computed_id = '-'.join(value for value in (group, name) if value is not None)
|
|
alt_id = AppManifest._get_package_id(app_configuration, computed_id)
|
|
if alt_id != computed_id:
|
|
SlimLogger.error(
|
|
'The combination of group and name from the [id] stanza of app.conf (', computed_id, ') '
|
|
'must equal the value of id in the [package] stanza of app.conf (', alt_id, ')'
|
|
)
|
|
if folder_name != computed_id:
|
|
SlimLogger.error(
|
|
'The combination of group and name from the [id] stanza of app.conf (', computed_id, ') '
|
|
'must equal the name of the app folder (', folder_name, ')'
|
|
)
|
|
name = folder_name # short-circuits a downstream error message
|
|
|
|
# Validate app version number:
|
|
# * <id.version> is required
|
|
# * <id.version> must equal <launcher.version>, if <launcher.version> is specified
|
|
|
|
version = normalize_version('id', version)
|
|
alt_version = app_configuration.get_value('app', 'launcher', 'version')
|
|
|
|
if alt_version is not None:
|
|
alt_version = normalize_version('launcher', alt_version)
|
|
if alt_version != version:
|
|
SlimLogger.error(
|
|
'Expected the value of version in the [launcher] stanza of app.conf (',
|
|
encode_string(alt_version), ' to equal the value of version in the '
|
|
'[id] stanza of app.conf (', encode_string(version), ')'
|
|
)
|
|
|
|
return ObjectView((('group', group), ('name', name), ('version', version)))
|
|
|
|
@staticmethod
|
|
def _get_package_id(app_configuration, default_value):
|
|
value = app_configuration.get_value('app', 'package', 'id')
|
|
if value is None:
|
|
SlimLogger.warning('There is no value for id in the [package] stanza of app.conf')
|
|
value = default_value
|
|
return value
|
|
|
|
@staticmethod
|
|
def _get_text(app_configuration, name):
|
|
""" Construct info.[license|privacyPolicy|releaseNotes].text element of the app manifest.
|
|
|
|
"""
|
|
partial_filename = path.join(app_configuration.app_root, name)
|
|
for extension in '.md', '.rtf', '.txt':
|
|
filename = partial_filename + extension
|
|
if path.isfile(filename):
|
|
return './' + path.basename(filename)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _get_title(app_configuration):
|
|
""" Construct the info.title element of the app manifest (no default).
|
|
|
|
"""
|
|
return app_configuration.get_value('app', 'ui', 'label')
|
|
|
|
@classmethod
|
|
def _load(cls, istream):
|
|
""" Load an AppManifest object from `istream`.
|
|
|
|
Parse out any comment lines. Caller is required to check for logged errors on return.
|
|
|
|
"""
|
|
text = ''.join(line for line in istream if re.match(r'\s*#', line) is None) # confirmed re.match copies no text
|
|
|
|
try:
|
|
object_view = json.loads(text, object_pairs_hook=AppManifest._create_object_view)
|
|
except ValueError as error:
|
|
SlimLogger.error('Failed to load app manifest from ', encode_filename(istream.name), ': ', error)
|
|
object_view = ObjectView.empty
|
|
|
|
current_directory = os.getcwd()
|
|
os.chdir(path.dirname(istream.name))
|
|
|
|
try:
|
|
app_manifest = AppManifest(object_view)
|
|
app_manifest.loaded = True
|
|
finally:
|
|
os.chdir(current_directory)
|
|
|
|
return app_manifest
|
|
|
|
# endregion
|
|
pass # pylint: disable=unnecessary-pass
|