#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright (C) 2022-2023  Nexedi SA and Contributors.
#                          Started by Kirill Smelkov <kirr@nexedi.com>
#
# This program is free software: you can Use, Study, Modify and Redistribute
# it under the terms of the GNU General Public License version 3, or (at your
# option) any later version, as published by the Free Software Foundation.
#
# You can also Link and Combine this program with other software covered by
# the terms of any of the Free Software licenses or any of the Open Source
# Initiative approved licenses and Convey the resulting work. Corresponding
# source of such a combination shall include the source code for all other
# software used.
#
# This program is distributed WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See COPYING file for full licensing terms.
# See https://www.nexedi.com/licensing for rationale and options.
"""Program nxd-bom generates bill of material for a software-release or slapos node.

Usage: nxd-bom software   <path-to-installed-software>
       nxd-bom node       <slapos deploy-script>

An example of generated bill of material is provided in example/ors-bom.txt .
"""

from collections import namedtuple
import datetime
from glob import glob
import importlib.metadata
from os.path import basename
from urllib.parse import unquote, urlparse
import argparse
import json
import sys, configparser, re, codecs
import uuid


# PkgInfo represents information about a package
PkgInfo = namedtuple('PkgInfo', [
    'name',
    'version',
    'kind',
    'url'])
    # TODO patches



