Skip to content

Commit

Permalink
Merge pull request #32 (squashed)
Browse files Browse the repository at this point in the history
commit a880677
Author: Lowell Alleman <lowell@kintyre.co>
Date:   Thu Jan 3 18:28:07 2019 -0500

    Add CLI unittests for rest-export

commit 57e385e
Author: Lowell Alleman <lowell@kintyre.co>
Date:   Thu Dec 13 15:32:39 2018 -0500

    New 'rest-export' command

    Added new 'rest-export' command that takes .conf files and turns them into a
    shell script of curl calls for importing configs into Splunk Cloud or other
    UI-only accessible instances.

    Missing unit tests.
    Ready for wider test audience.
  • Loading branch information
lowell80 committed Jan 3, 2019
1 parent e57cb1e commit 7c50687
Show file tree
Hide file tree
Showing 5 changed files with 297 additions and 3 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ repos:
- repo: local
hooks:
- id: make-cli-docs
name: Build CLI docs to - generate top-level README.md
name: Generating CLI reference docs -> docs/source/cli.md
language: script
entry: make_cli_docs.py
type: [ python ]
Expand Down
45 changes: 43 additions & 2 deletions docs/source/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ The following documents the CLI options

## ksconf
usage: ksconf [-h] [--version] [--force-color]
{check,combine,diff,promote,merge,minimize,sort,unarchive} ...
{check,combine,diff,promote,merge,minimize,sort,rest-export,unarchive}
...

Ksconf: Kintyre Splunk CONFig tool

