Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
- test fixtures are located in the same directory with test.
- test fixtures are stored as module files with the prefix test_.
- the fixtures suffix should be matching the tested module the same way as test file.
- do use exception types derived from sap.errors.SAPCliError to make sure the command line entry point intercepts them and prints nice error message instead of stacktrace
- avoid silent swallowing caught exceptions - if you need it, add a comment explaining why it is needed
50 changes: 49 additions & 1 deletion doc/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ contexts:
| `snc_myname` | string | no | - | `SNC_MYNAME` |
| `snc_partnername` | string | no | - | `SNC_PARTNERNAME` |
| `snc_lib` | string | no | - | `SNC_LIB` |
| `http_timeout` | float | no | `900` | `SAPCLI_HTTP_TIMEOUT` |

(*) Either `ashost` or `mshost` must be provided.

Expand Down Expand Up @@ -270,8 +269,57 @@ sapcli config use-context prod

# List available contexts
sapcli config get-contexts

# Merge a shared configuration file into your config
sapcli config merge --source /shared/team-connections.yml

# Merge from an HTTPS URL
sapcli config merge --source https://config.company.com/sapcli/common.yml

# Merge and overwrite existing entries
sapcli config merge --source /shared/team-connections.yml --overwrite

# Merge from an HTTP URL (not recommended)
sapcli config merge --source http://internal-server/config.yml --insecure