# bom_software retrieves BOM from .installed.cfg generated by buildout along the build.
def bom_software(installed_software_path): # -> {} (name,kind) -> PkgInfo
    bom = {}
    def addbom(urlpath, kind, version=None):
        name, ver = namever(urlpath)
        if version is not None:
            assert ver is None
            ver = version
        if ver is None:
            # some packages come without version in the url
            if name in ('libstemmer_c',):
                ver = ''
            else:
                assert ver is not None, urlpath
        ver = removeprefix(ver, name+'-')   # wendelin.core-2.0.alpha3-0-g6315384  -> 2.0.alpha3-0-g6315384
        if '//' in urlpath:
            url = urlpath
        else:
            if kind == 'egg':
                # XXX not strictly correct -> better retrieve the actual URL, but buildout does not save it in installed.cfg
                # remove +slapospatcheXXX suffix from egg url
                v = ver
                m = re.match(r'(?P<ver>.*)\+slapospatched.*', ver, re.I)
                if m is not None:
                    v = m.group('ver')
                # if ver comes with nxd -> use nexedi.org instead of pypi
                if 'nxd' in v.lower()  or  'slapos' in v.lower():
                    nxdbase = 'http://www.nexedi.org/static/packages/source'
                    if name == 'zc.buildout':
                        nxdbase += '/slapos.buildout'
                    url = '%s/%s-%s.tar.gz' % (nxdbase, name, v)
                else:
                    url = 'https://pypi.org/project/%s/%s/' % (name, v)
            elif kind == 'gem':
                url = 'https://rubygems.org/gems/%s/versions/%s' % (name, ver)
            else:
                raise NotImplementedError('TODO url for kind %r  (urlpath: %r)' % (kind, urlpath))


        info = PkgInfo(name, ver, kind, url)
        bkey = (name, kind, ver)
        if bkey in bom:
            assert bom[bkey] == info,  (bom[bkey], info)
        else:
            bom[bkey] = info


    _ = {
        '__buildout_space__':   ' ',
        '__buildout_space_n__': '\n',
        '__buildout_space_r__': '\r',
        '__buildout_space_f__': '\f',
        '__buildout_space_v__': '\v',
    }
    idb = configparser.ConfigParser(defaults=_)
    installed_cfg = '%s/.installed.cfg' % installed_software_path
    _ = idb.read(installed_cfg)
    if not _:
        raise RuntimeError("Cannot load %r" % installed_cfg)

    for s in idb.sections():
        if s == 'buildout':
            continue    # [buildout] is used internally

        part = idb[s]
        recipe = part['recipe'].rsplit('==', 1)[0]

        if recipe == 'slapos.recipe.cmmi':
            if part.get('path', raw=True):
                url = geturl(part, default=None)
            else:
                url = geturl(part)
            if url:
                addbom(url, '')    # XXX detect kind?

        elif recipe == 'slapos.recipe.build':
            # slapos.recipe.build is often used in creative ways to actually
            # run python code during the build. Let's detect this via lack of
            # `url`, but be careful and try to detect options looking like URLs
            # not to miss a normal part.
            url = geturl(part, None)
            if url is None:
                def raw_values(p):
                    for k in p:
                        yield p.get(k, raw=True)
                if any(v.strip().startswith('http') for v in raw_values(part)):
                    raise NotImplementedError('%s might be using url with %s in an unsupported way' % (s, recipe))

            else:
                addbom(url, '')     # XXX detect kind?

        elif recipe in ('slapos.recipe.build:download', 'slapos.recipe.build:download-unpacked', 'hexagonit.recipe.download', ):
            url = geturl(part)

            # strip off hash from the url
            vhash = None
            h = url.rfind('#')
            if h != -1:
                url, vhash = url[:h], url[h+1:]
                vhash = vhash[:8]   # abbreviate

            # slapos.recipe.build:download is often used to download .conf files, but sometimes it is used to download e.g. binaries
            # skip the part, if we can detect that downloaded item is a configuration file
            if isconf(url):
                continue

            # if url refer to local filesystem and comes as /base/... - it
            # comes from the SR itself and should be ignored
            base = part.get('_profile_base_location_')      # not always present, e.g. no on
            if base is not None  and  url.startswith(base): # ocropy-eng-traineddata
                continue

            # let's see if maybe its an archive from known place
            _ = namever(url, failonerr=False)
            if _ is not None:
                addbom(url, '', vhash)  # XXX detect kind
                continue

            raise NotImplementedError('%s uses %s with url that does not look like a .conf, archive or SR file: %s' % (s, recipe, url))


        elif recipe.startswith('slapos.recipe.template') or \
             recipe == 'collective.recipe.template':
            url = geturl(part, None)
            if url is None:
                if recipe.startswith('slapos'):
                    # old slapos.recipe.template option
                    url = part.get('template', raw=True)
                    if url is None:
                        # it is an inline= script
                        if 'inline' in part:
                            continue
                        # old versions of slapos.recipe.template did not save the url in options
                        if 'slapos.recipe.template-4' in part.get('__buildout_signature__', raw=True, fallback=''):
                            continue
                        raise ValueError('No url for %s' % part)
                    if url.startswith('inline:'):
                        continue
                else: # collective...
                    assert part['input'].startswith('inline:')

            if url is None or isconf(url):
                continue
            # NOTE binary is not expected with slapos.recipe.template
            raise ValueError('%s uses %s with url that does not look like a .conf file: %s' % (s, recipe, url))

        elif recipe in ('zc.recipe.egg:custom',  'zc.recipe.egg:develop'):
            eggpath = part['__buildout_installed__']
            assert len(eggpath.split()) == 1, eggpath  # no spaces inside - just one item
            isegg  = eggpath.endswith('.egg')
            islink = eggpath.endswith('.egg-link'),
            assert isegg or islink, eggpath
            if isegg:                   # ignore .egg-link - we declare it through the place
                addbom(eggpath, 'egg')  # from where destination is downloaded from

        elif recipe in ('zc.recipe.egg', 'zc.recipe.egg:eggs', 'zc.recipe.egg:script', 'zc.recipe.egg:scripts'):
            # zc.recipe.egg:* installs
            #   1) eggs, that are explicitly specified,
            #   2) indirect dependencies of eggs from "1", and
            #   3) scripts that use all those eggs from "1" and "2".
            # go through installed scripts and retrieve the eggs we can
            # retrieve to cover both "1" and "2". This does not cover the case
            # when no scripts are installed at all, but is better than nothing.
            # An alternative is to fix zc.recipe.egg itself to emit information
            # about all eggs it installs:
            xeggs = {} # xeggname -> xeggname-ver-....egg on the filesystem
            installedv = part['__buildout_installed__'].split()
            for f in installedv:
                for xeggpath in eggscript_imports(f):
                    if not xeggpath.endswith('.egg'):
                        continue    # e.g. neoppod-repository, *.egg-link
                    xeggfile = basename(xeggpath)
                    xegg, _ = namever(xeggfile)
                    if xegg in xeggs:
                        assert xeggs[xegg] == xeggfile, (xeggs[xegg], xeggfile)
                    else:
                        xeggs[xegg] = xeggfile
                        addbom(xeggfile, 'egg')

            # now go through explicitly listed eggs and also add them
            # XXX sadly zc.recipe.egg neither saves in .installed.cfg information about where the eggs are installed,
            #     nor there are [versions] save.
            #     -> look the egg on the filesystem
            # TODO it is better to fix zc.recipe.egg to save the full information, so that we can build BOM just from .installed.cfg
            eggdir = part['_e']
            eggdev = part['_d']
            eggs = part.get('eggs', part.name).split('\n')
            for eggname in eggs:
                if eggname == '':   # happens when %(__buildout_space_n__)s is appended  to eggname
                    continue        # because software.cfg contained vspace
                m = _egg_re.match(eggname)
                assert m is not None, eggname
                eggname = m.group('name')   # neoppod[admin, ctl, master] -> neoppod
                eggname = eggcanon(eggname) # cython-zstd -> cython_zstd

                if eggname in xeggs:
                    continue    # we already reported this egg via scripts above

                if glob('%s/%s.egg-link' % (eggdev, eggname)):
                    continue # e.g. wendelin.core.egg-link points to wendelin.core cloned via git and already reported as git

                installed_eggs = [basename(_) for _ in (glob('%s/*.egg' % eggdir) +
                                                        glob('%s/*.egg' % eggdev))]
                eggv = [_ for _ in installed_eggs if _.lower().startswith('%s-' % eggname)]
                if len(eggv) == 0:
                    raise ValueError('egg %s not found' % eggname)
                if len(eggv) > 1:
                    eggv.sort()
                    raise ValueError('egg %s is present multiple times: %s' % (eggname, eggv))
                addbom(eggv[0], 'egg')

        elif recipe == 'slapos.recipe.build:gitclone':
            repo = part['repository']
            ver  = part.get('revision')
            if ver is None:
                ver = part.get('branch')
            if ver is None:
                ver = 'HEAD'
            addbom(_remove_credentials_from_url(repo), 'git', ver)

        elif recipe in ('rubygemsrecipe',):
            location = part.get('location', raw=True)
            for gemspec in glob('%s/lib/ruby/gems/specifications/*.gemspec' % location):
                addbom(gemspec, 'gem')

        elif recipe in ('plone.recipe.command', 'zc.recipe.testrunner',):
            # not downloading anything
            pass

        else:
            raise NotImplementedError('TODO: add support for recipe %s' % recipe)


    return bom


