diff --git a/.gitignore b/.gitignore index 5494e11..ed830cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/jwt +/coverage-report # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/nats.conf b/nats.conf new file mode 100644 index 0000000..7f3d846 --- /dev/null +++ b/nats.conf @@ -0,0 +1,18 @@ +port: 4222 + +authorization: { + "password": "test", + "user": "test" +} + +max_connections: 1 + +websocket: { +port: 8888 + +tls { + "ca_file": "/tmp/nats_tools_tls_asuvvj4m-/ca.crt", + "cert_file": "/tmp/nats_tools_tls_asuvvj4m-/server.crt", + "key_file": "/tmp/nats_tools_tls_asuvvj4m-/server.key" +} +} diff --git a/pyproject.toml b/pyproject.toml index f254cc8..0303f0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,8 +19,10 @@ dynamic = ["version"] dependencies = ["httpx", "jinja2"] [project.optional-dependencies] +tls = ["trustme"] build = ["build", "invoke", "pip-tools"] dev = [ + "trustme", "black", "isort", "invoke", diff --git a/src/nats_tools/__about__.py b/src/nats_tools/__about__.py index 3cc0574..b577548 100644 --- a/src/nats_tools/__about__.py +++ b/src/nats_tools/__about__.py @@ -16,4 +16,4 @@ print(__version__) ``` """ -__version__ = "1.0.2" +__version__ = "2.0.0" diff --git a/src/nats_tools/__init__.py b/src/nats_tools/__init__.py index 4c84e4c..60ccc77 100644 --- a/src/nats_tools/__init__.py +++ b/src/nats_tools/__init__.py @@ -1,5 +1,11 @@ from .__about__ import __version__ +from .config import ConfigGenerator, check_config from .natsd import NATSD, NATSMonitor -from .templates import ConfigGenerator -__all__ = ["__version__", "NATSD", "NATSMonitor", "ConfigGenerator"] +__all__ = [ + "__version__", + "NATSD", + "NATSMonitor", + "ConfigGenerator", + "check_config", +] diff --git a/src/nats_tools/cmd.py b/src/nats_tools/cmd.py new file mode 100644 index 0000000..2510042 --- /dev/null +++ b/src/nats_tools/cmd.py @@ -0,0 +1,38 @@ +import os +import shutil +import subprocess +import typing as t +from pathlib import Path + +DEFAULT_BIN_DIR = Path.home().joinpath("nats-server").absolute() + + +def get_executable(bin_name: str = "nats-server") -> str: + """Get path to nats-server executable""" + # User directory + if DEFAULT_BIN_DIR.joinpath(bin_name).is_file(): + return DEFAULT_BIN_DIR.joinpath(bin_name).as_posix() + elif DEFAULT_BIN_DIR.joinpath(bin_name + ".exe").is_file(): + return DEFAULT_BIN_DIR.joinpath(bin_name + ".exe").as_posix() + # Any directory within PATH + else: + path = shutil.which(bin_name) + if path is None: + raise FileNotFoundError("nats-server executable not found") + return path + + +def nats_server( + *args: str, + stdout: t.Optional[t.Any] = None, + stderr: t.Optional[t.Any] = None, + max_cpus: t.Optional[int] = None, + executable: t.Optional[str] = None, +) -> "subprocess.Popen[bytes]": + """Execute NATS server using subprocess.Popen""" + executable = executable or get_executable("nats-server") + args = (executable,) + args + env = os.environ.copy() + if max_cpus: + env["GOMAXPROCS"] = format(max_cpus, ".2f") + return subprocess.Popen(args, stdout=stdout, stderr=stderr, env=env) diff --git a/src/nats_tools/config/__init__.py b/src/nats_tools/config/__init__.py new file mode 100644 index 0000000..cfdaec8 --- /dev/null +++ b/src/nats_tools/config/__init__.py @@ -0,0 +1,5 @@ +from .generator import ConfigGenerator, render +from .options import ServerOptions +from .utils import check_config, non_null + +__all__ = ["ServerOptions", "ConfigGenerator", "render", "non_null", "check_config"] diff --git a/src/nats_tools/config/blocks/__init__.py b/src/nats_tools/config/blocks/__init__.py new file mode 100644 index 0000000..1829df7 --- /dev/null +++ b/src/nats_tools/config/blocks/__init__.py @@ -0,0 +1,39 @@ +from .accounts import ( + Account, + AccountJetStreamLimits, + ServiceExport, + ServiceImport, + Source, + StreamExport, + StreamImport, +) +from .authorization import Authorization +from .cluster import Cluster +from .jetstream import JetStream +from .leafnodes import LeafNodes, RemoteLeafnode +from .mqtt import MQTT +from .resolvers import NATSResolver +from .tls import TLS +from .users import Permissions, User +from .websocket import Websocket + +__all__ = [ + "Account", + "AccountJetStreamLimits", + "Authorization", + "Cluster", + "NATSResolver", + "JetStream", + "LeafNodes", + "MQTT", + "Permissions", + "RemoteLeafnode", + "ServiceExport", + "ServiceImport", + "Source", + "StreamExport", + "StreamImport", + "TLS", + "User", + "Websocket", +] diff --git a/src/nats_tools/config/blocks/accounts.py b/src/nats_tools/config/blocks/accounts.py new file mode 100644 index 0000000..a5f848a --- /dev/null +++ b/src/nats_tools/config/blocks/accounts.py @@ -0,0 +1,119 @@ +import typing as t +from dataclasses import dataclass + +from .users import User + + +@dataclass +class StreamExport: + """Bind a subject for use as a stream from other accounts. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/accounts#export-configuration-map + """ + + stream: str + """A subject or subject with wildcards that the account will publish.""" + + accounts: t.Optional[t.List[str]] = None + """A list of account names that can import the stream. If not specified, the stream is public and any account can import it.""" + + +@dataclass +class ServiceExport: + """Bind a subject for use as a service from other accounts. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/accounts#export-configuration-map + """ + + service: str + """A subject or subject with wildcards that the account will subscribe to.""" + + accounts: t.Optional[t.List[str]] = None + """A list of account names that can import the service. If not specified, the service is public and any account can import it.""" + + response_type: t.Optional[str] = None + """Indicates if a response to a service request consists of a single or a stream of messages. + Possible values are: single or stream. (Default value is singleton) + """ + + +@dataclass +class Source: + """Source configuration map. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/accounts#source-configuration-map + """ + + account: str + """Account name owning the export.""" + + subject: str + """The subject under which the stream or service is made accessible to the importing account""" + + +@dataclass +class StreamImport: + """Enables account to consume a stream published by another account. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/accounts#import-configuration-map + """ + + stream: Source + """Stream import source configuration.""" + + prefix: t.Optional[str] = None + """A local subject prefix mapping for the imported stream.""" + + +@dataclass +class ServiceImport: + """Enables account to consume a service implemented by another account. + + References: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/accounts#import-configuration-map + """ + + service: Source + """Service import source configuration.""" + + to: t.Optional[str] = None + """A local subject mapping for imported service.""" + + +@dataclass +class AccountJetStreamLimits: + """JetStream account limits. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/resource_management#setting-account-resource-limits. + """ + + max_mem: t.Union[str, int, None] = None + """Maximum size of the `memory` storage. Default unit is bytes when integer is provided.""" + + max_file: t.Union[str, int, None] = None + """Maximum size of the `file` storage. Default unit is bytes when integer is provided.""" + + max_streams: t.Optional[int] = None + """Maximum number of streams.""" + + max_consumers: t.Optional[int] = None + """Maximum number of consumers.""" + + +@dataclass +class Account: + """Multi-tenance account configuration. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/accounts + """ + + users: t.List[User] + """Users which can connect to the account using username/password auth.""" + + exports: t.Optional[t.Sequence[t.Union[ServiceExport, StreamExport]]] = None + """Define account exports.""" + + imports: t.Optional[t.Sequence[t.Union[StreamImport, ServiceImport]]] = None + """Define account imports.""" + + jetstream: t.Optional[AccountJetStreamLimits] = None + """Resource limits for the account.""" diff --git a/src/nats_tools/config/blocks/authorization.py b/src/nats_tools/config/blocks/authorization.py new file mode 100644 index 0000000..bd582e0 --- /dev/null +++ b/src/nats_tools/config/blocks/authorization.py @@ -0,0 +1,24 @@ +import typing as t +from dataclasses import dataclass + +from .users import User + + +@dataclass +class Authorization: + """Client Authentication/Authorization + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/auth_intro#authorization-map + """ + + user: t.Optional[str] = None + """Specifies a single global user name for clients to the server (exclusive of token)""" + + password: t.Optional[str] = None + """Specifies a single global password for clients to the server (exclusive of token).""" + + token: t.Optional[str] = None + """Specifies a global token that can be used to authenticate to the server (exclusive of user and password)""" + + users: t.Optional[t.List[User]] = None + """A list of user configuration maps. For multiple username and password credentials, specify a users list.""" diff --git a/src/nats_tools/config/blocks/cluster.py b/src/nats_tools/config/blocks/cluster.py new file mode 100644 index 0000000..a9c5e13 --- /dev/null +++ b/src/nats_tools/config/blocks/cluster.py @@ -0,0 +1,47 @@ +import typing as t +from dataclasses import dataclass + +from .authorization import Authorization +from .tls import TLS + + +@dataclass +class Cluster: + """NATS cluster mode configuration. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/clustering/cluster_config + """ + + host: t.Optional[str] = None + """Interface where the gateway will listen for incoming route connections.""" + + port: t.Optional[int] = None + """Port where the gateway will listen for incoming route connections.""" + + listen: t.Optional[str] = None + """Combines host and port as `:`.""" + + tls: t.Optional[TLS] = None + """A tls configuration map for securing the clustering connection. verify is always enabled and cert_file is used for client and server.""" + + name: t.Optional[str] = None + """Name of the cluster.""" + + advertise: t.Optional[str] = None + """Hostport : to advertise how this server can be contacted by other cluster members.""" + + no_advertise: t.Optional[bool] = None + """When set to 'true', the server will not send or gossip its client URLs to other servers in the cluster and will not tell its client about the other servers' client URLs.""" + + routes: t.Optional[t.List[str]] = None + """A list of other servers (URLs) to cluster with. Self-routes are ignored. Should authentication via token or username/password be required, specify them as part of the URL.""" + + connect_retries: t.Optional[int] = None + """After how many failed connect attempts to give up establishing a connection to a discovered route. Default is 0, do not retry. When enabled, attempts will be made once a second. This, does not apply to explicitly configured routes.""" + + authorization: t.Optional[Authorization] = None + """Authorization map for configuring cluster routes. + When a single username/password is used, it defines the authentication mechanism this server expects, + and how this server will authenticate itself when establishing a connection to a discovered route. + This will not be used for routes explicitly listed in routes and therefore have to be provided as + part of the URL.""" diff --git a/src/nats_tools/config/blocks/jetstream.py b/src/nats_tools/config/blocks/jetstream.py new file mode 100644 index 0000000..ee3565d --- /dev/null +++ b/src/nats_tools/config/blocks/jetstream.py @@ -0,0 +1,32 @@ +import typing as t +from dataclasses import dataclass + + +@dataclass +class JetStream: + """Enable and configure JetStream. + + Reference: https://docs.nats.io/running-a-nats-service/configuration#jetstream + """ + + store_dir: t.Optional[str] = None + """Directory to use for JetStream storage. + Default to `/tmp/nats/jetstream`.""" + + max_mem: t.Union[str, int, None] = None + """Maximum size of the `memory` storage. Default to `75%` of available memory.""" + + max_file: t.Union[str, int, None] = None + """Maximum size of the `file` storage. Up to `1TB` if available.""" + + cipher: t.Optional[str] = None + """Set to enable storage-level encryption at rest. Choose either `chachapoly` or `aes`""" + + key: t.Optional[str] = None + """The encryption key to use when encryption is enabled. A key length of at least 32 bytes is recommended. Note, this key is HMAC-256 hashed on startup which reduces the byte length to 64.""" + + max_outstanding_catchup: t.Optional[str] = None + """Max in-flight bytes for stream catch-up. Default to `32MB`.""" + + domain: t.Optional[str] = None + """Jetstream domain.""" diff --git a/src/nats_tools/config/blocks/leafnodes.py b/src/nats_tools/config/blocks/leafnodes.py new file mode 100644 index 0000000..ceff19e --- /dev/null +++ b/src/nats_tools/config/blocks/leafnodes.py @@ -0,0 +1,109 @@ +import typing as t +from dataclasses import dataclass + +from .authorization import Authorization +from .tls import TLS + + +@dataclass +class LeafnodeUser: + """Credentials and accounts to bind to leaf node connection. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/leafnodes/leafnode_conf#authorization-block + """ + + user: t.Optional[str] = None + """Username for the leaf node connection.""" + + password: t.Optional[str] = None + """Password for the user entry.""" + + account: t.Optional[str] = None + """Account this leaf node connection should be bound to.""" + + +@dataclass +class LeanodeAuthorization: + """Leafnode Authorization + + Reference: https://docs.nats.io/running-a-nats-service/configuration/leafnodes/leafnode_conf#authorization-block + """ + + user: t.Optional[str] = None + """Username for the leaf node connection.""" + + password: t.Optional[str] = None + """Password for the user entry.""" + + account: t.Optional[str] = None + """Account this leaf node connection should be bound to.""" + + timeout: t.Optional[int] = None + """Maximum number of seconds to wait for leaf node authentication.""" + + users: t.Optional[t.List[LeafnodeUser]] = None + """List of credentials and account to bind to leaf node connections.""" + + +@dataclass +class RemoteLeafnode: + """Leafnode remote configuration. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/leafnodes/leafnode_conf#leafnode-remotes-entry-block + """ + + url: t.Optional[str] = None + """Leafnode URL (URL protocol should be nats-leaf).""" + + urls: t.Optional[t.List[str]] = None + """Leafnode URL array. Supports multiple URLs for discovery, e.g., urls: [ "nats-leaf://host1:7422", "nats-leaf://host2:7422" ]""" + + account: t.Optional[str] = None + """Account name or JWT public key identifying the local account to bind to this remote server. Any traffic locally on this account will be forwarded to the remote server""" + + credentials: t.Optional[str] = None + """Credential file for connecting to the leafnode server.""" + + tls: t.Optional[TLS] = None + """A TLS configuration block. Leafnode client will use specified TLS certificates when connecting/authenticating.""" + + ws_compression: t.Optional[bool] = None + """If connecting with Websocket protocol, this boolean (true or false) indicates to the remote server that it wishes to use compression. The default is false.""" + + ws_no_masking: t.Optional[bool] = None + """If connecting with Websocket protocol, this boolean indicates to the remote server that it wishes not to mask outbound WebSocket frames. The default is false, which means that outbound frames will be masked.""" + + +@dataclass +class LeafNodes: + """Leafnodes configuration. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/leafnodes/leafnode_conf#leafnodes-configuration-block + """ + + host: t.Optional[str] = None + """Interface where the server will listen for incoming leafnode connections.""" + + port: t.Optional[int] = None + """Port where the server will listen for incoming leafnode connections.""" + + listen: t.Optional[str] = None + """Listen specification `:` for leafnode connections. Either use this or the options host and/or port.""" + + tls: t.Optional[TLS] = None + """TLS configuration block (same as other nats-server tls configuration).""" + + advertise: t.Optional[str] = None + """Hostport : to advertise how this server can be contacted by leaf nodes. This is useful in cluster setups with NAT""" + + no_advertise: t.Optional[bool] = None + """if true the server shouldn't be advertised to leaf nodes.""" + + authorization: t.Optional[Authorization] = None + """Leafnode authorization configuration.""" + + remotes: t.Optional[t.List[RemoteLeafnode]] = None + """List of remote entries specifying servers where leafnode client connection can be made.""" + + reconnect: t.Optional[int] = None + """Interval in seconds at which reconnect attempts to a remote server are made.""" diff --git a/src/nats_tools/config/blocks/mqtt.py b/src/nats_tools/config/blocks/mqtt.py new file mode 100644 index 0000000..3bb00f3 --- /dev/null +++ b/src/nats_tools/config/blocks/mqtt.py @@ -0,0 +1,43 @@ +import typing as t +from dataclasses import dataclass + +from .tls import TLS + + +@dataclass +class MQTT: + """""" + + listen: t.Optional[str] = None + """Specify a host and port to listen for MQTT connections.""" + + host: t.Optional[str] = None + """MQTT server listening host.""" + + port: t.Optional[int] = None + """MQTT server listening port.""" + + tls: t.Optional[TLS] = None + """TLS configuration""" + + no_auth_user: t.Optional[str] = None + """If no user name is provided when an MQTT client connects, will default this user name. + Note that this is not compatible with running the server in operator mode. + """ + + ack_wait: t.Optional[str] = None + """the amount of time after which a QoS 1 message sent to + a client is redelivered as a DUPLICATE if the server has not + received the PUBACK packet on the original Packet Identifier. + The value has to be positive. + Zero will cause the server to use the default value (30 seconds). + Expressed as a time duration, with "s", "m", "h" indicating seconds, + minutes and hours respectively. For instance "10s" for 10 seconds, + "1m" for 1 minute, etc... + """ + + max_ack_pending: t.Optional[int] = None + """amount of QoS 1 messages the server can send to + a subscription without receiving any PUBACK for those messages. + The valid range is [0..65535]. + """ diff --git a/src/nats_tools/config/blocks/resolvers.py b/src/nats_tools/config/blocks/resolvers.py new file mode 100644 index 0000000..80f0d7c --- /dev/null +++ b/src/nats_tools/config/blocks/resolvers.py @@ -0,0 +1,35 @@ +import typing as t +from dataclasses import dataclass + + +@dataclass +class NATSResolver: + """Full NATS resolver. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/auth_intro/jwt/resolver#full + """ + + type: t.Literal["full", "cache"] = "full" + """Resolver type: `full` or `cache`.""" + + dir: str = "./jwt" + """Directory in which the account jwts will be stored.""" + + allow_delete: bool = False + """When `true`, support JWT deletion.""" + + interval: str = "2m" + """Interval at which a nats-server with a nats based account resolver will compare + it's state with one random nats based account resolver in the cluster and if needed + exchange jwt and converge on the same set of jwt. + """ + + limit: t.Optional[int] = None + """Number of JWT to keep. + + For full resolvers, new JWT will be rejected once limit is reached. + For cache resolvers, old JWT are evicted on new JWT. + """ + + ttl: t.Optional[str] = None + """How long to hold on to a jwt before discarding it. """ diff --git a/src/nats_tools/config/blocks/tls.py b/src/nats_tools/config/blocks/tls.py new file mode 100644 index 0000000..e61abd7 --- /dev/null +++ b/src/nats_tools/config/blocks/tls.py @@ -0,0 +1,49 @@ +import typing as t +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class TLS: + """TLS Configuration. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/tls + """ + + cert_file: t.Union[str, Path, None] = None + """TLS certificate file.""" + + key_file: t.Union[str, Path, None] = None + """TLS certificate key file.""" + + ca_file: t.Union[str, Path, None] = None + """TLS certificate authority file. When not present, default to the system trust store.""" + + cipher_suites: t.Optional[t.List[str]] = None + """When set, only the specified TLS cipher suites will be allowed. Values must match the golang version used to build the server.""" + + curve_preferences: t.Optional[t.List[str]] = None + """List of TLS cipher curves to use in order.""" + + insecure: t.Optional[bool] = None + """Skip certificate verification. This only applies to outgoing connections, NOT incoming client connections. NOT Recommended.""" + + timeout: t.Optional[float] = None + """TLS handshake timeout in fractional seconds. Default set to 0.5 seconds.""" + + verify: t.Optional[bool] = None + """If true, require and verify client certificates. To support use by Browser, this option does not apply to monitoring.""" + + verify_and_map: t.Optional[bool] = None + """If true, require and verify client certificates and map certificate values for authentication purposes. Does not apply to monitoring either.""" + + verify_cert_and_check_known_urls: t.Optional[bool] = None + """Only settable in a non client context where verify: true is the default (cluster/gateway). + The incoming connections certificate's X509v3 Subject Alternative Name DNS entries will be matched against all urls in the configuration context that contains this tls map. + If a match is found, the connection is accepted and rejected otherwise. + """ + + pinned_certs: t.Optional[t.List[str]] = None + """List of hex-encoded SHA256 of DER encoded public key fingerprints. + When present, during the TLS handshake, the provided certificate's fingerprint is required to be present in the list or the connection is closed. + """ diff --git a/src/nats_tools/config/blocks/users.py b/src/nats_tools/config/blocks/users.py new file mode 100644 index 0000000..d35ce69 --- /dev/null +++ b/src/nats_tools/config/blocks/users.py @@ -0,0 +1,69 @@ +import typing as t +from dataclasses import dataclass + + +@dataclass +class Permission: + """Explicitely list subject to allow or deny. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/authorization#permission-map + """ + + allow: t.Optional[t.List[str]] = None + deny: t.Optional[t.List[str]] = None + + +@dataclass +class AllowResponses: + """Dynamically allows publishing to reply subjects. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/authorization#allow-responses-map + """ + + max: t.Optional[int] = None + expires: t.Optional[str] = None + + +@dataclass +class Permissions: + """The user permissions map specify subjects that can be subscribed to or published by the specified client. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/authorization#permissions-configuration-map + """ + + publish: t.Union[str, t.List[str], Permission, None] = None + """subject, list of subjects, or permission map the client can publish""" + + subscribe: t.Union[str, t.List[str], Permission, None] = None + """subject, list of subjects, or permission map the client can subscribe to.""" + + allow_responses: t.Union[bool, AllowResponses, None] = None + """boolean or responses map, default is false. + Enabling this implicitly denies publish to other subjects, however an explicit publish + allow on a subject will override this implicit deny for that subject. + """ + + +@dataclass +class User: + """Specifies credentials and permissions options for a single user. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/auth_intro#user-configuration-map + """ + + user: str # NOSONAR + """username for client authentication. (Can also be a user for tls authentication).""" + + password: str + """password for the user entry.""" + + nkey: t.Optional[str] = None + """public nkey identifying an user.""" + + permissions: t.Optional[Permissions] = None + """Permissions map configuring subjects accessible to the user.""" + + allowed_connection_types: t.Optional[ + t.List[t.Literal["STANDARD", "WEBSOCKET", "MQTT", "LEAFNODE"]] + ] = None + """Restrict which type of connections are allowed for a specific user.""" diff --git a/src/nats_tools/config/blocks/websocket.py b/src/nats_tools/config/blocks/websocket.py new file mode 100644 index 0000000..9a07be3 --- /dev/null +++ b/src/nats_tools/config/blocks/websocket.py @@ -0,0 +1,64 @@ +import typing as t +from dataclasses import dataclass + +from .tls import TLS + + +@dataclass +class Websocket: + """Enable websocket support. + + Reference: https://docs.nats.io/running-a-nats-service/configuration/websocket/websocket_conf + """ + + listen: t.Optional[str] = None + """Specify a host and port to listen for websocket connections.""" + + host: t.Optional[str] = None + """Websocket listening host.""" + + port: t.Optional[int] = None + """Websocket listening port.""" + + advertise: t.Optional[str] = None + """Specify what `:` to be advertised for websocket connections.""" + + tls: t.Optional[TLS] = None + """TLS Configuration is required by default. In order to disabled TLS, set `no_tls` to `true`.""" + + no_tls: t.Optional[bool] = None + """Disable need for TLS by explicitely settings `no_tls` to `true`.""" + + same_origin: t.Optional[bool] = None + """When set to `true`, the HTTP origin header must match the request’s hostname. + This option is used only when the http request presents an Origin + header, which is the case for web browsers. If no Origin header is present, + this check will not be performed. + """ + + allowed_origins: t.Optional[t.List[str]] = None + """List of accepted origins. + When empty, and `same_origin` is `false`, clients from any origin are allowed to connect. + """ + + compression: t.Optional[bool] = None + """enables support for compressed websocket frames in the server. + For compression to be used, both server and client have to support it. + """ + + handshake_timeout: t.Optional[str] = None + """total time allowed for the server to read the client request and write the response back + to the client. This includes the time needed for the TLS handshake. + """ + + jwt_cookie: t.Optional[str] = None + """Name for an HTTP cookie, that if present will be used as a client JWT. + + If the client specifies a JWT in the CONNECT protocol, this option is ignored. + """ + + no_auth_user: t.Optional[str] = None + """If no user name is provided when a websocket client connects, will default + this user name in the authentication phase. + Note that this is not compatible with running the server in operator mode. + """ diff --git a/src/nats_tools/config/generator.py b/src/nats_tools/config/generator.py new file mode 100644 index 0000000..a338d2e --- /dev/null +++ b/src/nats_tools/config/generator.py @@ -0,0 +1,74 @@ +import typing as t +from dataclasses import dataclass +from pathlib import Path + +from .options import ServerOptions +from .utils import ( + check_config, + load_template_from_name, + load_template_from_path, + non_null, +) + + +@dataclass +class GeneratorContext: + salt: t.Optional[bytes] = None + + @classmethod + def new(cls) -> "GeneratorContext": + ctx = cls() + return ctx + + +class ConfigGenerator: + def __init__( + self, + template: t.Union[str, Path] = "default.j2", + context: t.Optional[GeneratorContext] = None, + ) -> None: + """Create a new instance of config generator. + + Arguments: + template: the template used to render configuration. + """ + self.context = context or GeneratorContext.new() + if isinstance(template, Path): + self.template = load_template_from_path(template) + elif Path(template).is_file(): + self.template = load_template_from_path(template) + else: + self.template = load_template_from_name(template) + + def render( + self, + options: ServerOptions, + check: bool = True, + ) -> str: + """Render configuration according to arguments.""" + if options.leafnodes and options.leafnodes.listen: + options.leafnodes.host = None + options.leafnodes.port = None + values = non_null(options) + if options.server_tags: + values["server_tags"] = [ + ":".join([key, value]) for key, value in options.server_tags.items() + ] + config = self.template.render(values) + config = config.replace(r"\u003e", ">") + if check: + check_config(config) + return config + + +def render(options: ServerOptions, template: t.Union[str, Path, None] = None) -> str: + """Render a config from options. + + If you plan to use this function several times, prefer using + ConfigGenerator instance instead. + """ + opts: t.Dict[str, t.Any] = {} + if template: + opts["template"] = template + generator = ConfigGenerator(**opts) + return generator.render(options) diff --git a/src/nats_tools/config/options.py b/src/nats_tools/config/options.py new file mode 100644 index 0000000..65b408b --- /dev/null +++ b/src/nats_tools/config/options.py @@ -0,0 +1,236 @@ +import typing as t +from dataclasses import dataclass +from pathlib import Path + +from .blocks import ( + MQTT, + TLS, + Account, + Authorization, + Cluster, + JetStream, + LeafNodes, + NATSResolver, + Websocket, +) + + +@dataclass +class ConnectivityOptions: + """NATS Server Connectivity options. + + Reference: https://docs.nats.io/running-a-nats-service/configuration#connectivity + """ + + host: t.Optional[str] = "0.0.0.0" + """NATS Server listening host.""" + + port: t.Optional[int] = 4222 + """NATS Server listening port.""" + + listen: t.Optional[str] = None + """Listen specification `:` for client connections. Either use this or the options host and/or port.""" + + client_advertise: t.Optional[str] = None + """Alternative client listen specification `:` or just `` to advertise to clients and other server.""" + + tls: t.Optional[TLS] = None + """Require TLS for client connections.""" + + +@dataclass +class TimeoutsOptions: + """NATS Server Connection Timeouts. + + Reference: https://docs.nats.io/running-a-nats-service/configuration#connection-timeouts. + """ + + ping_interval: t.Optional[str] = None + """Duration at which pings are sent to clients, leaf nodes and routes. + In the presence of client traffic, such as messages or client side pings, the server will not send pings. + """ + + ping_max: t.Optional[int] = None + """After how many unanswered pings the server will allow before closing the connection.""" + + write_deadline: t.Optional[str] = None + """Maximum number of seconds the server will block when writing. Once this threshold is exceeded the connection will be closed. See slow consumer on how to deal with this on the client.""" + + +@dataclass +class LimitsOptions: + """NATS Server Limits. + + Reference: https://docs.nats.io/running-a-nats-service/configuration#limits""" + + max_connections: t.Union[int, str, None] = None + """Maximum number of active client connections. Default to `64K`.""" + + max_control_line: t.Optional[str] = None + """Maximum length of a protocol line (including combined length of subject and queue group). + Increasing this value may require client changes to be used. Applies to all traffic. Default to `4KB`.""" + + max_payload: t.Optional[str] = None + """Maximum number of bytes in a message payload. Default to `1MB`""" + + max_pending: t.Optional[str] = None + """Maximum number of bytes buffered for a connection Applies to client connections. Default to `64M`.""" + + max_subscriptions: t.Optional[int] = None + """Maximum numbers of subscriptions per client and leafnode accounts connection.""" + + +@dataclass +class MonitoringOptions: + """Monitoring & Tracing. + + Reference: https://docs.nats.io/running-a-nats-service/configuration#monitoring-and-tracing + """ + + server_name: t.Optional[str] = None + """Server name (default auto-generated). + When JetStream is used, withing a domain, all server names need to be unique. + """ + + server_tags: t.Optional[t.Dict[str, str]] = None + """Key value tags describing properties of the server. + This will be exposed through `/varz` and can be used for system resource requests, such as placement of streams. + Note that the keypair will be presented as strings separated by ':'. + """ + + trace: t.Optional[bool] = None + """If `true` enable protocol trace log messages. Excludes the system account.""" + + trace_verbose: t.Optional[bool] = None + """If `true` enable protocol trace log messages. Includes the system account.""" + + debug: t.Optional[bool] = None + """If `true` enable debug log messages.""" + + logtime: t.Optional[bool] = None + """If `false`, log without timestamps.""" + + log_file: t.Union[str, Path, None] = None + """Log file name, relative to process working directory.""" + + log_size_limit: t.Optional[int] = None + """Size in bytes after the log file rolls over to a new one.""" + + max_traced_msg_len: t.Optional[int] = None + """Set a limit to the trace of the payload of a message.""" + + syslog: t.Optional[bool] = None + """Log to syslog.""" + + remote_syslog: t.Optional[str] = None + """Syslog server address.""" + + http_port: t.Optional[int] = None + """HTTP listening port for server monitoring.""" + + http: t.Optional[str] = None + """Listen specification `:` for server monitoring.""" + + https_port: t.Optional[int] = None + """https port for server monitoring. This is influenced by the tls property.""" + + https: t.Optional[str] = None + """Listen specification `:` for TLS server monitoring.""" + + http_base_path: t.Optional[str] = None + """Base path for monitoring endpoints.""" + + system_account: t.Optional[str] = None + """Name of the system account. Users of this account can subscribe to system events.""" + + pid_file: t.Union[str, Path, None] = None + """File containing PID, relative to process working directory. + This can serve as input to nats-server --signal. + """ + + ports_file_dir: t.Union[str, Path, None] = None + """Directory to write a file containing the servers open ports to, relative to process working directory.""" + + connect_error_reports: t.Optional[int] = None + """Number of attempts at which a repeated failed route, gateway or leaf node connection is reported. + Connect attempts are made once every second. Errors are reported every hour by default. + """ + + reconnect_error_reports: t.Optional[int] = None + """Number of failed attempt to reconnect a route, gateway or leaf node connection. + Default is to report every attempt. + """ + + +@dataclass +class RuntimeOptions: + """NATS Server Runtime configuration. + + Reference: https://docs.nats.io/running-a-nats-service/configuration#runtime-configuration + """ + + disable_sublist_cache: t.Optional[bool] = None + """If true disable subscription caches for all accounts. This is saves resources in situations where different subjects are used all the time.""" + + lame_duck_duration: t.Optional[str] = None + """In lame duck mode the server rejects new clients and slowly closes client connections. After this duration is over the server shuts down. This value cannot be set lower than 30 seconds.""" + + lame_duck_grace_period: t.Optional[str] = None + """This is the duration the server waits, after entering lame duck mode, before starting to close client connections.""" + + +@dataclass +class AuthorizationOptions: + authorization: t.Optional[Authorization] = None + """Configuration map for client authentication/authorization.""" + + no_auth_user: t.Optional[str] = None + """Username present in the authorization block or an account. + A client connecting without any form of authentication will be associated with this user, its permissions and account. + Note that this is not compatible with the operator mode. + """ + + +@dataclass +class DecentralizedAuthorizationOptions: + accounts: t.Optional[t.Dict[str, Account]] = None + """Configuration map for multi tenancy via accounts.""" + + operator: t.Optional[str] = None + """Operator JWT or path to an operator JWT.""" + + resolver: t.Optional[NATSResolver] = None + """Enable built-in NATS resolver.""" + + resolver_preload: t.Optional[t.Dict[str, str]] = None + """Map to preload account public keys and their corresponding JWT. + Keys consist of ``, value is the ``. + """ + + +@dataclass +class ServerOptions( + RuntimeOptions, + LimitsOptions, + TimeoutsOptions, + AuthorizationOptions, + DecentralizedAuthorizationOptions, + MonitoringOptions, + ConnectivityOptions, +): + """NATS server options as a flat data structure.""" + + jetstream: t.Optional[JetStream] = None + """Enable and configure Jetstream.""" + + leafnodes: t.Optional[LeafNodes] = None + """Enable and configure leafnode support.""" + + cluster: t.Optional[Cluster] = None + """Enable and configure cluster support.""" + + websocket: t.Optional[Websocket] = None + """Enable and configure websocket support.""" + + mqtt: t.Optional[MQTT] = None + """Enable and configure MQTT support.""" diff --git a/src/nats_tools/config/templates/default.j2 b/src/nats_tools/config/templates/default.j2 new file mode 100644 index 0000000..75a041d --- /dev/null +++ b/src/nats_tools/config/templates/default.j2 @@ -0,0 +1,371 @@ +# Auto-generated +{% if host and not listen -%} +# NATS server listening host +host: {{ host }} +{% endif -%} +{% if port and not listen -%} +# NATS server listening port +port: {{ port }} +{% endif -%} +{% if listen -%} +# NATS server listening address +listen: {{ listen }} +{% endif -%} +{% if tls -%} +# Configure TLS for client connections +tls: {{ tls|tojson(indent=2) }} +{% endif -%} +{% if client_advertise -%} +# Address advertised to client +client_advertise: {{ client_advertise }} +{% endif -%} +{% if ping_interval -%} +# Duration at which pings are sent to clients, leaf nodes and routes +ping_interval: {{ ping_interval }} +{% endif -%} +{% if ping_max -%} +# After how many unanswered pings the server will allow before closing the connection +ping_max: {{ ping_max }} +{% endif -%} +{% if write_deadline -%} +# Maximum number of seconds the server will block when writing. Once this threshold is exceeded the connection will be closed +write_deadline: "{{ write_deadline }}" +{% endif -%} +{% if max_connections -%} +# Maximum number of active client connections +max_connections: {{ max_connections }} +{% endif -%} +{% if max_control_line -%} +# Maximum length of a protocol line (including combined length of subject and queue group) +max_control_line: {{ max_control_line }} +{% endif -%} +{% if max_payload -%} +# Maximum number of bytes in a message payload +max_payload: {{ max_payload }} +{% endif -%} +{% if max_pending -%} +# Maximum number of bytes in a message payload +max_pending: {{ max_pending }} +{% endif -%} +{% if max_subscriptions -%} +# Maximum numbers of subscriptions per client and leafnode accounts connection +max_subscriptions: {{ max_subscriptions }} +{% endif -%} +{% if server_name -%} +# NATS server name +server_name: {{ server_name }} +{% endif -%} +{% if server_tags is defined and server_tags -%} +# Key value tags describing properties of the server +# Tags will be exposed through `/varz` and can be used +# for system resource requests, such as placement of streams +server_tags: {{ server_tags|tojson(indent=2) }} +{% endif -%} +{% if trace -%} +# Enable protocol trace log messages (excluding the system account) +trace: true +{% endif -%} +{% if trace_verbose -%} +# Enable protocol trace log messages (including the system account) +trace_verbose: true +{% endif -%} +{% if debug -%} +# Enable debug log messages +debug: true +{% endif -%} +{% if logtime is defined and logtime is false -%} +# Log without timestamp +logtime: {{ logtime|tojson }} +{% endif -%} +{% if log_file -%} +# Write logs to file +log_file: {{ log_file }} +{% endif -%} +{% if log_size_limit is defined -%} +# Roll over to a new file after limit is reached +log_size_limit: {{ log_size_limit }} +{% endif -%} +{% if max_traced_msg_len is defined -%} +# Set a limit to the trace of the payload of a message +max_traced_msg_len: {{ max_traced_msg_len }} +{% endif -%} +{% if syslog -%} +# Log to syslog +syslog: true +{% endif -%} +{% if remote_syslog -%} +# Log to remote syslog +remote_syslog: {{ remote_syslog }} +{% endif -%} +{% if http_port -%} +# Enable monitoring endpoint +http_port: {{ http_port }} +{% endif -%} +{% if http -%} +# Enable monitoring endpoint +http: {{ http }} +{% endif -%} +{% if https_port -%} +# Enable monitoring endpoint with TLS +https_port: {{ https_port }} +{% endif -%} +{% if https -%} +# Enable monitoring endpoint with TLS +https: {{ https }} +{% endif -%} +{% if http_base_path -%} +# Base path for monitoring endpoint +http_base_path: {{ http_base_path }} +{% endif -%} +{% if system_account -%} +# Configure system account +system_account: {{ system_account }} +{% endif -%} +{% if pid_file -%} +# Write process PID to file +pid_file: {{ pid_file }} +{% endif -%} +{% if ports_file_dir -%} +# Write process PID to file within directory +# File will be named "nats-server_.ports" +# Directory MUST exist before starting nats-server +ports_file_dir: {{ ports_file_dir }} +{% endif -%} +{% if connect_error_reports -%} +# Number of attempts at which a repeated failed route, gateway or leaf node connection is reported +connect_error_reports: {{ connect_error_reports }} +{% endif -%} +{% if reconnect_error_reports -%} +# Number of attempts at which a repeated failed route, gateway or leaf node reconnect is reported +reconnect_error_reports: {{ reconnect_error_reports }} +{% endif -%} +{% if disable_sublist_cache -%} +# Disable subscription caches for all accounts +# This is saves resources in situations where different subjects are used all the time +disable_sublist_cache: true +{% endif -%} +{% if lame_duck_duration -%} +# In lame duck mode the server rejects new clients and slowly closes client connections +# After this duration is over the server shuts down +# Note that this value cannot be set lower than 30 seconds +lame_duck_duration: "{{ lame_duck_duration }}" +{% endif -%} +{% if lame_duck_grace_period -%} +# The duration the server waits (after entering lame duck mode) +# before starting to close client connections +lame_duck_grace_period: "{{ lame_duck_grace_period }}" +{% endif -%} +{% if authorization -%} +# Configuration map for client authentication/authorization +authorization: {{ authorization|tojson(indent=2) }} +{% endif -%} +{% if no_auth_user -%} +# A client connecting without any form of authentication will be associated with this user, its permissions and account +no_auth_user: "{{ no_auth_user }}" +{% endif -%} +{% if accounts -%} +# Enable multitenancy using accounts +accounts: {{ accounts|tojson(indent=2) }} +{% endif -%} +{% if operator -%} +# Enable operator authorization mode +operator: {{ operator }} +{% endif -%} +{% if resolver -%} +# Use NATS resolver to resolve accounts +resolver: {{ resolver|tojson(indent=2) }} +{% endif -%} +{% if resolver_preload -%} +# Accounts JWT allowed to connect to the server by default +# Once server is started, accounts can be managed using NATS resolver +# Note that only system account is allowed to communicate with NATS resolver +resolver_preload: {{ resolver_preload|tojson(indent=2) }} +{% endif -%} +{% if jetstream is defined and jetstream is not none and jetstream is not false -%} +# Enable NATS JetStream +jetstream: {{ jetstream|tojson(indent=2) }} +{% endif -%} +{% if leafnodes %} +# Configure inbound and outbound leafnodes connections +leafnodes: { + {% if leafnodes.listen -%} + # Listen for incoming leafnode connections + listen: {{ leafnodes.listen }} +{% endif -%} + {% if leafnodes.port or leafnodes.host -%} + # Listen for incoming leafnode connections + {% if leafnodes.host -%} + host: {{ leafnodes.host }} + {% endif -%} + {% if leafnodes.port -%} + port: {{ leafnodes.port }} +{% endif -%} +{% endif -%} + {% if leafnodes.advertise %} + # Advertise how this server can be contacted by leaf nodes. + advertise: {{ leafnodes.advertise }} +{% endif -%} + {% if leafnodes.no_advertise %} + # Indicate that server shouldn't be advertised to leaf nodes. + no_advertise: true +{% endif %} + {%- if leafnodes.remotes -%} + # Connect to remote leaf nodes + remotes: {{ leafnodes.remotes|tojson(indent=2)|indent(2) }} +{% endif -%} + {%- if leafnodes.reconnect -%} + # Interval in seconds at which reconnect attempts to a remote server are made + reconnect: {{ leafnodes.reconnect }} +{% endif -%} + {% if leafnodes.tls -%} + # Require leafnodes to connect using TLS + tls: {{ leafnodes.tls|tojson(indent=2)|indent(2) }} +{% endif -%} + {% if leafnodes.authorization -%} + authorization: {{ leafnodes.authorization|tojson(indent=2)|indent(2) }} +{% endif -%} +} +{% endif -%} +{% if cluster %} +# Configure cluster mode +cluster: { + {% if cluster.name -%} + name: {{ cluster.name }} +{% endif -%} + {% if cluster.host or cluster.port or cluster.listen -%} + # Listen for incoming cluster connections + {% if cluster.host -%} + host: {{ cluster.host }} +{% endif -%} + {% if cluster.port -%} + port: {{ cluster.port }} +{% endif -%} + {% if cluster.listen -%} + listen: {{ cluster.listen }} +{% endif -%} +{% endif -%} + {% if cluster.advertise -%} + # Hostport : to advertise how this server can be contacted by other cluster members + advertise: {{ cluster.advertise }} +{% endif -%} + {% if cluster.no_advertise -%} + # Do not send or gossip server client URLs to other servers in the cluster + # Also prevent server telling its client about the other servers' client URLs + no_advertise: true +{% endif -%} + {% if cluster.routes -%} + # A list of other servers (URLs) to cluster with. Self-routes are ignored. + # Should authentication via token or username/password be required, specify them as part of the URL + routes: {{ cluster.routes|tojson(indent=2)|indent(2) }} +{% endif -%} + {% if cluster.connect_retries -%} + # After how many failed connect attempts to give up establishing a connection to a discovered route. Default is 0, do not retry. When enabled, attempts will be made once a second. + # This, does not apply to explicitly configured routes + connect_retries: {{ cluster.connect_retries }} +{% endif -%} + {% if cluster.tls -%} + # Configure TLS for communications between cluster members + tls: {{ cluster.tls|tojson(indent=2)|indent(2) }} +{% endif -%} + {% if cluster.authorization -%} + authorization: {{ cluster.authorization|tojson(indent=2)|indent(2) }} +{% endif -%} +} +{% endif -%} +{% if websocket %} +# Configure websocket server +websocket: { + {% if websocket.host or websocket.port or websocket.listen -%} + # Listen for incoming websocket connections + {% if websocket.host -%} + host: {{ websocket.host }} +{% endif -%} + {% if websocket.port -%} + port: {{ websocket.port }} +{% endif -%} + {% if websocket.listen -%} + listen: {{ websocket.listen }} +{% endif -%} +{% endif -%} + {% if websocket.advertise -%} + # Hostport : to to be advertised for websocket connections + advertise: {{ websocket.advertise }} +{% endif -%} + {% if websocket.tls -%} + # Configure TLS + tls: {{ websocket.tls|tojson(indent=2)|indent(2) }} +{% endif -%} + {% if websocket.no_tls -%} + # Serve plain websocket instead of secured websockets + # Use it only when NATS is served behind a reverse-proxy + # or during development + no_tls: true +{% endif -%} + {% if websocket.same_origin -%} + # HTTP origin header must match the request’s hostname + # If no Origin header is present, this check will not be performed + same_origin: true +{% endif -%} + {% if websocket.allowed_origins -%} + # HTTP origin header must match one of allowed origins + # If no Origin header is present, this check will not be performed + allowed_origins: {{ websocket.allowed_origins|tojson(indent=2)|indent(2) }} +{% endif -%} + {% if websocket.compression -%} + # Enable support for compressed websocket frames in the server + # Note: for compression to be used, both server and client have to support it + compression: true +{% endif -%} + {% if websocket.handshake_timeout -%} + # Total time allowed for the server to read the client request and write the response back + # to the client. + handshake_timeout: "{{ websocket.handshake_timeout }}" +{% endif -%} + {% if websocket.jwt_cookie -%} + # Name for an HTTP cookie, that if present will be used as a client JWT + # If the client specifies a JWT in the CONNECT protocol, this option is ignored + jwt_cookie: "{{ websocket.jwt_cookie }}" +{% endif -%} + {% if websocket.no_auth_user -%} + # A client connecting without any form of authentication will be associated with this user, its permissions and account + no_auth_user: "{{ websocket.no_auth_user }}" +{% endif -%} +} +{% endif -%} +{% if mqtt %} +# Configure MQTT server +mqtt: { + {% if mqtt.host or mqtt.port or mqtt.listen -%} + # Listen for incoming MQTT connections + {% if mqtt.host -%} + host: {{ mqtt.host }} +{% endif -%} + {% if mqtt.port -%} + port: {{ mqtt.port }} +{% endif -%} + {% if mqtt.listen -%} + listen: {{ mqtt.listen }} +{% endif -%} +{% endif -%} + {% if mqtt.tls -%} + # Configure TLS + tls: {{ mqtt.tls|tojson(indent=2)|indent(2) }} +{% endif -%} + {% if mqtt.no_auth_user -%} + # A client connecting without any form of authentication will be associated with this user, its permissions and account + no_auth_user: "{{ mqtt.no_auth_user }}" +{% endif -%} + {% if mqtt.ack_wait -%} + # The amount of time after which a QoS 1 message sent to + # a client is redelivered as a DUPLICATE if the server has not + # received the PUBACK packet on the original Packet Identifier + ack_wait: "{{ mqtt.ack_wait }}" +{% endif -%} + {% if mqtt.max_ack_pending -%} + # amount of QoS 1 messages the server can send to + # a subscription without receiving any PUBACK for those messages + # The valid range is [0..65535] + max_ack_pending: {{ mqtt.max_ack_pending }} +{% endif -%} +} +{% endif -%} diff --git a/src/nats_tools/config/utils.py b/src/nats_tools/config/utils.py new file mode 100644 index 0000000..b03c179 --- /dev/null +++ b/src/nats_tools/config/utils.py @@ -0,0 +1,73 @@ +import subprocess +import tempfile +import typing as t +from dataclasses import asdict, is_dataclass +from pathlib import Path + +import jinja2 + +from ..cmd import nats_server +from ..errors import InvalidConfigError + + +def check_config(config: str) -> None: + with tempfile.TemporaryDirectory(prefix="nats_tools_", suffix="-") as tempdir: + config_file = Path(tempdir) / "nats.conf" + config_file.write_text(config) + process = nats_server( + "--config", + config_file.as_posix(), + "-t", + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + stderr: bytes + _, stderr = process.communicate() + if process.returncode != 0: + error = error = ( + stderr.split(b"nats-server:", maxsplit=1)[1].strip().decode() + ) + error_config_file = config_file.parent.parent.joinpath( + tempdir + "nats.conf" + ) + error_config_file.write_text(config) + if config_file.as_posix() in error: + error = error.replace( + config_file.as_posix(), error_config_file.as_posix() + ) + else: + error = f"({error_config_file.as_posix()}) {error}" + raise InvalidConfigError(error) + + +def non_null(values: t.Any) -> t.Any: + """Get a dictionary out of a dataclass""" + if is_dataclass(values): + values = asdict(values) + if isinstance(values, dict): + for k, v in values.copy().items(): + if v is None: + values.pop(k) + else: + values[k] = non_null(v) + elif isinstance(values, list): + for idx, value in enumerate(values.copy()): + values[idx] = non_null(value) + return values + + +def load_template_from_path(template: t.Union[str, Path]) -> jinja2.Template: + """Load a jinja2 template from path.""" + filepath = Path(template).absolute() + if not filepath.exists(): + raise FileNotFoundError(filepath.as_posix()) + loader = jinja2.FileSystemLoader(filepath.parent) + environment = jinja2.Environment(loader=loader, autoescape=False) + return environment.get_template(filepath.name) + + +def load_template_from_name(template: str) -> jinja2.Template: + """Load a jinja2 template from name.""" + loader = jinja2.FileSystemLoader(Path(__file__).parent.joinpath("templates")) + environment = jinja2.Environment(loader=loader, autoescape=True) + return environment.get_template(template) diff --git a/src/nats_tools/errors.py b/src/nats_tools/errors.py new file mode 100644 index 0000000..a0db339 --- /dev/null +++ b/src/nats_tools/errors.py @@ -0,0 +1,2 @@ +class InvalidConfigError(Exception): + pass diff --git a/src/nats_tools/monitor.py b/src/nats_tools/monitor.py index 0065b44..60bc6a7 100644 --- a/src/nats_tools/monitor.py +++ b/src/nats_tools/monitor.py @@ -6,32 +6,44 @@ class SortOption(str, Enum): - # Connection ID CID = "cid" - # Connection start time, same as CID + """Connection ID""" + START = "start" - # Number of subscriptions + """Connection start time, same as CID""" + SUBS = "subs" - # Amount of data in bytes waiting to be sent to client + """Number of subscriptions""" + PENDING = "pending" - # Number of messages sent + """Amount of data in bytes waiting to be sent to client""" + MSGS_TO = "msgs_to" - # Number of messages received + """Number of messages sent""" + MSGS_FROM = "msgs_from" - # Number of bytes sent + """Number of messages received""" + BYTES_TO = "bytes_to" - # Number of bytes received + """Number of bytes sent""" + BYTES_FROM = "bytes_from" - # Last activity + """Number of bytes received""" + LAST = "last" - # Amount of inactivity + """Last activity""" + IDLE = "idle" - # Lifetime of the connection + """Amount of inactivity""" + UPTIME = "uptime" - # Stop time for a closed connection + """Lifetime of the connection""" + STOP = "stop" - # Reason for a closed connection + """Stop time for a closed connection""" + REASON = "reason" + """Reason for a closed connection""" class SubsOption(Enum): diff --git a/src/nats_tools/natsd.py b/src/nats_tools/natsd.py index b57c146..09368cd 100644 --- a/src/nats_tools/natsd.py +++ b/src/nats_tools/natsd.py @@ -13,264 +13,84 @@ import httpx -from nats_tools.monitor import NATSMonitor -from nats_tools.templates import ConfigGenerator +from .cmd import nats_server +from .config import ConfigGenerator, ServerOptions +from .monitor import NATSMonitor -DEFAULT_BIN_DIR = Path.home().joinpath("nats-server").absolute() - -class InvalidWindowsSignal(Enum): - SIGKILL = "KILL" - SIGQUIT = "QUIT" - SIGHUP = "HUP" - SIGUSR1 = "USR1" - SIGUSR2 = "USR2" - - -if os.name == "nt": - - class Signal(Enum): - # Kill the process immediatley - KILL = InvalidWindowsSignal.SIGKILL - # Kills the process immediately and perform a core dump - QUIT = InvalidWindowsSignal.SIGKILL - # Stops the server grafefully - STOP = signal.SIGTERM - # Reopens the log file for log rotation - REOPEN = InvalidWindowsSignal.SIGUSR1 - # Reload server configuration - RELOAD = InvalidWindowsSignal.SIGHUP - # Stops the server after evicting all clients - LDM = InvalidWindowsSignal.SIGUSR2 - -else: - - class Signal(Enum): # type: ignore[no-redef] - # Kill the process immediatley - KILL = signal.SIGKILL - # Kills the process immediately and perform a core dump - QUIT = signal.SIGQUIT - # Stops the server grafefully - STOP = signal.SIGTERM - # Reopens the log file for log rotation - REOPEN = signal.SIGUSR1 - # Reload server configuration - RELOAD = signal.SIGHUP - # Stops the server after evicting all clients - LDM = signal.SIGUSR2 +class Signal(str, Enum): + KILL = "KILL" + QUIT = "QUIT" + STOP = "STOP" + REOPEN = "REOPEN" + RELOAD = "RELOAD" + LDM = "LDM" class NATSD: def __init__( self, - address: str = "127.0.0.1", - port: int = 4222, - client_advertise: t.Optional[str] = None, - server_name: t.Optional[str] = None, - server_tags: t.Optional[t.Dict[str, str]] = None, - user: t.Optional[str] = None, - password: t.Optional[str] = None, - users: t.Optional[t.List[t.Dict[str, t.Any]]] = None, - token: t.Optional[str] = None, - http_port: int = 8222, - debug: t.Optional[bool] = None, - trace: t.Optional[bool] = None, - trace_verbose: t.Optional[bool] = None, - logtime: t.Optional[bool] = None, - pid_file: t.Union[str, Path, None] = None, - port_file_dir: t.Union[str, Path, None] = None, - log_file: t.Union[str, Path, None] = None, - log_size_limit: t.Optional[int] = None, - tls_cert: t.Union[str, Path, None] = None, - tls_key: t.Union[str, Path, None] = None, - tls_ca_cert: t.Union[str, Path, None] = None, - cluster_name: t.Optional[str] = None, - cluster_url: t.Optional[str] = None, - cluster_listen: t.Optional[str] = None, - routes: t.Optional[t.List[str]] = None, - no_advertise: t.Optional[bool] = None, - with_jetstream: bool = False, - jetstream_domain: t.Optional[str] = None, - store_directory: t.Union[str, Path, None] = None, - max_memory_store: t.Optional[int] = None, - max_file_store: t.Optional[int] = None, - max_outstanding_catchup: t.Optional[int] = None, - allow_leafnodes: bool = False, - leafnodes_listen_address: t.Optional[str] = None, - leafnodes_listen_port: t.Optional[int] = None, - leafnode_remotes: t.Optional[t.Dict[str, t.Any]] = None, - websocket_listen_address: t.Optional[str] = None, - websocket_listen_port: t.Optional[int] = None, - websocket_advertise_url: t.Optional[str] = None, - websocket_tls: t.Optional[bool] = None, - websocket_tls_cert: t.Union[str, Path, None] = None, - websocket_tls_key: t.Union[str, Path, None] = None, - websocket_same_origin: t.Optional[bool] = None, - websocket_allowed_origins: t.Optional[t.List[str]] = None, - websocket_compression: t.Optional[bool] = None, - jwt_path: t.Union[str, Path, None] = None, - operator: t.Optional[str] = None, - system_account: t.Optional[str] = None, - system_account_jwt: t.Optional[str] = None, - allow_delete_jwt: t.Optional[bool] = None, - compare_jwt_interval: t.Optional[str] = None, - resolver_preload: t.Optional[t.Dict[str, str]] = None, - config_file: t.Union[str, Path, None] = None, - max_cpus: t.Optional[float] = None, - start_timeout: float = 1, + options: t.Optional[ServerOptions] = None, + max_cpus: t.Optional[int] = None, + config_file: t.Optional[str] = None, + store_dir: t.Optional[str] = None, + redirect_output: bool = False, + start_timeout: float = 5, + stop_timeout: float = 15, ) -> None: - """Create a new instance of nats-server daemon. - - Arguments: - address: host address nats-server should listen to. Default is 127.0.0.1 (localhost). - port: tcp port nats-server should listen to. Clients can connect to this port. Default is 4222. - server_name: the server name. Default to auto-generated name. - user: username required for connections. Omitted by default. - password: password required for connections. Omitted by default. - token: authorization token required for connections. Omitted by default. - http_port: port for http monitoring. Default is 8222. - debug: enable debugging output. Default is False. - trace: enable raw traces. Default is False. - pid_file: file to write process ID to. Omitted by default. - log_file: file to redirect log output to. Omitted by default. - tls_cert: server certificate file (TLS is enabled when both cert and key are provided) - tls_key: server key file (TLS is enabled when both cert and key are provided) - tls_ca_cert: client certificate for CA verification (mutual TLS is enabled when ca cert is provided) - cluster_name: the cluster name. Default to auto-generated name when clustering is enabled. - cluster_url: cluster URL for sollicited routes. - cluster_listen: cluster URL from which members can solicite routes. Enable cluster mode when set. - routes: routes to solicit and connect. - no_advertise: do not advertise known cluster information to clients. - with_jetstream: enable jetstream engine when True. Disabled by default. - store_directory: path to jetstream store directory. Default to a temporary directory. - config_file: path to a configuration file. None by default. - max_cpus: maximum number of CPU configured using GOMAXPROCS environment variable. By default all CPUs can be used. - start_timeout: amount of time to wait before raising an error when starting the daemon with wait=True. - """ - if config_file is None: - config_file = Path(tempfile.mkdtemp()).joinpath("nats.conf") - generator = ConfigGenerator() - config_str = generator.render( - address=address, - port=port, - client_advertise=client_advertise, - server_name=server_name, - server_tags=server_tags, - user=user, - password=password, - users=users, - token=token, - http_port=http_port, - debug=debug, - trace=trace, - trace_verbose=trace_verbose, - logtime=logtime, - pid_file=pid_file, - port_file_dir=port_file_dir, - log_file=log_file, - log_size_limit=log_size_limit, - tls_cert=tls_cert, - tls_key=tls_key, - tls_ca_cert=tls_ca_cert, - cluster_name=cluster_name, - cluster_url=cluster_url, - cluster_listen=cluster_listen, - routes=routes, - no_advertise=no_advertise, - with_jetstream=with_jetstream, - jetstream_domain=jetstream_domain, - store_directory=store_directory, - max_memory_store=max_memory_store, - max_file_store=max_file_store, - max_outstanding_catchup=max_outstanding_catchup, - allow_leafnodes=allow_leafnodes, - leafnodes_listen_address=leafnodes_listen_address, - leafnodes_listen_port=leafnodes_listen_port, - leafnode_remotes=leafnode_remotes, - websocket_listen_address=websocket_listen_address, - websocket_listen_port=websocket_listen_port, - websocket_advertise_url=websocket_advertise_url, - websocket_tls=websocket_tls, - websocket_tls_cert=websocket_tls_cert, - websocket_tls_key=websocket_tls_key, - websocket_same_origin=websocket_same_origin, - websocket_allowed_origins=websocket_allowed_origins, - websocket_compression=websocket_compression, - jwt_path=jwt_path, - operator=operator, - system_account=system_account, - system_account_jwt=system_account_jwt, - allow_delete_jwt=allow_delete_jwt, - compare_jwt_interval=compare_jwt_interval, - resolver_preload=resolver_preload, - ) - config_file.write_text(config_str) - weakref.finalize(self, shutil.rmtree, config_file.parent, True) - self.server_name = server_name - self.address = address - self.port = port - self.user = user - self.password = password - self.timeout = start_timeout - self.http_port = http_port - self.token = token - self.bin_name = "nats-server" - self.bin_path: t.Optional[str] = None - self.config_file = Path(config_file) if config_file else None - self.debug = debug or os.environ.get("DEBUG_NATS_TEST", "") in ( - "true", - "1", - "y", - "yes", - "on", - ) - self.trace = trace or os.environ.get("DEBUG_NATS_TEST", "") in ( - "true", - "1", - "y", - "yes", - "on", - ) - self.pid_file = Path(pid_file).absolute().as_posix() if pid_file else None - self.log_file = Path(log_file).absolute().as_posix() if log_file else None + """Create a new instance of nats-server daemon.""" + # Whether to redirect stdout/stderr + self.redirect_output = redirect_output + # Store timeout + self.start_timeout = start_timeout + self.stop_timeout = stop_timeout + # Store options + self.options = options or ServerOptions() + # Store max CPU self.max_cpus = max_cpus - - self.tls_cert = tls_cert - self.tls_key = tls_key - self.tls_ca_cert = tls_ca_cert - if self.tls_ca_cert and self.tls_cert and self.tls_key: - self.tls_verify = True - self.tls = False - elif self.tls_cert and self.tls_key: - self.tls_verify = False - self.tls = True - elif self.tls_ca_cert: - raise ValueError( - "Both certificate and key files must be provided with a CA certificate" + # Extract monitoring endpoint + http_base_path = self.options.http_base_path or "/" + if not ( + self.options.http + or self.options.http_port + or self.options.https_port + or self.options.https + ): + self.options.http = "127.0.0.1:8222" + self.monitoring_endpoint = f"http://{self.options.http}{http_base_path}" + elif self.options.http_port: + self.monitoring_endpoint = ( + f"http://127.0.0.1:{self.options.http_port}{http_base_path}" ) - elif self.tls_cert or self.tls_key: - raise ValueError("Both certificate and key files must be provided") + elif self.options.http: + self.monitoring_endpoint = f"http://{self.options.http}{http_base_path}" else: - self.tls = False - self.tls_verify = False - - self.cluster_name = cluster_name - self.cluster_url = cluster_url - self.cluster_listen = cluster_listen - self.routes = routes - self.no_advertise = no_advertise - - self.jetstream_enabled = with_jetstream - if store_directory: - self.store_dir = Path(store_directory) - self._store_dir_is_temporary = False + raise NotImplementedError( + "NATSD instances cannot be created with TLS enabled monitoring endpoint" + ) + # Generate config + generator = ConfigGenerator() + config_str = generator.render(self.options) + # Determine path to config file + if config_file is None: + self.config_file = Path(tempfile.mkdtemp()).joinpath("nats.conf") + weakref.finalize(self, shutil.rmtree, self.config_file.parent, True) else: - self.store_dir = Path(tempfile.mkdtemp()).resolve(True) - self._store_dir_is_temporary = True - weakref.finalize(self, shutil.rmtree, self.store_dir.as_posix(), True) - + self.config_file = Path(config_file).expanduser().absolute() + # Write config to file + self.config_file.write_text(config_str) + # Check is store dir was provided or needs to be created + if self.options.jetstream: + if self.options.jetstream.store_dir is None and store_dir is None: + self.options.jetstream.store_dir = tempfile.mkdtemp() + # Clean-up temporary store dir on exit + weakref.finalize( + self, shutil.rmtree, self.options.jetstream.store_dir, True + ) + # Initialize subprocess attribute self.proc: t.Optional["subprocess.Popen[bytes]"] = None - self.monitor = NATSMonitor(f"http://{self.address}:{self.http_port}") + # Create a monitor instance + self.monitor = NATSMonitor(self.monitoring_endpoint) def is_alive(self) -> bool: if self.proc is None: @@ -279,85 +99,46 @@ def is_alive(self) -> bool: def _cleanup_on_exit(self) -> None: if self.proc and self.proc.poll() is None: - print( - "[\033[0;31mWARNING\033[0;0m] Stopping server listening on %d." - % self.port - ) self.kill() def start(self, wait: bool = False) -> "NATSD": - # Check if there is an nats-server binary in the current working directory - if Path(self.bin_name).is_file(): - self.bin_path = Path(self.bin_name).resolve(True).as_posix() - # Path in `../scripts/install_nats.sh` - elif DEFAULT_BIN_DIR.joinpath(self.bin_name).is_file(): - self.bin_path = DEFAULT_BIN_DIR.joinpath(self.bin_name).as_posix() - # This directory contains binary - else: - self.bin_path = shutil.which(self.bin_name) - if self.bin_path is None: - raise FileNotFoundError("nats-server executable not found") - - cmd = [ - self.bin_path, - "-p", - "%d" % self.port, - "-m", - "%d" % self.http_port, - "-a", - self.address, - ] - - if self.config_file is not None: - if not self.config_file.exists(): - raise FileNotFoundError(self.config_file) - else: - config_file = self.config_file.absolute().as_posix() - cmd.append("--config") - cmd.append(config_file) - - env = os.environ.copy() - - if self.max_cpus: - env["GOMAXPROCS"] = format(self.max_cpus, ".2f") - - if self.debug: - self.proc = subprocess.Popen(cmd, env=env) + """Start NATS server.""" + if self.redirect_output: + self.proc = nats_server( + "--config", + self.config_file.as_posix(), + max_cpus=self.max_cpus, + stdout=None, + stderr=None, + ) else: - self.proc = subprocess.Popen( - cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env + self.proc = nats_server( + "--config", + self.config_file.as_posix(), + max_cpus=self.max_cpus, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) - if self.debug: - print( - "[\033[0;33mDEBUG\033[0;0m] Server listening on port %d started." - % self.port - ) if wait: - deadline = time.time() + self.timeout or float("inf") + deadline = time.time() + self.start_timeout or float("inf") while True: status = self.proc.poll() if status is not None: - if self.debug: - print( - "[\033[0;31mWARNING\033[0;0m] Server listening on port {port} already finished running with exit {ret}".format( - port=self.port, ret=self.proc.returncode - ) - ) raise subprocess.CalledProcessError( returncode=self.proc.returncode, cmd=self.proc.args ) if time.time() > deadline: self.stop() raise TimeoutError( - f"nats-server failed to start before timeout ({self.timeout:.3f}s)" + f"nats-server failed to start before timeout ({self.start_timeout:.3f}s)" ) try: self.monitor.varz() break except httpx.HTTPError as exc: print( - f"[\033[0;31mDEBUG\033[0;0m] Waiting for server to be up. Last error: {type(exc).__name__} - {repr(exc)}." + f"DEBUG: Waiting for server to be up. Last error: {type(exc).__name__} - {repr(exc)}." ) time.sleep(0.1) continue @@ -365,37 +146,16 @@ def start(self, wait: bool = False) -> "NATSD": weakref.finalize(self, self._cleanup_on_exit) return self - def stop(self, timeout: t.Optional[float] = 10) -> None: - if self.debug: - print( - "[\033[0;33mDEBUG\033[0;0m] Server listening on %d will stop." - % self.port - ) - + def stop(self, timeout: t.Optional[float] = None) -> None: if self.proc is None: - if self.debug: - print( - "[\033[0;31mWARNING\033[0;0m] Failed terminating server listening on port %d" - % self.port - ) - - elif self.proc.returncode is not None: - if self.debug: - print( - "[\033[0;31mWARNING\033[0;0m] Server listening on port {port} already finished running with exit {ret}".format( - port=self.port, ret=self.proc.returncode - ) - ) + return + if self.proc.poll() is not None: + return else: try: - self.term(timeout=timeout) + self.term(timeout=timeout or self.stop_timeout) except TimeoutError: self.kill() - if self.debug: - print( - "[\033[0;33mDEBUG\033[0;0m] Server listening on %d was stopped." - % self.port - ) expected = 15 if os.name == "nt" else 1 if self.proc and self.proc.returncode != expected: raise subprocess.CalledProcessError( @@ -405,7 +165,7 @@ def stop(self, timeout: t.Optional[float] = 10) -> None: def wait(self, timeout: t.Optional[float] = None) -> int: """Wait for process to finish and return status code. - Possible status codes (non-exhaustive): + Possible status codes on Linux (non-exhaustive): -1: process is not started yet. 0: process has been stopped after entering lame duck mode. 1: process has been stopped due to TERM signal. @@ -419,54 +179,40 @@ def wait(self, timeout: t.Optional[float] = None) -> int: return status return self.proc.wait(timeout=timeout) - def send_signal(self, sig: t.Union[int, signal.Signals, Signal]) -> None: - if self.proc is None: - raise TypeError("Process is not started yet") - status = self.proc.poll() - if status is not None: - raise subprocess.CalledProcessError(status, cmd=self.proc.args) - if os.name != "nt": - if not isinstance(sig, Signal): - sig = signal.Signals(sig) - sig = Signal(sig) - os.kill(self.proc.pid, sig.value) - else: - sig = Signal(sig) - if isinstance(sig.value, InvalidWindowsSignal): - # Use a subprocess to explicitely call `nats-server --signal` which will handle signal correctly on Windows - if sig.value == InvalidWindowsSignal.SIGKILL: - os.kill(self.proc.pid, signal.SIGINT) - elif sig.value == InvalidWindowsSignal.SIGQUIT: - os.kill(self.proc.pid, signal.SIGBREAK) # type: ignore[attr-defined] - elif sig.value == InvalidWindowsSignal.SIGHUP: - warnings.warn("Config reload is not supported on Windows") - elif sig.value == InvalidWindowsSignal.SIGUSR1: - warnings.warn("Log file roration is not supported on Windows") - elif sig.value == InvalidWindowsSignal.SIGUSR2: - warnings.warn("Lame Duck Mode is not supported on Windows") - os.kill(self.proc.pid, signal.SIGINT) - else: - os.kill(self.proc.pid, sig.value) - def quit(self, timeout: t.Optional[float] = None) -> None: + """Send signal to immediately kill process with a core dump and wait until process exits.""" self.send_signal(Signal.QUIT) - self.wait(timeout=timeout) + self.wait(timeout=timeout or self.stop_timeout) def kill(self, timeout: t.Optional[float] = None) -> None: + """Send signal to immediately kill process and wait until process exits.""" self.send_signal(Signal.KILL) - self.wait(timeout=timeout) + self.wait(timeout=timeout or self.stop_timeout) - def term(self, timeout: t.Optional[float] = 10) -> None: + def term(self, timeout: t.Optional[float] = None) -> None: + """Send signal to gracefully terminate process and wait until process exits.""" self.send_signal(Signal.STOP) - self.wait(timeout=timeout) + self.wait(timeout=timeout or self.stop_timeout) def reopen_log_file(self) -> None: + """Send signal to reopen log file. + + NOTE: Not supported on Windows except when running nats-server as a service + """ self.send_signal(Signal.REOPEN) def enter_lame_duck_mode(self) -> None: + """Send signal to enter Lame Duck Mode. + + NOTE: Not supported on Windows except when running nats-server as a service + """ self.send_signal(Signal.LDM) def reload_config(self) -> None: + """Send signal to reload config. + + NOTE: Not supported on Windows except when running nats-server as a service + """ self.send_signal(Signal.RELOAD) def __enter__(self) -> "NATSD": @@ -479,3 +225,57 @@ def __exit__( traceback: t.Optional[types.TracebackType] = None, ) -> None: self.stop() + + def _send_signal_unix(self, _signal: Signal) -> None: + if os.name != "nt" and self.proc and self.proc.poll() is None: + if _signal == Signal.KILL: + # Kill the process immediatley + sig = signal.SIGKILL + elif _signal == Signal.QUIT: + # Kills the process immediately and perform a core dump + sig = signal.SIGQUIT + elif _signal == Signal.STOP: + # Stops the server grafefully + sig = signal.SIGTERM + elif _signal == Signal.REOPEN: + # Reopens the log file for log rotation + sig = signal.SIGUSR1 + elif _signal == Signal.RELOAD: + # Reload server configuration + sig = signal.SIGHUP + elif _signal == Signal.LDM: + # Stops the server after evicting all clients + sig = signal.SIGUSR2 + os.kill(self.proc.pid, sig.value) + + def _send_signal_windows(self, _signal: Signal) -> None: + if os.name == "nt" and self.proc and self.proc.poll() is None: + if _signal == Signal.KILL or _signal == Signal.QUIT: + warnings.warn( + "Quit and Kill are not supported on Windows. Interrupting process." + ) + os.kill(self.proc.pid, signal.SIGINT) + elif _signal == Signal.STOP: + os.kill(self.proc.pid, signal.SIGINT) + elif _signal == Signal.RELOAD: + warnings.warn("Log file roration is not supported on Windows") + elif _signal == Signal.REOPEN: + warnings.warn("Config reload is not supported on Windows") + elif _signal == Signal.LDM: + warnings.warn( + "Lame Duck Mode is not supported on Windows. Interrupting process." + ) + os.kill(self.proc.pid, signal.SIGINT) + + def send_signal(self, _signal: Signal) -> None: + """Send a signal to nats-server. Not well supported on Windows.""" + _signal = Signal(_signal) + if self.proc is None: + raise TypeError("Process is not started yet") + status = self.proc.poll() + if status is not None: + raise subprocess.CalledProcessError(status, cmd=self.proc.args) + if os.name != "nt": + self._send_signal_unix(_signal) + else: + self._send_signal_windows(_signal) diff --git a/src/nats_tools/templates/README.md b/src/nats_tools/templates/README.md deleted file mode 100644 index 4946b20..0000000 --- a/src/nats_tools/templates/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# NATS Templates - -> NATS config file generator. diff --git a/src/nats_tools/templates/__init__.py b/src/nats_tools/templates/__init__.py deleted file mode 100644 index 624a825..0000000 --- a/src/nats_tools/templates/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .config import ConfigGenerator - -__all__ = ["ConfigGenerator"] diff --git a/src/nats_tools/templates/config.py b/src/nats_tools/templates/config.py deleted file mode 100644 index bb3821e..0000000 --- a/src/nats_tools/templates/config.py +++ /dev/null @@ -1,239 +0,0 @@ -import typing as t -from pathlib import Path - -from .utils import load_template_from_name, load_template_from_path - - -class ConfigGenerator: - def __init__(self, template: t.Union[str, Path] = "default.conf.j2") -> None: - """Create a new instance of config generator. - - Arguments: - template: the template used to render configuration. - """ - if isinstance(template, Path): - self.template = load_template_from_path(template) - elif Path(template).is_file(): - self.template = load_template_from_path(template) - else: - self.template = load_template_from_name(template) - - def render( - self, - address: str = "127.0.0.1", - port: int = 4222, - client_advertise: t.Optional[str] = None, - server_name: t.Optional[str] = None, - server_tags: t.Optional[t.Dict[str, str]] = None, - user: t.Optional[str] = None, - password: t.Optional[str] = None, - users: t.Optional[t.List[t.Dict[str, t.Any]]] = None, - token: t.Optional[str] = None, - http_port: int = 8222, - debug: t.Optional[bool] = None, - trace: t.Optional[bool] = None, - trace_verbose: t.Optional[bool] = None, - logtime: t.Optional[bool] = None, - pid_file: t.Union[str, Path, None] = None, - port_file_dir: t.Union[str, Path, None] = None, - log_file: t.Union[str, Path, None] = None, - log_size_limit: t.Optional[int] = None, - tls_cert: t.Union[str, Path, None] = None, - tls_key: t.Union[str, Path, None] = None, - tls_ca_cert: t.Union[str, Path, None] = None, - cluster_name: t.Optional[str] = None, - cluster_url: t.Optional[str] = None, - cluster_listen: t.Optional[str] = None, - routes: t.Optional[t.List[str]] = None, - no_advertise: t.Optional[bool] = None, - with_jetstream: bool = False, - jetstream_domain: t.Optional[str] = None, - store_directory: t.Union[str, Path, None] = None, - max_memory_store: t.Optional[int] = None, - max_file_store: t.Optional[int] = None, - max_outstanding_catchup: t.Optional[int] = None, - allow_leafnodes: bool = False, - leafnodes_listen_address: t.Optional[str] = None, - leafnodes_listen_port: t.Optional[int] = None, - leafnode_remotes: t.Optional[t.Dict[str, t.Any]] = None, - websocket_listen_address: t.Optional[str] = None, - websocket_listen_port: t.Optional[int] = None, - websocket_advertise_url: t.Optional[str] = None, - websocket_tls: t.Optional[bool] = None, - websocket_tls_cert: t.Union[str, Path, None] = None, - websocket_tls_key: t.Union[str, Path, None] = None, - websocket_same_origin: t.Optional[bool] = None, - websocket_allowed_origins: t.Optional[t.List[str]] = None, - websocket_compression: t.Optional[bool] = None, - jwt_path: t.Union[str, Path, None] = None, - operator: t.Optional[str] = None, - system_account: t.Optional[str] = None, - system_account_jwt: t.Optional[str] = None, - allow_delete_jwt: t.Optional[bool] = None, - compare_jwt_interval: t.Optional[str] = None, - resolver_preload: t.Optional[t.Dict[str, str]] = None, - ) -> str: - """Render configuration according to arguments.""" - kwargs: t.Dict[str, t.Any] = {} - - kwargs["server_host"] = address - kwargs["server_port"] = port - kwargs["client_advertise"] = client_advertise - kwargs["server_name"] = server_name - kwargs["http_port"] = http_port - - if debug is not None: - kwargs["debug"] = debug - if trace is not None: - kwargs["trace"] = trace - if trace_verbose is not None: - kwargs["trace_verbose"] = trace_verbose - if logtime is not None: - kwargs["logtime"] = logtime - if pid_file is not None: - kwargs["pid_file"] = Path(pid_file).as_posix() - if port_file_dir is not None: - kwargs["port_file_dir"] = Path(port_file_dir).as_posix() - if log_file is not None: - kwargs["log_file"] = Path(log_file).as_posix() - if log_size_limit is not None: - kwargs["log_size_limit"] = log_size_limit - if server_tags: - kwargs["server_tags"] = [ - f"{key}:{value}" for key, value in server_tags.items() - ] - - cluster = False - if cluster_listen or cluster_url: - if cluster_listen is None: - cluster_listen = cluster_url - cluster = True - kwargs["cluster_listen"] = cluster_listen - if cluster_url is not None: - kwargs["cluster_url"] = cluster_url - if routes is not None: - kwargs["routes"] = routes - if no_advertise is not None: - kwargs["no_advertise"] = no_advertise - if cluster_name is not None: - kwargs["cluster_name"] = cluster_name - kwargs["cluster"] = cluster - - tls = False - if tls_cert or tls_key: - if not (tls_cert and tls_key): - raise ValueError( - "tls_cert and tls_key argument must be provided together" - ) - tls = True - tls_cert_file = Path(tls_cert).as_posix() - tls_key_file = Path(tls_key).as_posix() - kwargs["tls_cert_file"] = tls_cert_file - kwargs["tls_key_file"] = tls_key_file - if tls_ca_cert: - tls_ca_file = Path(tls_ca_cert).as_posix() - kwargs["tls_ca_file"] = tls_ca_file - kwargs["tls"] = tls - kwargs["enable_jetstream"] = with_jetstream - kwargs["jetstream_domain"] = jetstream_domain - kwargs["max_file_store"] = max_file_store - kwargs["max_memory_store"] = max_memory_store - kwargs["max_outstanding_catchup"] = max_outstanding_catchup - if store_directory is not None: - kwargs["jetstream_store_dir"] = store_directory - - if user or password: - if not (user and password): - raise ValueError( - "Both user and password argument must be provided together" - ) - - if token: - if user: - raise ValueError( - "token argument cannot be used together with user and password" - ) - - if users: - if token or user: - raise ValueError( - "users argument cannot be used with token or user and password" - ) - - if operator: - if users or token or user: - raise ValueError( - "operator argument cannot be used with any of users, token, user and password arguments" - ) - if system_account is None: - raise ValueError("system_account argument must be provided") - if system_account_jwt is None: - raise ValueError("system_account_jwt argument must be provided") - if jwt_path is None: - raise ValueError("jwt_path argument must be provided") - - kwargs["user"] = user - kwargs["password"] = password - kwargs["users"] = users - kwargs["token"] = token - - kwargs["operator"] = operator - kwargs["system_account"] = system_account - kwargs["jwt_path"] = jwt_path - jwts = resolver_preload or {} - if system_account and system_account_jwt: - jwts[system_account] = system_account_jwt - kwargs["jwts"] = jwts - kwargs["allow_delete_jwt"] = allow_delete_jwt or False - kwargs["compare_jwt_interval"] = compare_jwt_interval or "2m" - - if leafnodes_listen_address or leafnodes_listen_port: - leafnodes_listen_address = leafnodes_listen_address or address - leafnodes_listen_port = leafnodes_listen_port or 7422 - allow_leafnodes = True - kwargs["leafnodes_listen_address"] = leafnodes_listen_address - kwargs["leafnodes_listen_port"] = leafnodes_listen_port - kwargs["allow_leafnodes"] = allow_leafnodes - kwargs["leafnode_remotes"] = leafnode_remotes - - websocket = False - if websocket_listen_port or websocket_listen_address: - if websocket_listen_address is None: - websocket_listen_address = address - if websocket_listen_port is None: - if websocket_tls or websocket_tls_cert: - websocket_listen_port = 443 - websocket = True - kwargs["websocket_listen_port"] = websocket_listen_port - kwargs["websocket_listen_address"] = websocket_listen_address - if websocket_advertise_url: - kwargs["websocket_advertise_url"] = websocket_advertise_url - if websocket_tls_cert and websocket_tls_key: - if not websocket_tls_cert and websocket_tls_key: - raise ValueError( - "websocket_tls_cert and websocket_tls_key must be provided to enable websocket TLS" - ) - if ( - (tls and websocket_tls) or (tls and websocket_tls is None) - ) and websocket_tls_cert is None: - if tls_cert is None or tls_key is None: - raise ValueError( - "websocket_tls_cert and websocket_tls_key must be provided to enable websocket TLS" - ) - websocket_tls_cert = Path(tls_cert).as_posix() - websocket_tls_key = Path(tls_key).as_posix() - websocket_tls = False - if websocket_tls_cert: - websocket_tls = True - kwargs["websocket_tls_cert_file"] = websocket_tls_cert - kwargs["websocket_tls_key_file"] = websocket_tls_key - kwargs["websocket_tls"] = websocket_tls - if websocket_tls: - if websocket_same_origin is not None: - kwargs["websocket_same_origin"] = websocket_same_origin - kwargs["websocket_allowed_origins"] = websocket_allowed_origins - if websocket_compression is not None: - kwargs["websocket_compression"] = websocket_compression - kwargs["websocket"] = websocket - - return self.template.render(**kwargs) diff --git a/src/nats_tools/templates/data/default.conf.j2 b/src/nats_tools/templates/data/default.conf.j2 deleted file mode 100644 index 8910c33..0000000 --- a/src/nats_tools/templates/data/default.conf.j2 +++ /dev/null @@ -1,191 +0,0 @@ -# Server listenning host and port -host: {{ server_host }} -port: {{ server_port }} -{% if client_advertise -%} -# Address advertised to client -client_advertise: {{ client_advertise }} -{% endif -%} -{% if server_name -%} -# Server name -server_name: {{ server_name }} -{% endif -%} -{% if server_tags is defined and server_tags -%} -# A set of tags describing properties of the server. -# This will be exposed through /varz and can be used for system resource requests, such as placement of streams -server_tags: {{ server_tags|tojson }} -{% endif -%} -{% if debug is defined %} -# Enable debug log messages -debug: {{ debug|tojson }} -{% endif -%} -{% if trace is defined -%} -# Enable protocol trace log messages -trace: {{ trace|tojson }} -{% endif -%} -{% if trace_verbose is defined -%} -# Enable protocol trace log messages (including system account) -trace_verbose: {{ trace_verbose|tojson }} -{% endif -%} -{% if logtime is defined -%} -logtime: {{ logtime|tojson }} -{% endif -%} -{% if pid_file is defined -%} -# Write process id to file -pid_file: {{ pid_file }} -{% endif -%} -{% if port_file_dir is defined -%} -# Directory to write a file containing the servers open ports -port_file_dir: {{ port_file_dir }} -{% endif -%} -{% if log_file is defined -%} -# Write logs to file -log_file: {{ log_file }} -{% endif -%} -{% if log_size_limit is defined -%} -# roll over to a new file after limit is reached -log_size_limit: {{ log_size_limit }} -{% endif -%} -{% if tls %} -# TLS configuration -tls { - cert_file {{ tls_cert_file }} - key_file {{ tls_key_file }} - {%- if tls_ca_file %} - ca_file {{ tls_ca_file }} - verify true - {%- endif %} -} -{% endif -%} -{% if cluster %} -# Cluster configuration -cluster { - {%- if cluster_name is defined %} - # Cluster name - name: {{ cluster_name }} - {%- endif %} - # Address where NATS listens for incoming route connections - listen: {{ cluster_listen }} - {%- if cluster_url is defined %} - # Advertise how this server can be contacted by other cluster members - advertise: {{ cluster_url }} - {%- endif %} - {%- if no_advertise is defined %} - # Do not send or gossip client URLs to other servers in the cluster and do not tell clients about other servers' client URLs - no_advertise: {{ no_advertise|tojson }} - {%- endif %} - {%- if routes is defined %} - # A list of other servers (URLs) to cluster with. Self-routes are ignored. - routes: {{ routes|tojson }} - {%- endif %} -} -{% endif -%} -{%- if websocket %} -websocket { - host: {{ websocket_listen_address }} - port: {{ websocket_listen_port }} - {%- if websocket_advertise_url %} - # Advertise how this server can be reached by websocket clients - advertise: {{ websocket_advertise_url }} - {%- endif %} - {%- if websocket_tls %} - # Enable TLS for websocket connections - tls { - cert_file: {{ websocket_tls_cert_file }} - key_file: {{ websocket_tls_key_file }} - } - {%- else %} - no_tls: true - {%- endif %} - {%- if websocket_same_origin is defined %} - # HTTP origin header must match the request’s hostname - same_origin: {{ websocket_same_origin|tojson }} - {%- endif %} - {%- if websocket_allowed_origins is defined and websocket_allowed_origins %} - # This option is used only when the http request presents an Origin header - # which is the case for web browsers. If no Origin header is present, this - # check will not be performed - allowed_origins: {{ websocket_allowed_origins|tojson }} - {%- endif %} - {%- if websocket_compression is defined %} - # Enables support for compressed websocket frames - # For compression to be used, both server and client have to support it. - compression: {{ websocket_compression|tojson }} - {%- endif %} -} -{% endif -%} -{%- if enable_jetstream %} -# Jetstream configuration -jetstream { - store_dir: "{{ jetstream_store_dir|default('/tmp/data/jetstream') }}" - {%- if jetstream_domain %} - domain: "{{ jetstream_domain }}" - {%- endif %} - {%- if max_memory_store %} - max_memory_store: {{ max_memory_store }} - {%- endif %} - {%- if max_file_store %} - max_file_store: {{ max_file_store }} - {%- endif %} - {%- if max_outstanding_catchup %} - max_outstanding_catchup: {{ max_outstanding_catchup }} - {%- endif %} -} -{% endif -%} -{%- if allow_leafnodes or leafnode_remotes %} -# Enable leaf-nodes -leafnodes { -{%- if allow_leafnodes %} - host: {{ leafnodes_listen_address }} - port: {{ leafnodes_listen_port }} -{%- endif %} -{%- if leafnode_remotes %} - remotes = {{ leafnode_remotes|tojson }} -{%- endif %} -} -{% endif %} -# Enable monitoring endpoint -http_port: 8222 -{% if user and password %} -authorization { - # Clients must authenticate using user and password - user: {{ user }} - password: {{ password }} -} -{%- elif token %} -authorization { - # Clients must authenticate using token - token: {{ token }} -} -{% elif users %} -authorization { - # Clients must authenticate using one of the user connection listed below - users: {{ users|tojson }} -} -{% elif operator %} -# Operator JWT -operator: {{ operator }} - -# System account public key -system_account: {{ system_account }} - -# Configuration of the nats based resolver -resolver { - type: full - # Directory in which the account jwt will be stored - dir: "{{jwt_path}}" - # In order to support jwt deletion, set to true - # If the resolver type is full delete will rename the jwt. - # This is to allow manual restoration in case of inadvertent deletion. - # To restore a jwt, remove the added suffix .delete and restart or send a reload signal. - # To free up storage you must manually delete files with the suffix .delete. - allow_delete: {{ allow_delete_jwt|tojson }} - # Interval at which a nats-server with a nats based account resolver will compare - # it's state with one random nats based account resolver in the cluster and if needed, - # exchange jwt and converge on the same set of jwt. - interval: "{{ compare_jwt_interval }}" -} -{% if jwts %} -# Preload the nats based resolver with accounts JWT -resolver_preload: {{jwts|tojson(indent=2)}} -{%- endif %} -{% endif %} diff --git a/src/nats_tools/templates/utils.py b/src/nats_tools/templates/utils.py deleted file mode 100644 index e4f8586..0000000 --- a/src/nats_tools/templates/utils.py +++ /dev/null @@ -1,21 +0,0 @@ -import typing as t -from pathlib import Path - -import jinja2 - - -def load_template_from_path(template: t.Union[str, Path]) -> jinja2.Template: - """Load a jinja2 template from path.""" - filepath = Path(template).absolute() - if not filepath.exists(): - raise FileNotFoundError(filepath.as_posix()) - loader = jinja2.FileSystemLoader(filepath.parent) - environment = jinja2.Environment(loader=loader) - return environment.get_template(filepath.name) - - -def load_template_from_name(template: str) -> jinja2.Template: - """Load a jinja2 template from name.""" - loader = jinja2.FileSystemLoader(Path(__file__).parent.joinpath("data")) - environment = jinja2.Environment(loader=loader) - return environment.get_template(template) diff --git a/src/nats_tools/testing.py b/src/nats_tools/testing.py index ac785fd..03b2703 100644 --- a/src/nats_tools/testing.py +++ b/src/nats_tools/testing.py @@ -1,10 +1,9 @@ import typing as t -from pathlib import Path import pytest from _pytest.fixtures import SubRequest -from nats_tools.natsd import NATSD +from nats_tools.natsd import NATSD, ServerOptions F = t.TypeVar("F", bound=t.Callable[..., t.Any]) @@ -12,129 +11,16 @@ @pytest.fixture def natsd(request: SubRequest) -> t.Iterator[NATSD]: if hasattr(request, "param"): - params = dict(request.param) + options: ServerOptions = request.param else: - params = {"debug": True, "trace": True} - if params.get("debug", None) is None: - params["debug"] = True - if params.get("trace", None) is None: - params["trace"] = True - with NATSD(**params) as daemon: + options = ServerOptions() + if options.debug is None: + options.debug = True + if options.trace is None: + options.trace = True + with NATSD(options) as daemon: yield daemon -def parametrize_nats_server( - address: str = "127.0.0.1", - port: int = 4222, - client_advertise: t.Optional[str] = None, - server_name: t.Optional[str] = None, - server_tags: t.Optional[t.Dict[str, str]] = None, - user: t.Optional[str] = None, - password: t.Optional[str] = None, - users: t.Optional[t.List[t.Dict[str, t.Any]]] = None, - token: t.Optional[str] = None, - http_port: int = 8222, - debug: t.Optional[bool] = None, - trace: t.Optional[bool] = None, - trace_verbose: t.Optional[bool] = None, - logtime: t.Optional[bool] = None, - pid_file: t.Union[str, Path, None] = None, - port_file_dir: t.Union[str, Path, None] = None, - log_file: t.Union[str, Path, None] = None, - log_size_limit: t.Optional[int] = None, - tls_cert: t.Union[str, Path, None] = None, - tls_key: t.Union[str, Path, None] = None, - tls_ca_cert: t.Union[str, Path, None] = None, - cluster_name: t.Optional[str] = None, - cluster_url: t.Optional[str] = None, - cluster_listen: t.Optional[str] = None, - routes: t.Optional[t.List[str]] = None, - no_advertise: t.Optional[bool] = None, - with_jetstream: bool = False, - jetstream_domain: t.Optional[str] = None, - store_directory: t.Union[str, Path, None] = None, - max_memory_store: t.Optional[int] = None, - max_file_store: t.Optional[int] = None, - max_outstanding_catchup: t.Optional[int] = None, - allow_leafnodes: bool = False, - leafnodes_listen_address: t.Optional[str] = None, - leafnodes_listen_port: t.Optional[int] = None, - leafnode_remotes: t.Optional[t.Dict[str, t.Any]] = None, - websocket_listen_address: t.Optional[str] = None, - websocket_listen_port: t.Optional[int] = None, - websocket_advertise_url: t.Optional[str] = None, - websocket_tls: t.Optional[bool] = None, - websocket_tls_cert: t.Union[str, Path, None] = None, - websocket_tls_key: t.Union[str, Path, None] = None, - websocket_same_origin: t.Optional[bool] = None, - websocket_allowed_origins: t.Optional[t.List[str]] = None, - websocket_compression: t.Optional[bool] = None, - jwt_path: t.Union[str, Path, None] = None, - operator: t.Optional[str] = None, - system_account: t.Optional[str] = None, - system_account_jwt: t.Optional[str] = None, - allow_delete_jwt: t.Optional[bool] = None, - compare_jwt_interval: t.Optional[str] = None, - resolver_preload: t.Optional[t.Dict[str, str]] = None, - config_file: t.Union[str, Path, None] = None, - max_cpus: t.Optional[float] = None, - start_timeout: float = 1, -) -> t.Callable[[F], F]: - options = dict( - address=address, - port=port, - client_advertise=client_advertise, - server_name=server_name, - server_tags=server_tags, - user=user, - password=password, - users=users, - token=token, - http_port=http_port, - debug=debug, - trace=trace, - trace_verbose=trace_verbose, - logtime=logtime, - pid_file=pid_file, - port_file_dir=port_file_dir, - log_file=log_file, - log_size_limit=log_size_limit, - tls_cert=tls_cert, - tls_key=tls_key, - tls_ca_cert=tls_ca_cert, - cluster_name=cluster_name, - cluster_url=cluster_url, - cluster_listen=cluster_listen, - routes=routes, - no_advertise=no_advertise, - with_jetstream=with_jetstream, - jetstream_domain=jetstream_domain, - store_directory=store_directory, - max_memory_store=max_memory_store, - max_file_store=max_file_store, - max_outstanding_catchup=max_outstanding_catchup, - allow_leafnodes=allow_leafnodes, - leafnodes_listen_address=leafnodes_listen_address, - leafnodes_listen_port=leafnodes_listen_port, - leafnode_remotes=leafnode_remotes, - websocket_listen_address=websocket_listen_address, - websocket_listen_port=websocket_listen_port, - websocket_advertise_url=websocket_advertise_url, - websocket_tls=websocket_tls, - websocket_tls_cert=websocket_tls_cert, - websocket_tls_key=websocket_tls_key, - websocket_same_origin=websocket_same_origin, - websocket_allowed_origins=websocket_allowed_origins, - websocket_compression=websocket_compression, - jwt_path=jwt_path, - operator=operator, - system_account=system_account, - system_account_jwt=system_account_jwt, - allow_delete_jwt=allow_delete_jwt, - compare_jwt_interval=compare_jwt_interval, - resolver_preload=resolver_preload, - config_file=config_file, - start_timeout=start_timeout, - max_cpus=max_cpus, - ) +def parametrize_nats_server(options: ServerOptions) -> t.Callable[[F], F]: return pytest.mark.parametrize("natsd", [options], indirect=True) diff --git a/tests/e2e/test_natsd.py b/tests/e2e/test_natsd.py index 8fbb86e..318d016 100644 --- a/tests/e2e/test_natsd.py +++ b/tests/e2e/test_natsd.py @@ -1,15 +1,15 @@ -from nats_tools.natsd import NATSD +from nats_tools.natsd import NATSD, ServerOptions from nats_tools.testing import parametrize_nats_server def test_natsd_can_be_started_using_context_manager(): - with NATSD(debug=True) as nats: + with NATSD(ServerOptions(debug=True)) as nats: assert nats.is_alive() assert nats.monitor.healthz() == {"status": "ok"} def test_natsd_can_be_stopped(): - nats = NATSD(debug=True) + nats = NATSD(ServerOptions(debug=True)) nats.start(wait=True) assert nats.is_alive() assert nats.monitor.healthz() == {"status": "ok"} @@ -18,7 +18,7 @@ def test_natsd_can_be_stopped(): def test_natsd_can_be_reloaded(): - with NATSD(debug=True, port=4123) as nats: + with NATSD(ServerOptions(debug=True, port=4123)) as nats: assert nats.monitor.healthz() == {"status": "ok"} nats.reload_config() assert nats.monitor.healthz() == {"status": "ok"} @@ -29,8 +29,8 @@ def test_natsd_fixture_can_be_used_within_tests(natsd: NATSD): assert natsd.monitor.healthz() == {"status": "ok"} -@parametrize_nats_server(port=5000) +@parametrize_nats_server(ServerOptions(port=5000)) def test_parametetrize_nats_server_fixture_can_be_used(natsd: NATSD): - assert natsd.port == 5000 + assert natsd.options.port == 5000 assert natsd.is_alive() assert natsd.monitor.healthz() == {"status": "ok"} diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index e69de29..ce26ca1 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -0,0 +1,40 @@ +import shutil +import tempfile +import typing as t +from pathlib import Path + +import pytest +import trustme + +from .entitites import TLSCertificates + + +@pytest.fixture +def tls_files() -> t.Iterator[TLSCertificates]: + x509_ca = trustme.CA(organization_unit_name="nats-tools") + x509_server_cert = x509_ca.issue_server_cert("server") + x509_client_cert = x509_ca.issue_cert("client") + tmpdir = Path(tempfile.mkdtemp(prefix="nats_tools_tls_", suffix="-")) + # Initialize certificates paths + ca_cert = tmpdir / "ca.crt" + ca_cert.write_bytes(x509_ca.cert_pem.bytes()) + ca_key = tmpdir / "ca.key" + ca_key.write_bytes(x509_ca.private_key_pem.bytes()) + server_cert = tmpdir / "server.crt" + server_cert.write_bytes(x509_server_cert.private_key_and_cert_chain_pem.bytes()) + server_key = tmpdir / "server.key" + server_key.write_bytes(x509_server_cert.private_key_and_cert_chain_pem.bytes()) + client_cert = tmpdir / "client.crt" + client_cert.write_bytes(x509_client_cert.private_key_and_cert_chain_pem.bytes()) + client_key = tmpdir / "client.key" + client_key.write_bytes(x509_client_cert.private_key_and_cert_chain_pem.bytes()) + try: + yield TLSCertificates( + ca_file=ca_cert.as_posix(), + key_file=server_key.as_posix(), + cert_file=server_cert.as_posix(), + client_cert_file=client_cert.as_posix(), + client_key_file=client_key.as_posix(), + ) + finally: + shutil.rmtree(tmpdir) diff --git a/tests/unit/entitites.py b/tests/unit/entitites.py new file mode 100644 index 0000000..ed00641 --- /dev/null +++ b/tests/unit/entitites.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass +class TLSCertificates: + ca_file: str + cert_file: str + key_file: str + client_cert_file: str + client_key_file: str diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..5e7469c --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,2327 @@ +from nats_tools.config import ServerOptions, non_null, render +from nats_tools.config.blocks import ( + MQTT, + TLS, + Account, + AccountJetStreamLimits, + Authorization, + Cluster, + JetStream, + LeafNodes, + NATSResolver, + Permissions, + RemoteLeafnode, + ServiceExport, + ServiceImport, + Source, + StreamExport, + StreamImport, + User, + Websocket, +) + +from .entitites import TLSCertificates + + +def test_config_with_default_values() -> None: + """Test that config can be generated with default values only.""" + options = ServerOptions() + assert non_null(options) == {"host": "0.0.0.0", "port": 4222} + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +""" + ) + + +def test_config_with_listen_address() -> None: + """Test that config can be generated with listen address.""" + address = "127.0.0.1:4222" + options = ServerOptions(listen=address) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "listen": address} + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening address +listen: {address} +""" + ) + + +def test_config_with_client_advertise() -> None: + """Test that config can be generated with client_advertise.""" + advertise_address = "somewhere:8888" + options = ServerOptions(client_advertise=advertise_address) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "client_advertise": advertise_address, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Address advertised to client +client_advertise: {advertise_address} +""" + ) + + +def test_config_with_tls(tls_files: TLSCertificates) -> None: + options = ServerOptions( + tls=TLS( + cert_file=tls_files.cert_file, + key_file=tls_files.key_file, + ca_file=tls_files.ca_file, + ) + ) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "tls": { + "cert_file": tls_files.cert_file, + "key_file": tls_files.key_file, + "ca_file": tls_files.ca_file, + }, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Configure TLS for client connections +tls: {{ + "ca_file": "{tls_files.ca_file}", + "cert_file": "{tls_files.cert_file}", + "key_file": "{tls_files.key_file}" +}} +""" + ) + + +def test_config_with_ping_interval() -> None: + """Test that config can be generated with ping interval.""" + ping_interval = "2s" + options = ServerOptions(ping_interval=ping_interval) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "ping_interval": ping_interval, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Duration at which pings are sent to clients, leaf nodes and routes +ping_interval: {ping_interval} +""" + ) + + +def test_config_with_ping_max() -> None: + """Test that config can be generated with ping max.""" + ping_max = 1 + options = ServerOptions(ping_max=ping_max) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "ping_max": ping_max} + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# After how many unanswered pings the server will allow before closing the connection +ping_max: {ping_max} +""" + ) + + +def test_config_with_write_deadline() -> None: + """Test that config can be generated with custom write_deadline""" + write_deadline = "1s" + options = ServerOptions(write_deadline=write_deadline) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "write_deadline": write_deadline, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Maximum number of seconds the server will block when writing. Once this threshold is exceeded the connection will be closed +write_deadline: "{write_deadline}" +""" + ) + + +def test_config_with_max_connections() -> None: + max_connections = 1 + options = ServerOptions(max_connections=max_connections) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "max_connections": max_connections, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Maximum number of active client connections +max_connections: {max_connections} +""" + ) + + +def test_config_with_max_control_line() -> None: + max_control_line = "2KB" + options = ServerOptions(max_control_line=max_control_line) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "max_control_line": max_control_line, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Maximum length of a protocol line (including combined length of subject and queue group) +max_control_line: {max_control_line} +""" + ) + + +def test_config_with_max_payload() -> None: + max_payload = "2KB" + options = ServerOptions(max_payload=max_payload) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "max_payload": max_payload, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Maximum number of bytes in a message payload +max_payload: {max_payload} +""" + ) + + +def test_config_with_max_pending() -> None: + max_pending = "2MB" + options = ServerOptions(max_pending=max_pending) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "max_pending": max_pending, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Maximum number of bytes in a message payload +max_pending: {max_pending} +""" + ) + + +def test_config_with_max_subscriptions() -> None: + max_subscriptions = 1000 + options = ServerOptions(max_subscriptions=max_subscriptions) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "max_subscriptions": max_subscriptions, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Maximum numbers of subscriptions per client and leafnode accounts connection +max_subscriptions: {max_subscriptions} +""" + ) + + +def test_config_with_server_name() -> None: + server_name = "test" + options = ServerOptions(server_name=server_name) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "server_name": server_name, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# NATS server name +server_name: {server_name} +""" + ) + + +def test_config_with_server_tags() -> None: + server_tags = {"environment": "test", "project": "nats-tools"} + options = ServerOptions(server_tags=server_tags) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "server_tags": server_tags, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Key value tags describing properties of the server +# Tags will be exposed through `/varz` and can be used +# for system resource requests, such as placement of streams +server_tags: [ + "environment:{server_tags['environment']}", + "project:{server_tags['project']}" +] +""" + ) + + +def test_config_with_trace() -> None: + options = ServerOptions(trace=True) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "trace": True} + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable protocol trace log messages (excluding the system account) +trace: true +""" + ) + + +def test_config_with_trace_verbose() -> None: + options = ServerOptions(trace_verbose=True) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "trace_verbose": True} + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable protocol trace log messages (including the system account) +trace_verbose: true +""" + ) + + +def test_config_with_debug() -> None: + options = ServerOptions(debug=True) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "debug": True} + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable debug log messages +debug: true +""" + ) + + +def test_config_with_logtime() -> None: + options = ServerOptions(logtime=False) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "logtime": False} + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Log without timestamp +logtime: false +""" + ) + + +def test_config_with_log_file() -> None: + log_file = "test.log" + options = ServerOptions(log_file=log_file) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "log_file": log_file} + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Write logs to file +log_file: {log_file} +""" + ) + + +def test_config_with_log_file_size_limit() -> None: + log_size_limit = 2048 + options = ServerOptions(log_size_limit=log_size_limit) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "log_size_limit": log_size_limit, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Roll over to a new file after limit is reached +log_size_limit: {log_size_limit} +""" + ) + + +def test_config_with_max_traced_msg_len() -> None: + max_traced_msg_len = 2048 + options = ServerOptions(max_traced_msg_len=max_traced_msg_len) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "max_traced_msg_len": max_traced_msg_len, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Set a limit to the trace of the payload of a message +max_traced_msg_len: {max_traced_msg_len} +""" + ) + + +def test_config_with_syslog() -> None: + options = ServerOptions(syslog=True) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "syslog": True} + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Log to syslog +syslog: true +""" + ) + + +def test_config_with_remote_syslog() -> None: + remote_syslog = "some-remote:9000" + options = ServerOptions(remote_syslog=remote_syslog) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "remote_syslog": remote_syslog, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Log to remote syslog +remote_syslog: {remote_syslog} +""" + ) + + +def test_config_with_http_port() -> None: + http_port = 9000 + options = ServerOptions(http_port=http_port) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "http_port": http_port, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable monitoring endpoint +http_port: {http_port} +""" + ) + + +def test_config_with_http_address() -> None: + address = "0.0.0.0:8000" + options = ServerOptions(http=address) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "http": address} + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable monitoring endpoint +http: {address} +""" + ) + + +def test_config_with_https_port() -> None: + https_port = 9000 + options = ServerOptions(https_port=https_port) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "https_port": https_port, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable monitoring endpoint with TLS +https_port: {https_port} +""" + ) + + +def test_config_with_https_address() -> None: + address = "0.0.0.0:8000" + options = ServerOptions(https=address) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "https": address} + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable monitoring endpoint with TLS +https: {address} +""" + ) + + +def test_config_with_system_account() -> None: + account_name = user_name = password = "test" + accounts = {account_name: Account(users=[User(user=user_name, password=password)])} + options = ServerOptions(system_account=account_name, accounts=accounts) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "system_account": account_name, + "accounts": non_null(accounts), + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Configure system account +system_account: {account_name} +# Enable multitenancy using accounts +accounts: {{ + "{account_name}": {{ + "users": [ + {{ + "password": "{password}", + "user": "{user_name}" + }} + ] + }} +}} +""" + ) + + +def test_config_with_pid_file() -> None: + pid_file = "test.pid" + options = ServerOptions(pid_file=pid_file) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "pid_file": pid_file} + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Write process PID to file +pid_file: {pid_file} +""" + ) + + +def test_config_with_ports_file_dir() -> None: + ports_file_dir = "ports/" + options = ServerOptions(ports_file_dir=ports_file_dir) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "ports_file_dir": ports_file_dir, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Write process PID to file within directory +# File will be named "nats-server_.ports" +# Directory MUST exist before starting nats-server +ports_file_dir: {ports_file_dir} +""" + ) + + +def test_config_with_connect_error_reports() -> None: + connect_error_reports = 1 + options = ServerOptions(connect_error_reports=connect_error_reports) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "connect_error_reports": connect_error_reports, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Number of attempts at which a repeated failed route, gateway or leaf node connection is reported +connect_error_reports: {connect_error_reports} +""" + ) + + +def test_config_with_reconnect_error_reports() -> None: + reconnect_error_reports = 1 + options = ServerOptions(reconnect_error_reports=reconnect_error_reports) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "reconnect_error_reports": reconnect_error_reports, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Number of attempts at which a repeated failed route, gateway or leaf node reconnect is reported +reconnect_error_reports: {reconnect_error_reports} +""" + ) + + +def test_config_with_disable_sublist_cache() -> None: + options = ServerOptions(disable_sublist_cache=True) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "disable_sublist_cache": True, + } + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Disable subscription caches for all accounts +# This is saves resources in situations where different subjects are used all the time +disable_sublist_cache: true +""" + ) + + +def test_config_with_lame_duck_duration() -> None: + lame_duck_duration = "30s" + options = ServerOptions(lame_duck_duration=lame_duck_duration) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "lame_duck_duration": lame_duck_duration, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# In lame duck mode the server rejects new clients and slowly closes client connections +# After this duration is over the server shuts down +# Note that this value cannot be set lower than 30 seconds +lame_duck_duration: "{lame_duck_duration}" +""" + ) + + +def test_config_with_duck_grace_period() -> None: + lame_duck_grace_period = "5s" + options = ServerOptions(lame_duck_grace_period=lame_duck_grace_period) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "lame_duck_grace_period": lame_duck_grace_period, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# The duration the server waits (after entering lame duck mode) +# before starting to close client connections +lame_duck_grace_period: "{lame_duck_grace_period}" +""" + ) + + +def test_config_with_user_password_authorization() -> None: + authorization = Authorization(user="test", password="test") + options = ServerOptions(authorization=authorization) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "authorization": non_null(authorization), + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Configuration map for client authentication/authorization +authorization: {{ + "password": "{authorization.password}", + "user": "{authorization.user}" +}} +""" + ) + + +def test_config_with_multi_users() -> None: + user1 = password1 = "test" + user2 = password2 = "other" + user3 = password3 = "yetanother" + authorization = Authorization( + users=[ + User(user=user1, password=password1), + User( + user=user2, + password=password2, + allowed_connection_types=["STANDARD", "WEBSOCKET"], + ), + User( + user=user3, + password=password3, + permissions=Permissions(publish=["test.*"]), + ), + ] + ) + options = ServerOptions(authorization=authorization) + assert non_null(authorization) == { + "users": [ + {"user": user1, "password": password1}, + { + "user": user2, + "password": password2, + "allowed_connection_types": ["STANDARD", "WEBSOCKET"], + }, + { + "user": user3, + "password": password3, + "permissions": {"publish": ["test.*"]}, + }, + ] + } + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "authorization": non_null(authorization), + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Configuration map for client authentication/authorization +authorization: {{ + "users": [ + {{ + "password": "{password1}", + "user": "{user1}" + }}, + {{ + "allowed_connection_types": [ + "STANDARD", + "WEBSOCKET" + ], + "password": "{password2}", + "user": "{user2}" + }}, + {{ + "password": "{password3}", + "permissions": {{ + "publish": [ + "test.*" + ] + }}, + "user": "{user3}" + }} + ] +}} +""" + ) + + +def test_config_with_token_authorization() -> None: + authorization = Authorization(token="test") + options = ServerOptions(authorization=authorization) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "authorization": non_null(authorization), + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Configuration map for client authentication/authorization +authorization: {{ + "token": "{authorization.token}" +}} +""" + ) + + +def test_config_with_accounts_and_no_auth_user() -> None: + account_name = user = password = no_auth_user = "test" + accounts = {account_name: Account(users=[User(user=user, password=password)])} + options = ServerOptions(accounts=accounts, no_auth_user=no_auth_user) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "accounts": non_null(accounts), + "no_auth_user": no_auth_user, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# A client connecting without any form of authentication will be associated with this user, its permissions and account +no_auth_user: "{user}" +# Enable multitenancy using accounts +accounts: {{ + "{account_name}": {{ + "users": [ + {{ + "password": "{password}", + "user": "{user}" + }} + ] + }} +}} +""" + ) + + +def test_config_with_accounts_and_jetstream_limit() -> None: + account_name = user = password = "test" + limits = AccountJetStreamLimits(max_mem=10, max_file=20) + accounts = { + account_name: Account( + users=[User(user=user, password=password)], jetstream=limits + ) + } + options = ServerOptions(accounts=accounts) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "accounts": non_null(accounts), + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable multitenancy using accounts +accounts: {{ + "{account_name}": {{ + "jetstream": {{ + "max_file": {limits.max_file}, + "max_mem": {limits.max_mem} + }}, + "users": [ + {{ + "password": "{password}", + "user": "{user}" + }} + ] + }} +}} +""" + ) + + +def test_config_with_accounts_and_exports() -> None: + account_name = user = password = "test" + stream_subject = "test-stream" + service_subject = "test-service" + accounts = { + account_name: Account( + users=[User(user=user, password=password)], + exports=[ + StreamExport(stream=stream_subject), + ServiceExport(service=service_subject), + ], + ) + } + options = ServerOptions(accounts=accounts) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "accounts": non_null(accounts), + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable multitenancy using accounts +accounts: {{ + "{account_name}": {{ + "exports": [ + {{ + "stream": "{stream_subject}" + }}, + {{ + "service": "{service_subject}" + }} + ], + "users": [ + {{ + "password": "{password}", + "user": "{user}" + }} + ] + }} +}} +""" + ) + + +def test_config_with_accounts_and_imports() -> None: + account_name = user = password = "test" + account_name2 = user2 = password2 = "test2" + stream_subject = "test-stream" + service_subject = "test-service" + accounts = { + account_name: Account( + users=[User(user=user, password=password)], + exports=[ + StreamExport(stream=stream_subject), + ServiceExport(service=service_subject), + ], + ), + account_name2: Account( + users=[User(user=user2, password=password2)], + imports=[ + StreamImport( + stream=Source(account=account_name, subject=stream_subject) + ), + ServiceImport( + service=Source(account=account_name, subject=service_subject) + ), + ], + ), + } + options = ServerOptions(accounts=accounts) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "accounts": non_null(accounts), + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable multitenancy using accounts +accounts: {{ + "{account_name}": {{ + "exports": [ + {{ + "stream": "{stream_subject}" + }}, + {{ + "service": "{service_subject}" + }} + ], + "users": [ + {{ + "password": "{password}", + "user": "{user}" + }} + ] + }}, + "{account_name2}": {{ + "imports": [ + {{ + "stream": {{ + "account": "{account_name}", + "subject": "{stream_subject}" + }} + }}, + {{ + "service": {{ + "account": "{account_name}", + "subject": "{service_subject}" + }} + }} + ], + "users": [ + {{ + "password": "{password2}", + "user": "{user2}" + }} + ] + }} +}} +""" + ) + + +def test_config_with_operator() -> None: + operator = "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiI2RVc0NEtIUlZRU1laN1lIWEpORVJCNzNLR1NKR1pPRVZRN0VTQUVWTkFaNFpGNVFEM1BBIiwiaWF0IjoxNjYzNzIwNjUyLCJpc3MiOiJPREc0WUpYTkhaSElDU0VPVVNLVlA1RFdWREZZR0pXNzRKTTVYRkxGSEZYS0tPRTVVQVNHUlRWNSIsIm5hbWUiOiJRVUFSQSIsInN1YiI6Ik9ERzRZSlhOSFpISUNTRU9VU0tWUDVEV1ZERllHSlc3NEpNNVhGTEZIRlhLS09FNVVBU0dSVFY1IiwibmF0cyI6eyJvcGVyYXRvcl9zZXJ2aWNlX3VybHMiOlsibmF0czovL2xvY2FsaG9zdDo0MjIyIl0sInN5c3RlbV9hY2NvdW50IjoiQURRRE5WS1hKVEZJRTNZTjRCQ0RHNVFLRlBJWlQ2TFFGVEhVV0taMkpZM04yRUVEUzUzMkdMSlciLCJ0eXBlIjoib3BlcmF0b3IiLCJ2ZXJzaW9uIjoyfX0.x2tWeEP5ofk3hpaWjRf_qlorB9XBzZMEoVCQnGB_nOiGxc65cGo98V-TRLmofvE4miwhbDAdBj9fh-jeuyjMBA" + options = ServerOptions(operator=operator) + assert non_null(options) == {"host": "0.0.0.0", "port": 4222, "operator": operator} + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable operator authorization mode +operator: {operator} +""" + ) + + +def test_config_with_resolver() -> None: + resolver = NATSResolver() + options = ServerOptions(resolver=resolver) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "resolver": non_null(resolver), + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Use NATS resolver to resolve accounts +resolver: {{ + "allow_delete": {'true' if resolver.allow_delete else 'false'}, + "dir": "{resolver.dir}", + "interval": "{resolver.interval}", + "type": "{resolver.type}" +}} +""" + ) + + +def test_config_with_resolver_preload() -> None: + resolver_preload = { + "ACP5QLX7CZ7345FB4A3XHCZNFEUV4NHZ2HL743IXZVD7FCKWLX2BDVZY": "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJIVTJHS0tVM0hNQlhXUVhDWllHWlVFNjdRTkVNQUdJT1o3NUVFVkJaNDNZNE5OV0g0UERRIiwiaWF0IjoxNjYzNzIwNjUyLCJpc3MiOiJPREc0WUpYTkhaSElDU0VPVVNLVlA1RFdWREZZR0pXNzRKTTVYRkxGSEZYS0tPRTVVQVNHUlRWNSIsIm5hbWUiOiJRVUFSQSIsInN1YiI6IkFDUDVRTFg3Q1o3MzQ1RkI0QTNYSENaTkZFVVY0TkhaMkhMNzQzSVhaVkQ3RkNLV0xYMkJEVlpZIiwibmF0cyI6eyJsaW1pdHMiOnsic3VicyI6LTEsImRhdGEiOi0xLCJwYXlsb2FkIjotMSwiaW1wb3J0cyI6LTEsImV4cG9ydHMiOi0xLCJ3aWxkY2FyZHMiOnRydWUsImNvbm4iOi0xLCJsZWFmIjotMX0sImRlZmF1bHRfcGVybWlzc2lvbnMiOnsicHViIjp7fSwic3ViIjp7fX0sInR5cGUiOiJhY2NvdW50IiwidmVyc2lvbiI6Mn19.Bw-PfEAy9g3eE1UZDFXqA4xM9_nQtp26bZWKw0nynDduZS5zbOfm6tgz8fxjvyB13yyIoXQzKRqqPL8Ea2ejBA" + } + + options = ServerOptions(resolver_preload=resolver_preload) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "resolver_preload": resolver_preload, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Accounts JWT allowed to connect to the server by default +# Once server is started, accounts can be managed using NATS resolver +# Note that only system account is allowed to communicate with NATS resolver +resolver_preload: {{ + "{list(resolver_preload.keys())[0]}": "{list(resolver_preload.values())[0]}" +}} +""" + ) + + +def test_config_with_jetstream_default_values() -> None: + options = ServerOptions(jetstream=JetStream()) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "jetstream": {}, + } + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable NATS JetStream +jetstream: {} +""" + ) + + +def test_config_with_jetstream_store_dir() -> None: + store_dir = "test" + options = ServerOptions(jetstream=JetStream(store_dir=store_dir)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "jetstream": {"store_dir": store_dir}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable NATS JetStream +jetstream: {{ + "store_dir": "{store_dir}" +}} +""" + ) + + +def test_config_with_jetstream_max_memory_store() -> None: + max_mem = "2M" + options = ServerOptions(jetstream=JetStream(max_mem=max_mem)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "jetstream": {"max_mem": max_mem}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable NATS JetStream +jetstream: {{ + "max_mem": "{max_mem}" +}} +""" + ) + + +def test_config_with_jetstream_max_file() -> None: + max_file = "2M" + options = ServerOptions(jetstream=JetStream(max_file=max_file)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "jetstream": {"max_file": max_file}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable NATS JetStream +jetstream: {{ + "max_file": "{max_file}" +}} +""" + ) + + +def test_config_with_jetstream_encryption_cipher_and_key() -> None: + cipher = "chachapoly" + key = "6dYfBV0zzEkR3vxZCNjxmnVh/aIqgid1" + options = ServerOptions(jetstream=JetStream(cipher=cipher, key=key)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "jetstream": {"cipher": cipher, "key": key}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable NATS JetStream +jetstream: {{ + "cipher": "{cipher}", + "key": "{key}" +}} +""" + ) + + +def test_config_with_jetstream_max_outstanding_catchup() -> None: + max_outstanding_catchup = "2M" + options = ServerOptions( + jetstream=JetStream(max_outstanding_catchup=max_outstanding_catchup) + ) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "jetstream": {"max_outstanding_catchup": max_outstanding_catchup}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable NATS JetStream +jetstream: {{ + "max_outstanding_catchup": "{max_outstanding_catchup}" +}} +""" + ) + + +def test_config_with_jetstream_domain() -> None: + domain = "test" + options = ServerOptions(jetstream=JetStream(domain=domain)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "jetstream": {"domain": domain}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable NATS JetStream +jetstream: {{ + "domain": "{domain}" +}} +""" + ) + + +def test_config_with_leafnodes_default_values() -> None: + options = ServerOptions(leafnodes=LeafNodes(port=7422)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "leafnodes": {"port": 7422}, + } + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure inbound and outbound leafnodes connections +leafnodes: { + # Listen for incoming leafnode connections + port: 7422 +} +""" + ) + + +def test_config_with_leafnodes_with_host() -> None: + host = "0.0.0.0" + options = ServerOptions(leafnodes=LeafNodes(port=7422, host=host)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "leafnodes": {"host": host, "port": 7422}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure inbound and outbound leafnodes connections +leafnodes: {{ + # Listen for incoming leafnode connections + host: {host} + port: 7422 +}} +""" + ) + + +def test_config_with_leafnodes_listen_address() -> None: + address = "0.0.0.0:7422" + options = ServerOptions(leafnodes=LeafNodes(listen=address)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "leafnodes": {"listen": address}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure inbound and outbound leafnodes connections +leafnodes: {{ + # Listen for incoming leafnode connections + listen: {address} +}} +""" + ) + + +def test_config_with_leafnodes_advertise() -> None: + address = "somewhere:7422" + options = ServerOptions(leafnodes=LeafNodes(port=7422, advertise=address)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "leafnodes": {"advertise": address, "port": 7422}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure inbound and outbound leafnodes connections +leafnodes: {{ + # Listen for incoming leafnode connections + port: 7422 + + # Advertise how this server can be contacted by leaf nodes. + advertise: {address} +}} +""" + ) + + +def test_config_with_leafnodes_no_advertise() -> None: + options = ServerOptions(leafnodes=LeafNodes(port=7422, no_advertise=True)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "leafnodes": {"no_advertise": True, "port": 7422}, + } + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure inbound and outbound leafnodes connections +leafnodes: { + # Listen for incoming leafnode connections + port: 7422 + + # Indicate that server shouldn't be advertised to leaf nodes. + no_advertise: true +} +""" + ) + + +def test_config_with_leafnodes_remotes() -> None: + remote_url = "nats-leaf://somewhere:4222" + options = ServerOptions( + leafnodes=LeafNodes(remotes=[RemoteLeafnode(url=remote_url)]) + ) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "leafnodes": {"remotes": [{"url": remote_url}]}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure inbound and outbound leafnodes connections +leafnodes: {{ + # Connect to remote leaf nodes + remotes: [ + {{ + "url": "{remote_url}" + }} + ] +}} +""" + ) + + +def test_config_with_leafnodes_reconnect() -> None: + reconnect = 5 + options = ServerOptions(leafnodes=LeafNodes(reconnect=5)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "leafnodes": {"reconnect": reconnect}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure inbound and outbound leafnodes connections +leafnodes: {{ + # Interval in seconds at which reconnect attempts to a remote server are made + reconnect: {reconnect} +}} +""" + ) + + +def test_config_with_leafnodes_tls(tls_files: TLSCertificates) -> None: + options = ServerOptions( + leafnodes=LeafNodes( + tls=TLS( + cert_file=tls_files.cert_file, + key_file=tls_files.key_file, + ca_file=tls_files.ca_file, + ) + ) + ) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "leafnodes": { + "tls": { + "cert_file": tls_files.cert_file, + "key_file": tls_files.key_file, + "ca_file": tls_files.ca_file, + } + }, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure inbound and outbound leafnodes connections +leafnodes: {{ + # Require leafnodes to connect using TLS + tls: {{ + "ca_file": "{tls_files.ca_file}", + "cert_file": "{tls_files.cert_file}", + "key_file": "{tls_files.key_file}" + }} +}} +""" + ) + + +# TODO: Test leafnodes with authorization + + +def test_config_with_cluster_name() -> None: + cluster_name = "test" + options = ServerOptions(cluster=Cluster(name=cluster_name)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "cluster": {"name": cluster_name}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure cluster mode +cluster: {{ + name: {cluster_name} +}} +""" + ) + + +def test_config_with_cluster_host() -> None: + cluster_host = "0.0.0.0" + options = ServerOptions(cluster=Cluster(host=cluster_host)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "cluster": {"host": cluster_host}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure cluster mode +cluster: {{ + # Listen for incoming cluster connections + host: {cluster_host} +}} +""" + ) + + +def test_config_with_cluster_port() -> None: + cluster_port = 6222 + options = ServerOptions(cluster=Cluster(port=cluster_port)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "cluster": {"port": cluster_port}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure cluster mode +cluster: {{ + # Listen for incoming cluster connections + port: {cluster_port} +}} +""" + ) + + +def test_config_with_cluster_listen() -> None: + address = "0.0.0.0:6222" + options = ServerOptions(cluster=Cluster(listen=address)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "cluster": {"listen": address}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure cluster mode +cluster: {{ + # Listen for incoming cluster connections + listen: {address} +}} +""" + ) + + +def test_config_with_cluster_advertise() -> None: + address = "0.0.0.0:6222" + options = ServerOptions(cluster=Cluster(advertise=address)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "cluster": {"advertise": address}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure cluster mode +cluster: {{ + # Hostport : to advertise how this server can be contacted by other cluster members + advertise: {address} +}} +""" + ) + + +def test_config_with_cluster_no_advertise() -> None: + options = ServerOptions(cluster=Cluster(no_advertise=True)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "cluster": {"no_advertise": True}, + } + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure cluster mode +cluster: { + # Do not send or gossip server client URLs to other servers in the cluster + # Also prevent server telling its client about the other servers' client URLs + no_advertise: true +} +""" + ) + + +def test_config_with_cluster_routes() -> None: + routes = ["nats://somewhere-else:6222"] + options = ServerOptions(cluster=Cluster(routes=routes)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "cluster": {"routes": routes}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure cluster mode +cluster: {{ + # A list of other servers (URLs) to cluster with. Self-routes are ignored. + # Should authentication via token or username/password be required, specify them as part of the URL + routes: [ + "{routes[0]}" + ] +}} +""" + ) + + +def test_config_with_cluster_connect_retries() -> None: + connect_retries = 2 + options = ServerOptions(cluster=Cluster(connect_retries=connect_retries)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "cluster": {"connect_retries": connect_retries}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure cluster mode +cluster: {{ + # After how many failed connect attempts to give up establishing a connection to a discovered route. Default is 0, do not retry. When enabled, attempts will be made once a second. + # This, does not apply to explicitly configured routes + connect_retries: {connect_retries} +}} +""" + ) + + +def test_config_with_cluster_tls(tls_files: TLSCertificates) -> None: + options = ServerOptions( + cluster=Cluster( + tls=TLS( + cert_file=tls_files.cert_file, + key_file=tls_files.key_file, + ca_file=tls_files.ca_file, + ) + ) + ) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "cluster": { + "tls": { + "cert_file": tls_files.cert_file, + "key_file": tls_files.key_file, + "ca_file": tls_files.ca_file, + } + }, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure cluster mode +cluster: {{ + # Configure TLS for communications between cluster members + tls: {{ + "ca_file": "{tls_files.ca_file}", + "cert_file": "{tls_files.cert_file}", + "key_file": "{tls_files.key_file}" + }} +}} +""" + ) + + +# TODO: Test cluster with authorization + + +def test_config_with_websocket_no_tls() -> None: + options = ServerOptions(websocket=Websocket(no_tls=True)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "websocket": {"no_tls": True}, + } + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure websocket server +websocket: { + # Serve plain websocket instead of secured websockets + # Use it only when NATS is served behind a reverse-proxy + # or during development + no_tls: true +} +""" + ) + + +def test_config_with_websocket_tls(tls_files: TLSCertificates) -> None: + options = ServerOptions( + websocket=Websocket( + tls=TLS( + cert_file=tls_files.cert_file, + key_file=tls_files.key_file, + ca_file=tls_files.ca_file, + ) + ) + ) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "websocket": { + "tls": { + "cert_file": tls_files.cert_file, + "key_file": tls_files.key_file, + "ca_file": tls_files.ca_file, + } + }, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure websocket server +websocket: {{ + # Configure TLS + tls: {{ + "ca_file": "{tls_files.ca_file}", + "cert_file": "{tls_files.cert_file}", + "key_file": "{tls_files.key_file}" + }} +}} +""" + ) + + +def test_config_with_websocket_host() -> None: + websocket_host = "0.0.0.0" + options = ServerOptions(websocket=Websocket(host=websocket_host)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "websocket": {"host": websocket_host}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure websocket server +websocket: {{ + # Listen for incoming websocket connections + host: {websocket_host} +}} +""" + ) + + +def test_config_with_websocket_port() -> None: + websocket_port = 6222 + options = ServerOptions(websocket=Websocket(port=websocket_port)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "websocket": {"port": websocket_port}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure websocket server +websocket: {{ + # Listen for incoming websocket connections + port: {websocket_port} +}} +""" + ) + + +def test_config_with_websocket_listen() -> None: + address = "0.0.0.0:6222" + options = ServerOptions(websocket=Websocket(listen=address)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "websocket": {"listen": address}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure websocket server +websocket: {{ + # Listen for incoming websocket connections + listen: {address} +}} +""" + ) + + +def test_config_with_websocket_advertise() -> None: + address = "0.0.0.0:6222" + options = ServerOptions(websocket=Websocket(advertise=address)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "websocket": {"advertise": address}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure websocket server +websocket: {{ + # Hostport : to to be advertised for websocket connections + advertise: {address} +}} +""" + ) + + +def test_config_with_websocket_same_origin() -> None: + options = ServerOptions(websocket=Websocket(same_origin=True)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "websocket": {"same_origin": True}, + } + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure websocket server +websocket: { + # HTTP origin header must match the request’s hostname + # If no Origin header is present, this check will not be performed + same_origin: true +} +""" + ) + + +def test_config_with_websocket_allowed_origins() -> None: + origins = ["example.com"] + options = ServerOptions(websocket=Websocket(allowed_origins=origins)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "websocket": {"allowed_origins": origins}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure websocket server +websocket: {{ + # HTTP origin header must match one of allowed origins + # If no Origin header is present, this check will not be performed + allowed_origins: [ + "{origins[0]}" + ] +}} +""" + ) + + +def test_config_with_websocket_compression() -> None: + options = ServerOptions(websocket=Websocket(compression=True)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "websocket": {"compression": True}, + } + config = render(options) + assert config == ( + """# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure websocket server +websocket: { + # Enable support for compressed websocket frames in the server + # Note: for compression to be used, both server and client have to support it + compression: true +} +""" + ) + + +def test_config_with_websocket_handshake_timeout() -> None: + timeout = "30s" + options = ServerOptions(websocket=Websocket(handshake_timeout=timeout)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "websocket": {"handshake_timeout": timeout}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure websocket server +websocket: {{ + # Total time allowed for the server to read the client request and write the response back + # to the client. + handshake_timeout: "{timeout}" +}} +""" + ) + + +def test_config_with_websocket_jwt_cookie() -> None: + cookie_name = "test" + options = ServerOptions(websocket=Websocket(jwt_cookie=cookie_name)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "websocket": {"jwt_cookie": cookie_name}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure websocket server +websocket: {{ + # Name for an HTTP cookie, that if present will be used as a client JWT + # If the client specifies a JWT in the CONNECT protocol, this option is ignored + jwt_cookie: "{cookie_name}" +}} +""" + ) + + +def test_config_with_websocket_and_no_auth_user() -> None: + account_name = user = password = "test" + accounts = {account_name: Account(users=[User(user=user, password=password)])} + options = ServerOptions(accounts=accounts, websocket=Websocket(no_auth_user=user)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "accounts": non_null(accounts), + "websocket": {"no_auth_user": user}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable multitenancy using accounts +accounts: {{ + "{account_name}": {{ + "users": [ + {{ + "password": "{password}", + "user": "{user}" + }} + ] + }} +}} + +# Configure websocket server +websocket: {{ + # A client connecting without any form of authentication will be associated with this user, its permissions and account + no_auth_user: "{user}" +}} +""" + ) + + +def test_config_with_mqtt_host() -> None: + mqtt_host = "0.0.0.0" + options = ServerOptions(mqtt=MQTT(host=mqtt_host)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "mqtt": {"host": mqtt_host}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure MQTT server +mqtt: {{ + # Listen for incoming MQTT connections + host: {mqtt_host} +}} +""" + ) + + +def test_config_with_mqtt_port() -> None: + mqtt_port = 6222 + options = ServerOptions(mqtt=MQTT(port=mqtt_port)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "mqtt": {"port": mqtt_port}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure MQTT server +mqtt: {{ + # Listen for incoming MQTT connections + port: {mqtt_port} +}} +""" + ) + + +def test_config_with_mqtt_listen() -> None: + address = "0.0.0.0:6222" + options = ServerOptions(mqtt=MQTT(listen=address)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "mqtt": {"listen": address}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure MQTT server +mqtt: {{ + # Listen for incoming MQTT connections + listen: {address} +}} +""" + ) + + +def test_config_with_mqtt_ack_wait() -> None: + ack_wait = "10s" + options = ServerOptions(mqtt=MQTT(ack_wait=ack_wait)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "mqtt": {"ack_wait": ack_wait}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure MQTT server +mqtt: {{ + # The amount of time after which a QoS 1 message sent to + # a client is redelivered as a DUPLICATE if the server has not + # received the PUBACK packet on the original Packet Identifier + ack_wait: "{ack_wait}" +}} +""" + ) + + +def test_config_with_mqtt_max_ack_pending() -> None: + max_ack_pending = 10 + options = ServerOptions(mqtt=MQTT(max_ack_pending=max_ack_pending)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "mqtt": {"max_ack_pending": max_ack_pending}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure MQTT server +mqtt: {{ + # amount of QoS 1 messages the server can send to + # a subscription without receiving any PUBACK for those messages + # The valid range is [0..65535] + max_ack_pending: {max_ack_pending} +}} +""" + ) + + +def test_config_with_mqtt_tls(tls_files: TLSCertificates) -> None: + options = ServerOptions( + mqtt=MQTT( + tls=TLS( + cert_file=tls_files.cert_file, + key_file=tls_files.key_file, + ca_file=tls_files.ca_file, + ) + ) + ) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "mqtt": { + "tls": { + "cert_file": tls_files.cert_file, + "key_file": tls_files.key_file, + "ca_file": tls_files.ca_file, + } + }, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 + +# Configure MQTT server +mqtt: {{ + # Configure TLS + tls: {{ + "ca_file": "{tls_files.ca_file}", + "cert_file": "{tls_files.cert_file}", + "key_file": "{tls_files.key_file}" + }} +}} +""" + ) + + +def test_config_with_mqtt_and_no_auth_user() -> None: + account_name = user = password = "test" + accounts = {account_name: Account(users=[User(user=user, password=password)])} + options = ServerOptions(accounts=accounts, mqtt=MQTT(no_auth_user=user)) + assert non_null(options) == { + "host": "0.0.0.0", + "port": 4222, + "accounts": non_null(accounts), + "mqtt": {"no_auth_user": user}, + } + config = render(options) + assert config == ( + f"""# Auto-generated +# NATS server listening host +host: 0.0.0.0 +# NATS server listening port +port: 4222 +# Enable multitenancy using accounts +accounts: {{ + "{account_name}": {{ + "users": [ + {{ + "password": "{password}", + "user": "{user}" + }} + ] + }} +}} + +# Configure MQTT server +mqtt: {{ + # A client connecting without any form of authentication will be associated with this user, its permissions and account + no_auth_user: "{user}" +}} +""" + )