Expand All @@ -17,7 +18,7 @@ The following documents the CLI options
"default" (which splunk can't handle natively) are all supported tasks.

positional arguments:
{check,combine,diff,promote,merge,minimize,sort,unarchive}
{check,combine,diff,promote,merge,minimize,sort,rest-export,unarchive}
check Perform basic syntax and sanity checks on .conf files
combine Combine configuration files across multiple source
directories into a single destination directory. This
Expand All @@ -39,6 +40,8 @@ The following documents the CLI options
duplicated in the default conf(s)
sort Sort a Splunk .conf file creating a normalized format
appropriate for version control
rest-export Export .conf settings as a curl script to apply to a
Splunk instance later (via REST)
unarchive Install or upgrade an existing app in a git-friendly
and safe way

Expand Down Expand Up @@ -401,6 +404,44 @@ The following documents the CLI options
example.


## ksconf rest-export
usage: ksconf rest-export [-h] [--output FILE] [-u] [--url URL] [--app APP]
[--user USER]
FILE [FILE ...]

Build an executable script of the stanzas in a configuration file that can be later applied to
a running Splunk instance via the Splunkd REST endpoint.

This can be helpful when pushing complex props & transforms to an instance where you only have
UI access and can't directly publish an app.

WARNING: This command is indented for manual admin workflows. It's quite possible that shell
escaping bugs exist that may allow full shell access if you put this into an automated workflow.
Evalute the risks, review the code, and run as a least-privilege user, and be responsible.

For now the assumption is that 'curl' command will be used. (Patches to support the Power Shell
Invoke-WebRequest cmdlet would be greatly welcomed!)

ksconf rest-export --output=apply_props.sh etc/app/Splunk_TA_aws/local/props.conf

positional arguments:
FILE Configuration file(s) to export settings from.

optional arguments:
-h, --help show this help message and exit
--output FILE, -t FILE
Save the shell script output to this file. If not
provided, the output is written to standard output.
-u, --update Assume that the REST entities already exist. By
default output assumes stanzas are being created.
(This is an unfortunate quark of the configs REST API)
--url URL URL of Splunkd. Default: https://localhost:8089
--app APP Set the namespace (app name) for the endpoint
--user USER Set the user associated. Typically the default of
'nobody' is ideal if you want to share the
configurations at the app-level.


## ksconf unarchive
usage: ksconf unarchive [-h] [--dest DIR] [--app-name NAME]
[--default-dir DIR] [--exclude EXCLUDE] [--keep KEEP]
Expand Down
204 changes: 204 additions & 0 deletions ksconf/commands/restexport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# -*- coding: utf-8 -*-
"""
SUBCOMMAND: ksconf rest-export --output=script.sh <CONF>
Usage example:
ksconf rest-export --output=apply_props.sh /opt/splunk/etc/app/Splunk_TA_aws/local/props.conf
NOTE:
If we add support for Windows CURL, then we'll need to also support proper quoting for the '%'
character. This can be done with '%^', wonky, I know...
"""
from __future__ import absolute_import, unicode_literals

import sys
import os

from argparse import FileType
from six.moves.urllib.parse import quote

from ksconf.commands import KsconfCmd, dedent, ConfFileType
from ksconf.conf.parser import PARSECONF_LOOSE, GLOBAL_STANZA
from ksconf.consts import EXIT_CODE_SUCCESS
from ksconf.util.completers import conf_files_completer
from collections import OrderedDict



class CurlCommand(object):
def __init__(self):
self.url = None
self.pre_args = [ "-k" ]
self.post_args = []
self.headers = OrderedDict()
self.data = OrderedDict()

@classmethod
def quote(cls, s):
if "$" in s:
s = '"{}"'.format(s)
elif " " in s or "$" in s:
s = "'{}'".format(s)
return s

def get_command(self):
cmd = ["curl"]

args = []
if self.headers:
for header in self.headers:
value = self.headers[header]
args.append("-H")
args.append("{}: {}".format(header, value))
if self.data:
for key in self.data:
value = self.data[key]
args.append("-d")
args.append("{}={}".format(quote(key), quote(value)))


if self.pre_args:
cmd.append(" ".join(self.pre_args))
cmd.append(self.url)
args = [ self.quote(arg) for arg in args ]
cmd.extend(args)
if self.post_args:
cmd.append(" ".join(self.post_args))
return " ".join(cmd)



class RestExportCmd(KsconfCmd):
help = "Export .conf settings as a curl script to apply to a Splunk instance later (via REST)"
description = dedent("""\
Build an executable script of the stanzas in a configuration file that can be later applied to
a running Splunk instance via the Splunkd REST endpoint.
This can be helpful when pushing complex props & transforms to an instance where you only have
UI access and can't directly publish an app.
WARNING: This command is indented for manual admin workflows. It's quite possible that shell
escaping bugs exist that may allow full shell access if you put this into an automated workflow.
Evalute the risks, review the code, and run as a least-privilege user, and be responsible.
For now the assumption is that 'curl' command will be used. (Patches to support the Power Shell
Invoke-WebRequest cmdlet would be greatly welcomed!)
ksconf rest-export --output=apply_props.sh etc/app/Splunk_TA_aws/local/props.conf
""")
format = "manual"

def register_args(self, parser):
parser.add_argument("conf", metavar="FILE", nargs="+",
type=ConfFileType("r", "load", parse_profile=PARSECONF_LOOSE),
help="Configuration file(s) to export settings from."
).completer = conf_files_completer
parser.add_argument("--output", "-t", metavar="FILE",
type=FileType("w"), default=sys.stdout,
help="Save the shell script output to this file. "
"If not provided, the output is written to standard output.")
'''
parser.add_argument("--syntax", choices=["curl", "powershell"], # curl-windows?
default="curl",
help="Pick the output syntax mode. "
"Currently only 'curl' is supported.")
'''
parser.add_argument("-u", "--update", action="store_true", default=False,
help="Assume that the REST entities already exist. "
"By default output assumes stanzas are being created. "
"(This is an unfortunate quark of the configs REST API)")
parser.add_argument("--url", default="https://localhost:8089",
help="URL of Splunkd. Default: %(default)s")
parser.add_argument("--app", default="$SPLUNK_APP",
help="Set the namespace (app name) for the endpoint")
parser.add_argument("--user", default="nobody",
help="Set the user associated. Typically the default of 'nobody' is "
"ideal if you want to share the configurations at the app-level.")

@staticmethod
def build_rest_url(base, user, app, conf):
# XXX: Quote user & app; however for now we're still allowing the user to pass though an
# environmental variable as-is and quoting would break that. Need to make a decision,
# for now this is not likely to be a big issue given app and user name restrictions.
url = "{}/servicesNS/{}/{}/configs/conf-{}".format(base, user, app, conf)
return url

def run(self, args):
''' Snapshot multiple configuration files into a single json snapshot. '''
"""
Some inspiration in the form of CURL commands...
[single_quote_kv]
REGEX = ([^=\s]+)='([^']+)'
FORMAT = $1::$2
MV_ADD = 0
CREATE NEW:
curl -k https://SPLUNK:8089/servicesNS/nobody/my_app/configs/conf-transforms \
-H "Authorization: Splunk $SPLUNKDAUTH" -X POST \
-d name=single_quote_kv \
-d REGEX="(%5B%5E%3D%5Cs%5D%2B)%3D%27(%5B%5E%27%5D%2B)%27" \
-d FORMAT='$1::$2'
UPDATE EXISTING: (note the change in URL/name attribute)
curl -k https://SPLUNK:8089/servicesNS/nobody/my_app/configs/conf-transforms/single_quote_kv \
-H "Authorization: Splunk $SPLUNKDAUTH" -X POST \
-d REGEX="(%5B%5E%3D%5Cs%5D%2B)%3D%27(%5B%5E%27%5D%2B)%27" \
-d FORMAT='$1::$2' \
-d MV_ADD=0
"""
# XXX: Someday make multiline output that looks pretty... someday

stream = args.output

if True:
# Make this preamble optional
stream.write("## Example of creating a local SPLUNKDAUTH token\n")
stream.write("export SPLUNKDAUTH=$("
"curl -ks {}/services/auth/login -d username=admin -d password=changeme "
"| grep sessionKey "
r"| sed -re 's/\s*<sessionKey>(.*)<.sessionKey>/\1/')".format(args.url))
stream.write("\n\n\n")

for conf_proxy in args.conf:
conf = conf_proxy.data
conf_type = os.path.basename(conf_proxy.name).replace(".conf", "")

stream.write("# CURL REST commands for {}\n".format(conf_proxy.name))

for stanza_name, stanza_data in conf.items():
cc = CurlCommand()
cc.url = self.build_rest_url(args.url, args.user, args.app, conf_type)

if stanza_name is GLOBAL_STANZA:
# XXX: Research proper handling of default/global stanazas..
# As-is, curl returns an HTTP error, but yet the new entry is added to the
# conf file. So I suppose we could ignore the exit code?! ¯\_(ツ)_/¯
stream.write("### WARN: Writing to the default stanza may not work as "
"expected. Or it may work, but be reported as a failure. "
"Patches welcome!\n")
cc.url += "/default"
elif args.update:
cc.url += "/" + quote(stanza_name, "") # Must quote '/'s too.
else:
cc.data["name"] = stanza_name

# Add individual keys
for (key, value) in stanza_data.items():
cc.data[key] = value

cc.headers["Authorization"] = "Splunk $SPLUNKDAUTH"

stream.write(cc.get_command())
stream.write("\n")
stream.write("\n")
stream.write("\n")

return EXIT_CODE_SUCCESS
1 change: 1 addition & 0 deletions ksconf/setup_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
Ep("merge", "ksconf.commands.merge", "MergeCmd"),
Ep("minimize", "ksconf.commands.minimize", "MinimizeCmd"),
Ep("sort", "ksconf.commands.sort", "SortCmd"),
Ep("rest-export", "ksconf.commands.restexport", "RestExportCmd"),
Ep("unarchive", "ksconf.commands.unarchive","UnarchiveCmd"),
],
}
Expand Down
48 changes: 48 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ def copy_static(self, static, rel_path):
unittest.TestCase.assertNotRegex = unittest.TestCase.assertNotRegexpMatches