# geturl returns value of part:url
_missing = object()
def geturl(part, default=_missing):
    url = part.get('url', raw=True)     # raw because e.g. .../ZABBIX%20Latest%20Stable/...
    if url is None:
        if default is not _missing:
            return default
        raise KeyError('section %s has no url' % part)
    return _remove_credentials_from_url(url)

_egg_re = re.compile(r'^(?P<name>[\w\-\.]+)(\[.*\])?$')

# eggcanon returns canonical name for an egg.
# e.g. Cython-Zstd -> cython_zstd
def eggcanon(eggname):
    return eggname.lower().replace('-', '_')


# eggscript_imports returns list of eggs put into python path in python script
# generated by zc.recipe.egg at path.
_re_importv = re.compile(r"\nsys\.path\[0:0\] = \[\n(?P<importv>( *'[^'\n]+',\n)+) *]", re.MULTILINE)
def eggscript_imports(path):
    with open(path, 'r') as f:
        data = f.read()

    m = _re_importv.search(data)
    if m is None:
        raise RuntimeError("%s does not look like zc.recipe.egg script" % path)

    importv = []
    for x in m.group('importv').splitlines():
        x = x.strip()
        x = removeprefix(x, "'")
        x = removesuffix(x, "',")
        x = codecs.escape_decode(x)[0].decode('utf-8')
        importv.append(x)

    return importv


def _remove_credentials_from_url(url):
    parsed_url = urlparse(url)
    netloc = parsed_url.hostname
    if parsed_url.port:
        netloc += f':{parsed_url.port}'
    return parsed_url._replace(
        netloc=netloc).geturl()


def bom_node(XXX):
    1/0
    # TODO bom_node should:
    #
    # 1. retrieve list of software that slapos-node brings with itself
    #    since slapos-node is built via buildout this could be extracted via
    #    node_software from /opt/slapos/.installed.cfg once we fix slapos-node
    #    package to preserve that .installed.cfg in the package itself.
    #
    # 2. retrieve list of base-OS packages that are installed by https://deploy.rapid.space/slapos
    #    This can be computed as the difference in between two sets of base-OS packages:
    #    b) set of base-OS packages after deploy.rapid.space/slapos was run, and
    #    a) set of base-OS packages after minimal base-OS was installed.
    #
    #    this computation could be automated via
    #    - preparing e.g. Debian 11 minimal chroot via debootstrap,
    #    - taking snapshot for list of installed packages via `dpkg -l`,
    #    - then deploying slapos-node in the chroot,
    #    - taking snapshot for the list of installed package again,
    #    - and finally computing the difference.
    #
    # Please see example/ors-bom.txt for "BOM(slapos-node)", "List of software
    # that slapos-node brings with itself" and "List of base-OS packages
    # installed by https://deploy.rapid.space/slapos" sections for what end result could look like.