# Merge from HTTPS without certificate validation (e.g. self-signed cert)
sapcli --skip-ssl-validation config merge --source https://internal-server/config.yml
```

### Merging configuration files

The `merge` command allows you to incorporate connection details from a shared
configuration file into your personal config. This is useful for onboarding
new users or distributing common system connection details across a team.

**Merge semantics:**

- The `connections`, `users`, and `contexts` sections are merged additively.
- Existing entries with the same name are **not overwritten** by default. Your
personal config always wins. Use `--overwrite` to replace existing entries.
- Your `current-context` is never changed by the merge.
- The command prints a summary of what was added and what was skipped.

**Source types:**

- **Local file path**: any file path on the local filesystem.
- **HTTPS URL**: a remote configuration file served over HTTPS. Plain HTTP
is rejected for security reasons.

**Security notes:**

- Shared configuration files should not contain passwords. The command will
warn if the source file contains credentials.
- Remote sources must use HTTPS. Plain HTTP URLs are rejected unless
`--insecure` is passed, which should only be used for trusted internal
networks.
- For HTTPS servers with self-signed certificates or CA trust issues (common
on Windows), use the global `--skip-ssl-validation` flag to skip certificate
verification:
```bash
sapcli --skip-ssl-validation config merge --source https://internal-server/config.yml
```

## Context selection precedence

The active context is determined in the following order:
Expand Down
13 changes: 6 additions & 7 deletions sap/adt/core.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Base ADT functionality module"""

import os

import xml.sax
from xml.sax.handler import ContentHandler

Expand Down Expand Up @@ -93,7 +91,7 @@ class Connection:
"""

# pylint: disable=too-many-arguments
def __init__(self, host, client, user, password, port=None, ssl=True, verify=True):
def __init__(self, host, client, user, password, port=None, ssl=True, verify=True, ssl_server_cert=None):
"""Parameters:
- host: string host name
- client: string SAP client
Expand All @@ -103,6 +101,7 @@ def __init__(self, host, client, user, password, port=None, ssl=True, verify=Tru
(default 80 or 443 - it depends on the parameter ssl)
- ssl: boolean to switch between http and https
- verify: boolean to switch SSL validation on/off
- ssl_server_cert: optional path to a custom CA certificate file
"""

setup_keepalive()
Expand All @@ -116,6 +115,7 @@ def __init__(self, host, client, user, password, port=None, ssl=True, verify=Tru
if port is None:
port = '80'
self._ssl_verify = verify
self._ssl_server_cert = ssl_server_cert

self._host = host
self._port = port
Expand Down Expand Up @@ -223,10 +223,9 @@ def _get_session(self):
self._session = requests.Session()
self._session.auth = self._auth
# requests.session.verify is either boolean or path to CA to use!
self._session.verify = os.environ.get('SAP_SSL_SERVER_CERT', self._session.verify)

if self._session.verify is not True:
mod_log().info('Using custom SSL Server cert path: SAP_SSL_SERVER_CERT = %s', self._session.verify)
if self._ssl_server_cert:
self._session.verify = self._ssl_server_cert
mod_log().info('Using custom SSL Server cert path: %s', self._session.verify)
elif self._ssl_verify is False:
import urllib3
urllib3.disable_warnings()
Expand Down
15 changes: 10 additions & 5 deletions sap/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ def adt_connection_from_args(args):

return sap.adt.Connection(
args.ashost, args.client, args.user, args.password,
port=args.port, ssl=args.ssl, verify=args.verify)
port=args.port, ssl=args.ssl, verify=args.verify,
ssl_server_cert=args.ssl_server_cert)


def rfc_connection_from_args(args):
Expand Down Expand Up @@ -153,7 +154,7 @@ def gcts_connection_from_args(args):

return sap.rest.Connection('sap/bc/cts_abapvcs', 'system', args.ashost, args.client,
args.user, args.password, port=args.port, ssl=args.ssl,
verify=args.verify)
verify=args.verify, ssl_server_cert=args.ssl_server_cert)


def odata_connection_from_args(service_name, args):
Expand All @@ -163,7 +164,7 @@ def odata_connection_from_args(service_name, args):
import sap.odata
return sap.odata.Connection(service_name, args.ashost, args.port,
args.client, args.user, args.password, args.ssl,
args.verify)
args.verify, ssl_server_cert=args.ssl_server_cert)


def no_connection(_args):
Expand Down Expand Up @@ -194,6 +195,7 @@ def build_empty_connection_values():
port=None,
ssl=None,
verify=None,
ssl_server_cert=None,
user=None,
password=None,
)
Expand Down Expand Up @@ -247,7 +249,7 @@ def resolve_default_connection_values(args):
elif 'port' in config_values:
try:
args.port = int(config_values['port'])
except ValueError as exc:
except (ValueError, TypeError) as exc:
raise SAPCliConfigError(f"Config port must be an integer, got: '{config_values['port']}'") from exc
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else:
args.port = 443
Expand All @@ -270,6 +272,9 @@ def resolve_default_connection_values(args):
else:
args.verify = True

if not args.ssl_server_cert:
args.ssl_server_cert = os.getenv('SAP_SSL_SERVER_CERT') or config_values.get('ssl_server_cert')

if not args.user:
args.user = os.getenv('SAP_USER') or config_values.get('user')

Expand Down Expand Up @@ -316,7 +321,7 @@ def _apply_config_extra_params(args, config_values):
}

for param in extra_params:
if hasattr(args, param) and not getattr(args, param) and param in config_values:
if not getattr(args, param, None) and param in config_values:
setattr(args, param, config_values[param])


Expand Down
4 changes: 4 additions & 0 deletions sap/cli/_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def report_args_error_and_exit(args, error):
sys.exit(ExitCodes.INVALID_CONFIGURATION)


# pylint: disable=too-many-statements
def parse_command_line(argv):
"""Parses command line arguments"""

Expand Down Expand Up @@ -78,6 +79,9 @@ def parse_command_line(argv):
arg_parser.add_argument(
'--skip-ssl-validation', dest='verify', default=None, action='store_false',
help='Skip validation of SSL server certificates')
arg_parser.add_argument(
'--ssl-server-cert', dest='ssl_server_cert', type=str, default=None,
help='Path to a custom CA certificate file for SSL verification')
arg_parser.add_argument(
'--port', dest='port', type=int, default=None,
help='ADT HTTP port; default = 443')
Expand Down
66 changes: 61 additions & 5 deletions sap/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import yaml

import sap.cli.core
from sap.config import ConfigFile
from sap.config import ConfigFile, MERGEABLE_SECTIONS, fetch_config_source, merge_into


class CommandGroup(sap.cli.core.CommandGroup):
Expand Down Expand Up @@ -47,12 +47,22 @@ def current_context(_, args):
config_file = _get_config_file(args)
console = sap.cli.core.get_console()

context_name = getattr(args, 'name', None) or config_file.current_context

if context_name is None:
console.printerr('No current context is set.')
if not config_file.data:
console.printerr('No configuration file found.')
return 1

context_name = getattr(args, 'name', None)

if context_name is not None:
if context_name not in config_file.contexts:
console.printerr(f'Context \'{context_name}\' not found in configuration file.')
return 1
else:
context_name = config_file.current_context
if context_name is None:
console.printerr('No current context is set.')
return 1

console.printout(context_name)

return 0
Expand Down Expand Up @@ -106,3 +116,49 @@ def get_contexts(_, args):
console.printout(f'{marker} {name:<20s} {connection:<20s} {user}')

return 0


@CommandGroup.argument('--insecure', action='store_true', default=False,
help='Allow plain HTTP source URLs (not recommended)')
@CommandGroup.argument('--overwrite', action='store_true', default=False,
help='Overwrite existing entries with source values')
@CommandGroup.argument('--source', required=True,
help='Path or HTTPS URL to the source configuration file')
@CommandGroup.command()
def merge(_, args):
"""Merge a source configuration into the user config"""

console = sap.cli.core.get_console()

# --skip-ssl-validation is a global flag (dest='verify', store_false,
# default=None). Treat None as True (verify by default).
ssl_verify = getattr(args, 'verify', None)
if ssl_verify is None:
ssl_verify = True

source_data = fetch_config_source(args.source, insecure=args.insecure,
ssl_verify=ssl_verify)

config_file = _get_config_file(args)

summary = merge_into(config_file, source_data, overwrite=args.overwrite)

config_file.save()

has_changes = False
for section in MERGEABLE_SECTIONS:
added = summary['added'][section]
if added:
has_changes = True
console.printout(f'Added {section}: {", ".join(added)}')

for section in MERGEABLE_SECTIONS:
skipped = summary['skipped'][section]
if skipped:
has_changes = True
console.printout(f'Skipped {section} (already exist): {", ".join(skipped)}')

if not has_changes:
console.printout('Nothing to merge.')

return 0
Loading
Loading