class CliSimpleTestCase(unittest.TestCase):
""" Test some very simple CLI features. """

Expand Down Expand Up @@ -548,6 +550,52 @@ def test_mixed_quiet_missing(self):
self.assertRegex(ko.stderr, r"Skipping missing file: [^\r\n]+[/\\]not-a-real-file.conf")


class CliRestExportTest(unittest.TestCase):
transforms_sample1 = """
[single_quote_kv]
REGEX = ([^=\s]+)='([^']+)'
FORMAT = $1::$2
MV_ADD = 0
"""

def test_simple_transforms_insert(self):
twd = TestWorkDir()
f = twd.write_file("transforms.conf", self.transforms_sample1)
with ksconf_cli:
ko = ksconf_cli("rest-export", f)
# XXX: Check for more things...
self.assertEqual(ko.returncode, EXIT_CODE_SUCCESS)
self.assertRegex(ko.stdout, r"([\r\n]+|^)curl -k")
# Should use 'name' for stanza, and not be embedded in URL
self.assertRegex(ko.stdout, r"name=single_quote_kv")
# Make sure fancy regex is encoded as expected
self.assertIn("%5B%5E%3D%5Cs%5D%2B", ko.stdout)
self.assertNotRegex(ko.stdout, r"https://[^ ]+/single_quote_kv ")

def test_simple_transforms_update(self):
twd = TestWorkDir()
f = twd.write_file("transforms.conf", self.transforms_sample1)
with ksconf_cli:
ko = ksconf_cli("rest-export", "--update", f)
self.assertEqual(ko.returncode, EXIT_CODE_SUCCESS)
self.assertRegex(ko.stdout, r"([\r\n]+|^)curl ")
self.assertRegex(ko.stdout, r"https://[^ ]+/single_quote_kv ")
self.assertNotRegex(ko.stdout, r"name=single_quote_kv")

def test_warn_on_global_entry(self):
twd = TestWorkDir()
f = twd.write_file("props.conf", """
EVAL-always_present = 1
[syslog]
REPORT-single-quote = single_quote_kv
""")
with ksconf_cli:
ko = ksconf_cli("rest-export", f)
self.assertEqual(ko.returncode, EXIT_CODE_SUCCESS)
self.assertRegex(ko.stdout, r".*#+\s+WARN")



class CliSortTest(unittest.TestCase):
def setUp(self):
self.twd = twd = TestWorkDir()
Expand Down

0 comments on commit 7c50687

Please sign in to comment.