# namever extracts item name and version from an url/path.
# for example:
#
#     http://www.python.org/ftp/python/2.7.18/Python-2.7.18.tar.xz  ->  ('Python', '2.7.18')
download_url_matchers = (
    re.compile(r'/gitweb/\?p=(?P<name>\w+)\.git;a=snapshot;h=(?P<rev>\w+)'),
    re.compile(r'https://github.com/libevent/(?P<name>.+)/releases/download/[^/]+/libevent-(?P<rev>[^/]+)-stable'),
    re.compile(r'https://github.com/(?P<name>nextcloud/.+)/releases/download/(?P<rev>[^/]+)'),
    re.compile(r'https://github.com/tesseract-ocr/(?P<name>tessdata)/raw/(?P<rev>.+)'),
    re.compile(r'/github.com/[\w\-]+/(?P<name>[\w\-]+)/archive/(refs/tags/)?(?P<rev>.+)$'),
    re.compile(r'/raw.githubusercontent.com/[\w\-]+/(?P<name>[\w\-]+)/(?P<rev>[\w\.\-]+)/'),
    re.compile(r'/lab.nexedi.com/[\w\-]+/(?P<name>[\w\-]+)/repository/archive.tar.(gz|bz2)\?ref=(?P<rev>.+)$'),
    re.compile(r'/lab.nexedi.com/[\w\-]+/(?P<name>[\w\-]+)/repository/(?P<rev>.+)/archive'),
    re.compile(r'/sourceforge.net/.+/(?P<name>[\w\_]+)-(?P<rev>[\w\.]+)/download$'),
    re.compile(r'/golang.org/dl/(?P<name>go)(?P<rev>[\w\.]+).src$'),
    re.compile(r'/go.dev/dl/(?P<name>go)(?P<rev>[\w\.]+).src$'),
    re.compile(r'archive.debian.org/debian-archive/debian/pool/[\w\/]+/(?P<name>.+)/.*_(?P<rev>.+)(\.orig|)+$'),
    re.compile(r'cdimage.debian.org/cdimage/archive/(?P<rev>.+)/amd64/iso-cd/(?P<name>[^/-]+)'),
    re.compile(r'https://.*.storage.googleapis.com/(?P<rev>.+)/(?P<name>.+)-\d+(\.\d+)*'),
    re.compile(r'https://.*.storage.googleapis.com/(?P<rev>.+)/(?P<name>.+)'),
    re.compile(r'https://downloads.metabase.com/(?P<rev>.+)/(?P<name>.+).jar'),
    re.compile(r'https://nodejs.org/download/release/v(?P<rev>.+)/(?P<name>.+)'),
    re.compile(r'https://(?P<name>nodejs).org/dist/v(?P<rev>.+)/node-v'),
    re.compile(r'snappymail.eu/repository/(?P<name>nextcloud/.+)-(?P<rev>.+)-nextcloud'),
    re.compile(r'dlcdn.apache.org/.+/binaries/(?P<name>.+)-(?P<rev>.+)-bin'),
    re.compile(r'https://inkscape.org/gallery/item/13330/(?P<name>inkscape)-(?P<rev>.+)_.'),
)
git_url_matchers = (
    re.compile(r'/(?P<name>[\w\.\-]+)\.git$'),
    re.compile(r'(lab\.nexedi\.com|gitlab\.com|github\.com)/[\w\.\-]+/(?P<name>[\w\.\-]+)$'),
)
_osdn_f    = re.compile(r'/osdn.net/frs/redir.php\?f=(?P<f>.+)$')
def namever(url, failonerr=True): # -> (name, ver)  | None if !failonerr
    # http://www.ijg.org/files/jpegsrc.v9d.tar.gz  ->  http://www.ijg.org/files/jpegsrc.v9d
    def del_tgztail(s):
        for tail in ('.tgz', '.tar.gz', '.tbz', '.tar.bz2', '.tar.xz', '.tar.lz', '.zip', '.egg'):
            s = removesuffix(s, tail)
        for tail in ('-linux-x86_64', '_Linux_x86-64_rpm', '-linux64', '_linux64'): # FIXME x86/arm, non-linux ?
            s = removesuffix(s, tail)
        for tail in ['-py2.7'] + ['-py3.%d' % _ for _ in range(20)]:
            s = removesuffix(s, tail)
        for tail in ('-source', '-src', '.orig'):
            s = removesuffix(s, tail)
        return s
    url = del_tgztail(url)

    _ = _namever(url, failonerr)
    if _ is None:
        assert not failonerr
        return None

    name, ver = _
    if ver is not None:
        # swig-3.0.12.tar.gz  ->  swig-3.0.12
        # originally from https://sourceforge.net/projects/swig/files/swig/swig-3.0.12/swig-3.0.12.tar.gz/download
        ver = del_tgztail(ver)
        ver = removeprefix(ver, 'v')
    # XXX hack: ERP5 SR uses both readline8 and readline-5
    # XXX       Cloudooo SR ----//---- libpng16 and libpng12
    # XXX       Cloudooo SR ----//---- python2 and python3
    # TODO -> general multiversion support
    if name == 'readline' and ver.startswith('5'):    name += '5'
    if name == 'libpng'   and ver.startswith('1.2'):  name += '12'
    if name == 'Python'   and ver.startswith('3'):    name += '3'
    return name, ver

def _namever(url, failonerr):
    for r in download_url_matchers:
        m = r.search(url)
        if m is not None:
            return m.group('name'), m.group('rev')

    for r in git_url_matchers:
        m = r.search(url)
        if m is not None:
            return m.group('name'), None

    m = _osdn_f.search(url)
    if m is not None:
        url = unquote(m.group('f'))

    if url.endswith('.gemspec'):
        with open(url) as f:
            _ = f.readline()
            stub_line = f.readline()
        dash, stub, name, ver, _, _ = stub_line.split()
        assert (dash, stub) == ('#', 'stub:')
        return name, ver

    filename = basename(url)

    name   = filename
    ver    = ''
    dashed = False
    while 1:
        # re.rsplit([-_], name, 1)
        m = re.search('[-_][^-_]*$', name)
        if m is None:
            break
        dashed = True
        v = name[m.start():]
        v_ndigit = 0
        for _ in v:
            if _.isnumeric():
                v_ndigit += 1
        isver = 'patch' in v            or \
                'slapospatched' in v    or \
                v_ndigit > (len(v)-1) / 3
        if 'rfc' in v:  # ex strict_rfc3339-0.7
            isver = False
        if not isver:   # doesn't look like a version
            break
        name = name[:m.start()]
        ver = v + ver
    if dashed:
        if not ver:
            return name, None       # no version
        else:
            return name, ver[1:]    # ver without leading '-'

    m = re.search(r'\.v.+$', filename)  # jpegsrc.v9d
    if m is not None:
        name = filename[:m.start()]
        ver  = filename[m.start()+1:]
        return name, ver

    m = re.match(r'(?P<name>.*[^0-9])(?P<rev>[0-9]+)$', filename) # IPAexfont00201
    if m is not None:
        return m.group('name'), m.group('rev')

    if not failonerr:
        return None

    raise RuntimeError('Unsupported url: %r' % (url,))


# isconf returns whether url points to data related to configuration file
# (contrary to binary executable), or a simple/small script.
def isconf(url):
    if url.endswith('.in')                      or \
       url.endswith('.cfg')                     or \
       url.endswith('.cfg.jinja2')              or \
       url.endswith('.jinja2.cfg')              or \
       url.endswith('.jinja2.library')          or \
       url.endswith('.asn')                     or \
       url.endswith('/ltelogs.jinja2.sh')       or \
       '/promise/' in url                       or \
       url.endswith('/template-fonts-conf')     :
        return True
    return False


# removeprefix/removesuffix provides fallbacks similarly-named str methods.
def removeprefix(s, prefix):
    if s.startswith(prefix):
        s = s[len(prefix):]
    return s
def removesuffix(s, suffix):
    if s.endswith(suffix):
        s = s[:len(s)-len(suffix)]
    return s

# ----------------------------------------

# fmt_bom formats BOM into text.
def fmt_bom(bom): # -> str
    outv = []
    def emit(text):
        outv.append(text+'\n')

    # emit BOM grouped by kind
    kinds = set()
    for info in bom.values():
        kinds.add(info.kind)

    for kind in sorted(kinds):
        if kind != '':
            emit('\n>>> %ss:' % kind)
        for bkey in sorted(bom, key=lambda _: _[0].lower()):  # by name, ignoring case
            info = bom[bkey]
            if info.kind == kind:
                # TODO autoalign
                emit('%-28s %-10s %s' % (info.name, info.version, info.url))

    return ''.join(outv)

def fmt_bom_cyclonedx_json(bom, software_path):

    # possible future extensions:
    # - describe patches applied to components (using components[*].pedigree.patches )
    # - for egg components, include metadata (licence, author, description) by reading
    #   EGG-INFO/PKG-INFO
    cfgparser = configparser.ConfigParser()
    cfgparser.read('%s/buildout.cfg' % software_path)
    software_url = cfgparser.get('buildout', 'extends')
    name = software_url.split('/')[-2]  # slapos convention

    bom_json = {
        "serialNumber": f'urn:uuid:{uuid.uuid4()}',
        "version": 1,
        "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
        "bomFormat": "CycloneDX",
        "specVersion": "1.5",
        "metadata": {
            "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
            "component": {
                "name": name,
                "type": "application",
                "externalReferences": [
                    {
                        "type": "build-meta",
                        "url": _remove_credentials_from_url(software_url),
                    }
                ]
            },
            "tools": {
                "components": [
                    {
                        "type": "application",
                        "name": "nxdbom",
                        "version": importlib.metadata.version("nxdbom"),
                        "externalReferences": [
                            {
                                "type": "vcs",
                                "url": "https://lab.nexedi.com/nexedi/nxd-bom/"
                            }
                        ]
                    }
                ]
            }
        }
    }
    components = bom_json["components"] = []
    for _, pkginfo in sorted(bom.items()):
        cpe = None
        externalReferences = []
        if pkginfo.url:
            externalReferences.append(
                {
                    'url': pkginfo.url,
                    'type': (
                        'vcs'
                        if pkginfo.kind == 'git'
                        else 'distribution'
                    ),
                }
            )
        purl_type = 'generic'
        if pkginfo.kind == 'egg':
            purl_type = 'pypi'
        elif pkginfo.kind == 'gem':
            purl_type = 'gem'
        else:
            cpe_vendor = '*'
            parsed_url = urlparse(pkginfo.url)
            if parsed_url.hostname == 'github.com':
              cpe_vendor = parsed_url.path.split('/')[1]
            cpe = f'cpe:2.3:*:{cpe_vendor}:{pkginfo.name}:{pkginfo.version}:*:*:*:*:*:*:*'
        purl = f'pkg:{purl_type}/{pkginfo.name}@{pkginfo.version}'
        component = {
            'name': pkginfo.name,
            'purl': purl,
            'type': 'library',
            'version': pkginfo.version,
        }
        if cpe:
            component['cpe'] = cpe
        if externalReferences:
            component['externalReferences'] = externalReferences
        components.append(component)

    return bom_json


def main():
    parser = argparse.ArgumentParser(
        prog=__name__,
        description=__doc__
    )
    parser.add_argument('-f', '--format', choices=['text', 'cyclonedx-json'], default='text')
    parser.add_argument('-o', '--output',
        type=argparse.FileType('w', encoding='UTF-8'),
        default=sys.stdout)
    subparsers = parser.add_subparsers(dest='mode', title='Mode-specific commands', required=True)
    software_parser = subparsers.add_parser('software', help="Generates BOM from an installed software")
    software_parser.add_argument(dest="software_path")
    node_parser = subparsers.add_parser('node', help="Generates BOM from a slapos deploy script")
    node_parser.add_argument(dest="deploy_script_path")

    args = parser.parse_args()
    if args.mode == 'software':
        bom = bom_software(args.software_path)
    else:
        assert args.mode == 'node'
        bom = bom_node(args.deploy_script_path)

    # print retrieved BOM
    if args.format == 'text':
        print(fmt_bom(bom), file=args.output)
    else:
        assert args.format == 'cyclonedx-json'
        json.dump(fmt_bom_cyclonedx_json(bom, args.software_path), args.output, indent=True)


if __name__ == '__main__':
    main()
