diff --git a/.gitignore b/.gitignore index 940794e6..5dd61f94 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,6 @@ dlldata.c project.lock.json project.fragment.lock.json artifacts/ -**/Properties/launchSettings.json *_i.c *_p.c @@ -286,3 +285,5 @@ __pycache__/ *.btm.cs *.odx.cs *.xsd.cs + +.DS_Store diff --git a/Build.ps1 b/Build.ps1 index 2b67ba43..29f5ea0f 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -86,8 +86,7 @@ Clean-Output Create-ArtifactDir Restore-Packages Publish-Archives($version) -# Temporarily disabled while SerilogTracing is in pre-release -# Publish-DotNetTool($version) +Publish-DotNetTool($version) Execute-Tests($version) Publish-Docs($version) diff --git a/README.md b/README.md index 1e16c8c4..6187b0ed 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ To connect to Seq in a docker container on the local machine use the machine's I Use Docker networks and volumes to make local files and other containers accessible to `seqcli` within its container. - ### Connecting without an API key If you're automating Seq setup, chances are you won't have an API key yet for `seqcli` to use. During the initial Seq server configuration, you can specify `firstRun.adminUsername` and `firstRun.adminPasswordHash` (or the equivalent environment variables `SEQ_FIRSTRUN_ADMINUSERNAME` and `SEQ_FIRSTRUN_ADMINPASSWORDHASH`) to set an initial username and password for the administrator account. You can use these to create an API key, and then use the API key token with the remaining `seqcli` commands. @@ -60,41 +59,72 @@ When connecting with an API key the allowed operations are determined by the [pe To determine the permission required for a command check the 'Permission demand' column of the [equivalent server API operation](https://docs.datalust.co/docs/server-http-api). For example, the command `apikey create` uses the [`POST api/apikeys` endpoint](https://docs.datalust.co/docs/server-http-api#apiapikeys), which requires the `Write` permission. -## Commands +## Usage -Usage: +All `seqcli` commands follow the same pattern: ``` seqcli [] ``` -Available commands: +### Command help + +The complete list of supported commands can be viewed by running: + +``` +seqcli help +``` + +To show usage information for a specific command, run `seqcli help `, for example: + +``` +seqcli help apikey create +``` + +This also works for command groups; to list all `apikey` sub-commands, run: + +``` +seqcli help apikey +``` + +## Available commands - `apikey` - [`apikey create`](#apikey-create) — Create an API key for automation or ingestion. - [`apikey list`](#apikey-list) — List available API keys. - [`apikey remove`](#apikey-remove) — Remove an API key from the server. + - [`apikey update`](#apikey-update) — Update an existing API key. - `app` - [`app define`](#app-define) — Generate an app definition for a .NET `[SeqApp]` plug-in. - [`app install`](#app-install) — Install an app package. - [`app list`](#app-list) — List installed app packages. - [`app run`](#app-run) — Host a .NET `[SeqApp]` plug-in. + - [`app uninstall`](#app-uninstall) — Uninstall an app package. - [`app update`](#app-update) — Update an installed app package. - `appinstance` - [`appinstance create`](#appinstance-create) — Create an instance of an installed app. - [`appinstance list`](#appinstance-list) — List instances of installed apps. - [`appinstance remove`](#appinstance-remove) — Remove an app instance from the server. + - [`appinstance update`](#appinstance-update) — Update an existing app instance. - [`bench`](#bench) — Measure query performance. - [`config`](#config) — View and set fields in the `SeqCli.json` file; run with no arguments to list all fields. - `dashboard` - [`dashboard list`](#dashboard-list) — List dashboards. - [`dashboard remove`](#dashboard-remove) — Remove a dashboard from the server. - [`dashboard render`](#dashboard-render) — Produce a CSV or JSON result set from a dashboard chart. +- `expressionindex` + - [`expressionindex create`](#expressionindex-create) — Create an expression index. + - [`expressionindex list`](#expressionindex-list) — List expression indexes. + - [`expressionindex remove`](#expressionindex-remove) — Remove an expression index from the server. - `feed` - [`feed create`](#feed-create) — Create a NuGet feed. - [`feed list`](#feed-list) — List NuGet feeds. - [`feed remove`](#feed-remove) — Remove a NuGet feed from the server. + - [`feed update`](#feed-update) — Update an existing NuGet feed. - [`help`](#help) — Show information about available commands. +- `index` + - [`index list`](#index-list) — List indexes. + - [`index suppress`](#index-suppress) — Suppress an index. - [`ingest`](#ingest) — Send log events from a file or `STDIN`. - [`license apply`](#license-apply) — Apply a license to the Seq server. - [`log`](#log) — Send a structured log event to the server. @@ -112,6 +142,7 @@ Available commands: - [`retention create`](#retention-create) — Create a retention policy. - [`retention list`](#retention-list) — List retention policies. - [`retention remove`](#retention-remove) — Remove a retention policy from the server. + - [`retention update`](#retention-update) — Update an existing retention policy. - `sample` - [`sample ingest`](#sample-ingest) — Log sample events into a Seq instance. - [`sample setup`](#sample-setup) — Configure a Seq instance with sample dashboards, signals, users, and so on. @@ -126,6 +157,7 @@ Available commands: - [`signal import`](#signal-import) — Import signals in newline-delimited JSON format. - [`signal list`](#signal-list) — List available signals. - [`signal remove`](#signal-remove) — Remove a signal from the server. + - [`signal update`](#signal-update) — Update an existing signal. - [`tail`](#tail) — Stream log events matching a filter. - `template` - [`template export`](#template-export) — Export entities into template files. @@ -134,11 +166,13 @@ Available commands: - [`user create`](#user-create) — Create a user. - [`user list`](#user-list) — List users. - [`user remove`](#user-remove) — Remove a user from the server. + - [`user update`](#user-update) — Update an existing user. - [`version`](#version) — Print the current executable version. - `workspace` - [`workspace create`](#workspace-create) — Create a workspace. - [`workspace list`](#workspace-list) — List available workspaces. - [`workspace remove`](#workspace-remove) — Remove a workspace from the server. + - [`workspace update`](#workspace-update) — Update an existing workspace. ### `apikey create` @@ -159,7 +193,7 @@ seqcli apikey create -t 'Test API Key' -p Environment=Test | `--minimum-level=VALUE` | The minimum event level/severity to accept; the default is to accept all events | | `--use-server-timestamps` | Discard client-supplied timestamps and use server clock values | | `--permissions=VALUE` | A comma-separated list of permissions to delegate to the API key; valid permissions are `Ingest` (default), `Read`, `Write`, `Project` and `System` | -| `--connect-username=VALUE` | A username to connect with, useful primarily when setting up the first API key | +| `--connect-username=VALUE` | A username to connect with, useful primarily when setting up the first API key; servers with an 'Individual' subscription only allow one simultaneous request with this option | | `--connect-password=VALUE` | When `connect-username` is specified, a corresponding password | | `--connect-password-stdin` | When `connect-username` is specified, read the corresponding password from `STDIN` | | `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | @@ -208,6 +242,24 @@ seqcli apikey remove -t 'Test API Key' | `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | | `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +### `apikey update` + +Update an existing API key. + +Example: + +``` +seqcli apikey update --json '{...}' +``` + +| Option | Description | +| ------ | ----------- | +| `--json=VALUE` | The updated API key in JSON format; this can be produced using `seqcli apikey list --json` | +| `--json-stdin` | Read the updated API key as JSON from `STDIN` | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + ### `app define` Generate an app definition for a .NET `[SeqApp]` plug-in. @@ -289,6 +341,24 @@ seqcli tail --json | seqcli app run -d "./bin/Debug/netstandard2.2" -p ToAddress | `--id=VALUE` | The app instance id, used only for app configuration; defaults to a placeholder id. | | `--read-env` | Read app configuration and settings from environment variables, as specified in https://docs.datalust.co/docs/seq-apps-in-other-languages; ignores all options except --directory and --type | +### `app uninstall` + +Uninstall an app package. + +Example: + +``` +seqcli app uninstall --package-id 'Seq.App.JsonArchive' +``` + +| Option | Description | +| ------ | ----------- | +| `--package-id=VALUE` | The package id of the app package to uninstall | +| `-i`, `--id=VALUE` | The id of a single app package to uninstall | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + ### `app update` Update an installed app package. @@ -328,7 +398,7 @@ seqcli appinstance create -t 'Email Ops' --app hostedapp-314159 -p To=ops@exampl | `-t`, `--title=VALUE` | A title for the app instance | | `--app=VALUE` | The id of the installed app package to instantiate | | `-p`, `--property=NAME=VALUE` | Specify name/value settings for the app, e.g. `-p ToAddress=example@example.com -p Subject="Alert!"` | -| `--stream[=VALUE]` | Stream incoming events to this app instance as they're ingested; optionally accepts a signal expression limiting which events should be streamed | +| `--stream[=VALUE]` | Stream incoming events to this app instance as they're ingested; optionally accepts a signal expression limiting which events should be streamed, for example `signal-1,signal-2` | | `--overridable=VALUE` | Specify setting names that may be overridden by users when invoking the app | | `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | | `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | @@ -376,6 +446,24 @@ seqcli appinstance remove -t 'Email Ops' | `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | | `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +### `appinstance update` + +Update an existing app instance. + +Example: + +``` +seqcli appinstance update --json '{...}' +``` + +| Option | Description | +| ------ | ----------- | +| `--json=VALUE` | The updated app instance in JSON format; this can be produced using `seqcli appinstance list --json` | +| `--json-stdin` | Read the updated app instance as JSON from `STDIN` | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + ### `bench` Measure query performance. @@ -473,6 +561,63 @@ seqcli dashboard render -i dashboard-159 -c 'Response Time (ms)' --last 7d --by | `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | | `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +### `expressionindex create` + +Create an expression index. + +Example: + +``` +seqcli expressionindex create --expression "ServerName" +``` + +| Option | Description | +| ------ | ----------- | +| `-e`, `--expression=VALUE` | The expression to index | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +| `--json` | Print output in newline-delimited JSON (the default is plain text) | +| `--no-color` | Don't colorize text output | +| `--force-color` | Force redirected output to have ANSI color (unless `--no-color` is also specified) | + +### `expressionindex list` + +List expression indexes. + +Example: + +``` +seqcli expressionindex list +``` + +| Option | Description | +| ------ | ----------- | +| `-i`, `--id=VALUE` | The id of a single expression index to list | +| `--json` | Print output in newline-delimited JSON (the default is plain text) | +| `--no-color` | Don't colorize text output | +| `--force-color` | Force redirected output to have ANSI color (unless `--no-color` is also specified) | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + +### `expressionindex remove` + +Remove an expression index from the server. + +Example: + +``` +seqcli expressionindex -i expressionindex-2529 +``` + +| Option | Description | +| ------ | ----------- | +| `-i`, `--id=VALUE` | The id of an expression index to remove | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + ### `feed create` Create a NuGet feed. @@ -536,6 +681,24 @@ seqcli feed remove -n CI | `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | | `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +### `feed update` + +Update an existing NuGet feed. + +Example: + +``` +seqcli feed update --json '{...}' +``` + +| Option | Description | +| ------ | ----------- | +| `--json=VALUE` | The updated NuGet feed in JSON format; this can be produced using `seqcli feed list --json` | +| `--json-stdin` | Read the updated NuGet feed as JSON from `STDIN` | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + ### `help` Show information about available commands. @@ -550,6 +713,43 @@ seqcli help search | ------ | ----------- | | `-m`, `--markdown` | Generate markdown for use in documentation | +### `index list` + +List indexes. + +Example: + +``` +seqcli index list +``` + +| Option | Description | +| ------ | ----------- | +| `-i`, `--id=VALUE` | The id of a single index to list | +| `--json` | Print output in newline-delimited JSON (the default is plain text) | +| `--no-color` | Don't colorize text output | +| `--force-color` | Force redirected output to have ANSI color (unless `--no-color` is also specified) | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + +### `index suppress` + +Suppress an index. + +Example: + +``` +seqcli index suppress -i index-2191448f1d9b4f22bd32c6edef752748 +``` + +| Option | Description | +| ------ | ----------- | +| `-i`, `--id=VALUE` | The id of an index to suppress | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + ### `ingest` Send log events from a file or `STDIN`. @@ -812,6 +1012,24 @@ seqcli retention remove -i retentionpolicy-17 | `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | | `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +### `retention update` + +Update an existing retention policy. + +Example: + +``` +seqcli retention update --json '{...}' +``` + +| Option | Description | +| ------ | ----------- | +| `--json=VALUE` | The updated retention policy in JSON format; this can be produced using `seqcli retention list --json` | +| `--json-stdin` | Read the updated retention policy as JSON from `STDIN` | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + ### `sample ingest` Log sample events into a Seq instance. @@ -998,6 +1216,24 @@ seqcli signal remove -t 'Test Signal' | `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | | `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +### `signal update` + +Update an existing signal. + +Example: + +``` +seqcli signal update --json '{...}' +``` + +| Option | Description | +| ------ | ----------- | +| `--json=VALUE` | The updated signal in JSON format; this can be produced using `seqcli signal list --json` | +| `--json-stdin` | Read the updated signal as JSON from `STDIN` | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + ### `tail` Stream log events matching a filter. @@ -1117,6 +1353,24 @@ seqcli user remove -n alice | `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | | `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +### `user update` + +Update an existing user. + +Example: + +``` +seqcli user update --json '{...}' +``` + +| Option | Description | +| ------ | ----------- | +| `--json=VALUE` | The updated user in JSON format; this can be produced using `seqcli user list --json` | +| `--json-stdin` | Read the updated user as JSON from `STDIN` | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + ### `version` Print the current executable version. @@ -1185,6 +1439,24 @@ seqcli workspace remove -t 'My Workspace' | `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | | `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +### `workspace update` + +Update an existing workspace. + +Example: + +``` +seqcli workspace update --json '{...}' +``` + +| Option | Description | +| ------ | ----------- | +| `--json=VALUE` | The updated workspace in JSON format; this can be produced using `seqcli workspace list --json` | +| `--json-stdin` | Read the updated workspace as JSON from `STDIN` | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + ## Extraction patterns The `seqcli ingest` command can be used for parsing plain text logs into structured log events. diff --git a/appveyor.yml b/appveyor.yml index ba62cb76..74bd2fdb 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,4 @@ -version: 2024.1.{build} +version: 2024.3.{build} skip_tags: true image: - Visual Studio 2022 @@ -31,7 +31,7 @@ for: - provider: NuGet api_key: - secure: +WuB7C/ehQ955aj7fus2FFZWhtJ8/SSxViv+xmtlV2GkYyMl3B7rhhavBgNORxCt + secure: V+MlStLrFQQKjtbJQQ9RDTGD2VPlPJaQhkXayCO3AM4dWBKf5/fQWJgStZyK6Tx3 skip_symbols: true artifact: /seqcli\..*\.nupkg/ on: diff --git a/global.json b/global.json index f2068dc8..1658e451 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.101" + "version": "8.0.204" } -} \ No newline at end of file +} diff --git a/src/Roastery/Data/Database.cs b/src/Roastery/Data/Database.cs index 067dea7b..a166d5b7 100644 --- a/src/Roastery/Data/Database.cs +++ b/src/Roastery/Data/Database.cs @@ -9,7 +9,6 @@ using Serilog; using Serilog.Events; using SerilogTracing; -using SerilogTracing.Instrumentation; namespace Roastery.Data; diff --git a/src/Roastery/Roastery.csproj b/src/Roastery/Roastery.csproj index f5c03f1e..6451f899 100644 --- a/src/Roastery/Roastery.csproj +++ b/src/Roastery/Roastery.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Roastery/Web/RequestLoggingMiddleware.cs b/src/Roastery/Web/RequestLoggingMiddleware.cs index ea55f25f..f0cdb784 100644 --- a/src/Roastery/Web/RequestLoggingMiddleware.cs +++ b/src/Roastery/Web/RequestLoggingMiddleware.cs @@ -5,7 +5,6 @@ using Serilog.Context; using Serilog.Events; using SerilogTracing; -using SerilogTracing.Instrumentation; namespace Roastery.Web; diff --git a/src/SeqCli/Cli/CommandMetadata.cs b/src/SeqCli/Cli/CommandMetadata.cs index 49b18d89..997c450a 100644 --- a/src/SeqCli/Cli/CommandMetadata.cs +++ b/src/SeqCli/Cli/CommandMetadata.cs @@ -16,9 +16,9 @@ namespace SeqCli.Cli; public class CommandMetadata : ICommandMetadata { - public string Name { get; set; } = null!; + public required string Name { get; set; } public string? SubCommand { get; set; } - public string HelpText { get; set; } = null!; + public required string HelpText { get; set; } public string? Example { get; set; } public bool IsPreview { get; set; } } diff --git a/src/SeqCli/Cli/Commands/ApiKey/UpdateCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/UpdateCommand.cs new file mode 100644 index 00000000..b8ecff35 --- /dev/null +++ b/src/SeqCli/Cli/Commands/ApiKey/UpdateCommand.cs @@ -0,0 +1,25 @@ +// Copyright © Datalust Pty Ltd and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Seq.Api; +using SeqCli.Connection; + +namespace SeqCli.Cli.Commands.ApiKey; + +[Command("apikey", "update", + "Update an existing API key", + Example="seqcli apikey update --json '{...}'")] +class UpdateCommand(SeqConnectionFactory connectionFactory): + Shared.UpdateCommand(connectionFactory, "apikey", nameof(SeqConnection.ApiKeys), "API key"); + \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/App/UninstallCommand.cs b/src/SeqCli/Cli/Commands/App/UninstallCommand.cs new file mode 100644 index 00000000..c9d275a9 --- /dev/null +++ b/src/SeqCli/Cli/Commands/App/UninstallCommand.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using SeqCli.Cli.Features; +using SeqCli.Connection; +using SeqCli.Util; +using Serilog; + +namespace SeqCli.Cli.Commands.App; + +[Command("app", "uninstall", "Uninstall an app package", + Example = "seqcli app uninstall --package-id 'Seq.App.JsonArchive'")] +// ReSharper disable once UnusedType.Global +class UninstallCommand : Command +{ + readonly SeqConnectionFactory _connectionFactory; + + string? _packageId, _id; + readonly ConnectionFeature _connection; + + public UninstallCommand(SeqConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + + Options.Add( + "package-id=", + "The package id of the app package to uninstall", + packageId => _packageId = ArgumentString.Normalize(packageId)); + + Options.Add( + "i=|id=", + "The id of a single app package to uninstall", + t => _id = ArgumentString.Normalize(t)); + + _connection = Enable(); + } + + protected override async Task Run() + { + if (_packageId == null && _id == null) + { + Log.Error("A `package-id` or `id` must be specified"); + return 1; + } + + var connection = _connectionFactory.Connect(_connection); + + var toRemove = _id != null ? [await connection.Apps.FindAsync(_id)] + : (await connection.Apps.ListAsync()) + .Where(app => _packageId == app.Package.PackageId) + .ToArray(); + + if (!toRemove.Any()) + { + Log.Error("No matching API key was found"); + return 1; + } + + foreach (var app in toRemove) + await connection.Apps.RemoveAsync(app); + + return 0; + } +} diff --git a/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs b/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs index bed979df..5b71c2cc 100644 --- a/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs @@ -52,7 +52,7 @@ public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config Options.Add( "stream:", - "Stream incoming events to this app instance as they're ingested; optionally accepts a signal expression limiting which events should be streamed", + "Stream incoming events to this app instance as they're ingested; optionally accepts a signal expression limiting which events should be streamed, for example `signal-1,signal-2`", s => { _streamIncomingEvents = true; @@ -116,4 +116,4 @@ bool ValidateSettingName(string settingName) return 0; } -} \ No newline at end of file +} diff --git a/src/SeqCli/Cli/Commands/AppInstance/UpdateCommand.cs b/src/SeqCli/Cli/Commands/AppInstance/UpdateCommand.cs new file mode 100644 index 00000000..f46d7760 --- /dev/null +++ b/src/SeqCli/Cli/Commands/AppInstance/UpdateCommand.cs @@ -0,0 +1,25 @@ +// Copyright © Datalust Pty Ltd and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Seq.Api; +using SeqCli.Connection; + +namespace SeqCli.Cli.Commands.AppInstance; + +[Command("appinstance", "update", + "Update an existing app instance", + Example="seqcli appinstance update --json '{...}'")] +class UpdateCommand(SeqConnectionFactory connectionFactory): + Shared.UpdateCommand(connectionFactory, "appinstance", nameof(SeqConnection.AppInstances), "app instance"); + \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Bench/QueryBenchCase.cs b/src/SeqCli/Cli/Commands/Bench/QueryBenchCase.cs index 42fcaa99..ce31c26e 100644 --- a/src/SeqCli/Cli/Commands/Bench/QueryBenchCase.cs +++ b/src/SeqCli/Cli/Commands/Bench/QueryBenchCase.cs @@ -18,8 +18,8 @@ namespace SeqCli.Cli.Commands.Bench; class QueryBenchCase { - public string Id { get; set; } = null!; - public string Query { get; set; } = null!; + public required string Id { get; set; } + public required string Query { get; set; } public string? SignalExpression { get; set; } // Not used programmatically at this time. diff --git a/src/SeqCli/Cli/Commands/ConfigCommand.cs b/src/SeqCli/Cli/Commands/ConfigCommand.cs index f991dc44..3cc565e6 100644 --- a/src/SeqCli/Cli/Commands/ConfigCommand.cs +++ b/src/SeqCli/Cli/Commands/ConfigCommand.cs @@ -19,6 +19,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; +using Newtonsoft.Json; using SeqCli.Config; using SeqCli.Util; using Serilog; @@ -142,11 +143,12 @@ static void List(SeqCliConfig config) static IEnumerable> ReadPairs(object config) { foreach (var property in config.GetType().GetTypeInfo().DeclaredProperties - .Where(p => p.CanRead && p.GetMethod!.IsPublic && !p.GetMethod.IsStatic && !p.Name.StartsWith("Encoded")) + .Select(p => new { Property = p, Name = GetConfigPropertyName(p)}) + .Where(p => p.Property.CanRead && p.Property.GetMethod!.IsPublic && !p.Property.GetMethod.IsStatic && !p.Name.StartsWith("encoded", StringComparison.OrdinalIgnoreCase)) .OrderBy(p => p.Name)) { var propertyName = Camelize(property.Name); - var propertyValue = property.GetValue(config); + var propertyValue = property.Property.GetValue(config); if (propertyValue is IDictionary dict) { @@ -175,11 +177,16 @@ static void List(SeqCliConfig config) } } + static string GetConfigPropertyName(PropertyInfo property) + { + return property.GetCustomAttribute()?.PropertyName ?? property.Name; + } + static string Camelize(string s) { if (s.Length < 2) throw new NotSupportedException("No camel-case support for short names"); - return char.ToLowerInvariant(s[0]) + s.Substring(1); + return char.ToLowerInvariant(s[0]) + s[1..]; } static string Format(object? value) diff --git a/src/SeqCli/Cli/Commands/ExpressionIndex/CreateCommand.cs b/src/SeqCli/Cli/Commands/ExpressionIndex/CreateCommand.cs new file mode 100644 index 00000000..5fc39086 --- /dev/null +++ b/src/SeqCli/Cli/Commands/ExpressionIndex/CreateCommand.cs @@ -0,0 +1,70 @@ +// Copyright © Datalust Pty Ltd and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading.Tasks; +using Seq.Api.Model.Signals; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Connection; +using SeqCli.Signals; +using SeqCli.Syntax; +using SeqCli.Util; +using Serilog; + +namespace SeqCli.Cli.Commands.ExpressionIndex; + +[Command("expressionindex", "create", "Create an expression index", + Example = "seqcli expressionindex create --expression \"ServerName\"")] +class CreateCommand : Command +{ + readonly SeqConnectionFactory _connectionFactory; + + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + + string? _expression; + + public CreateCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + { + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + + Options.Add( + "e=|expression=", + "The expression to index", + v => _expression = ArgumentString.Normalize(v)); + + _connection = Enable(); + _output = Enable(new OutputFormatFeature(config.Output)); + } + + protected override async Task Run() + { + var connection = _connectionFactory.Connect(_connection); + + if (string.IsNullOrEmpty(_expression)) + { + Log.Error("An `expression` must be specified"); + return 1; + } + + var index = await connection.ExpressionIndexes.TemplateAsync(); + index.Expression = _expression; + index = await connection.ExpressionIndexes.AddAsync(index); + + _output.WriteEntity(index); + + return 0; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/ExpressionIndex/ListCommand.cs b/src/SeqCli/Cli/Commands/ExpressionIndex/ListCommand.cs new file mode 100644 index 00000000..6836d3f1 --- /dev/null +++ b/src/SeqCli/Cli/Commands/ExpressionIndex/ListCommand.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Connection; + +namespace SeqCli.Cli.Commands.ExpressionIndex; + +[Command("expressionindex", "list", "List expression indexes", Example="seqcli expressionindex list")] +class ListCommand : Command +{ + readonly SeqConnectionFactory _connectionFactory; + + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + string? _id; + + public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + { + if (config == null) throw new ArgumentNullException(nameof(config)); + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + + Options.Add( + "i=|id=", + "The id of a single expression index to list", + id => _id = id); + + _output = Enable(new OutputFormatFeature(config.Output)); + _connection = Enable(); + } + + protected override async Task Run() + { + var connection = _connectionFactory.Connect(_connection); + var list = _id is not null + ? [await connection.ExpressionIndexes.FindAsync(_id)] + : await connection.ExpressionIndexes.ListAsync(); + _output.ListEntities(list); + return 0; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs b/src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs new file mode 100644 index 00000000..c7ebee37 --- /dev/null +++ b/src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs @@ -0,0 +1,59 @@ +// Copyright 2018 Datalust Pty Ltd and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using SeqCli.Cli.Features; +using SeqCli.Connection; +using Serilog; + +namespace SeqCli.Cli.Commands.ExpressionIndex; + +[Command("expressionindex", "remove", "Remove an expression index from the server", + Example = "seqcli expressionindex -i expressionindex-2529")] +class RemoveCommand : Command +{ + readonly SeqConnectionFactory _connectionFactory; + + readonly ConnectionFeature _connection; + string? _id; + + public RemoveCommand(SeqConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + + Options.Add( + "i=|id=", + "The id of an expression index to remove", + id => _id = id); + + _connection = Enable(); + } + + protected override async Task Run() + { + if (_id == null) + { + Log.Error("An `id` must be specified"); + return 1; + } + + var connection = _connectionFactory.Connect(_connection); + var toRemove = await connection.ExpressionIndexes.FindAsync(_id); + await connection.ExpressionIndexes.RemoveAsync(toRemove); + + return 0; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Feed/UpdateCommand.cs b/src/SeqCli/Cli/Commands/Feed/UpdateCommand.cs new file mode 100644 index 00000000..1dd5d265 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Feed/UpdateCommand.cs @@ -0,0 +1,25 @@ +// Copyright © Datalust Pty Ltd and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Seq.Api; +using SeqCli.Connection; + +namespace SeqCli.Cli.Commands.Feed; + +[Command("feed", "update", + "Update an existing NuGet feed", + Example="seqcli feed update --json '{...}'")] +class UpdateCommand(SeqConnectionFactory connectionFactory): + Shared.UpdateCommand(connectionFactory, "feed", nameof(SeqConnection.Feeds), "NuGet feed"); + \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs index 4e69cebd..06f007fb 100644 --- a/src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs @@ -151,25 +151,6 @@ protected override async Task Run(string[] unrecognized) if (container == null) throw new Exception("Host did not build container."); - app.Use(async (context, next) => - { - try - { - await next(); - } - // ISSUE: this exception type isn't currently used. - catch (RequestProcessingException rex) - { - if (context.Response.HasStarted) - throw; - - context.Response.StatusCode = (int)rex.StatusCode; - context.Response.ContentType = "text/plain; charset=UTF-8"; - await context.Response.Body.WriteAsync(Encoding.UTF8.GetBytes(rex.Message)); - await context.Response.CompleteAsync(); - } - }); - foreach (var mapper in container.Resolve>()) { mapper.MapEndpoints(app); diff --git a/src/SeqCli/Cli/Commands/Index/ListCommand.cs b/src/SeqCli/Cli/Commands/Index/ListCommand.cs new file mode 100644 index 00000000..958451b5 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Index/ListCommand.cs @@ -0,0 +1,61 @@ +// Copyright 2018 Datalust Pty Ltd and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Seq.Api.Model.Indexes; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Connection; + +namespace SeqCli.Cli.Commands.Index; + +[Command("index", "list", "List indexes", Example="seqcli index list")] +class ListCommand : Command +{ + readonly SeqConnectionFactory _connectionFactory; + + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + string? _id; + + public ListCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + { + if (config == null) throw new ArgumentNullException(nameof(config)); + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + + Options.Add( + "i=|id=", + "The id of a single index to list", + id => _id = id); + + _output = Enable(new OutputFormatFeature(config.Output)); + _connection = Enable(); + } + + protected override async Task Run() + { + var connection = _connectionFactory.Connect(_connection); + + var list = _id is not null + ? [await connection.Indexes.FindAsync(_id)] + : await connection.Indexes.ListAsync(); + + _output.ListEntities(list); + + return 0; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs b/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs new file mode 100644 index 00000000..5aa495bd --- /dev/null +++ b/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs @@ -0,0 +1,59 @@ +// Copyright 2018 Datalust Pty Ltd and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading.Tasks; +using Seq.Api.Model.Indexes; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Connection; +using Serilog; + +namespace SeqCli.Cli.Commands.Index; + +[Command("index", "suppress", "Suppress an index", Example="seqcli index suppress -i index-2191448f1d9b4f22bd32c6edef752748")] +class SuppressCommand : Command +{ + readonly SeqConnectionFactory _connectionFactory; + readonly ConnectionFeature _connection; + string? _id; + + public SuppressCommand(SeqConnectionFactory connectionFactory, SeqCliConfig config) + { + if (config == null) throw new ArgumentNullException(nameof(config)); + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + + Options.Add( + "i=|id=", + "The id of an index to suppress", + id => _id = id); + + _connection = Enable(); + } + + protected override async Task Run() + { + if (_id == null) + { + Log.Error("An `id` must be specified"); + return 1; + } + + var connection = _connectionFactory.Connect(_connection); + var toSuppress = await connection.Indexes.FindAsync(_id); + await connection.Indexes.SuppressAsync(toSuppress); + + return 0; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/LogCommand.cs b/src/SeqCli/Cli/Commands/LogCommand.cs index ee79356c..536245a9 100644 --- a/src/SeqCli/Cli/Commands/LogCommand.cs +++ b/src/SeqCli/Cli/Commands/LogCommand.cs @@ -86,7 +86,7 @@ protected override async Task Run() continue; var name = key.Trim(); - if (name.StartsWith("@")) + if (name.StartsWith('@')) name = $"@{name}"; payload[name] = new JValue(value); diff --git a/src/SeqCli/Cli/Commands/RetentionPolicy/UpdateCommand.cs b/src/SeqCli/Cli/Commands/RetentionPolicy/UpdateCommand.cs new file mode 100644 index 00000000..12dded9e --- /dev/null +++ b/src/SeqCli/Cli/Commands/RetentionPolicy/UpdateCommand.cs @@ -0,0 +1,25 @@ +// Copyright © Datalust Pty Ltd and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Seq.Api; +using SeqCli.Connection; + +namespace SeqCli.Cli.Commands.RetentionPolicy; + +[Command("retention", "update", + "Update an existing retention policy", + Example="seqcli retention update --json '{...}'")] +class UpdateCommand(SeqConnectionFactory connectionFactory): + Shared.UpdateCommand(connectionFactory, "retention", nameof(SeqConnection.RetentionPolicies), "retention policy"); + \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Shared/UpdateCommand.cs b/src/SeqCli/Cli/Commands/Shared/UpdateCommand.cs new file mode 100644 index 00000000..df555ee8 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Shared/UpdateCommand.cs @@ -0,0 +1,92 @@ +// Copyright © Datalust Pty Ltd and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using SeqCli.Cli.Features; +using SeqCli.Connection; +using SeqCli.Templates.Ast; +using SeqCli.Templates.Import; +using SeqCli.Templates.Parser; +using SeqCli.Util; +using Serilog; + +namespace SeqCli.Cli.Commands.Shared; + +abstract class UpdateCommand: Command +{ + readonly SeqConnectionFactory _connectionFactory; + + readonly ConnectionFeature _connection; + readonly string _resourceGroupName; + readonly string _entityName; + + string? _json; + bool _jsonStdin; + + protected UpdateCommand(SeqConnectionFactory connectionFactory, string commandGroupName, string resourceGroupName, string? entityName = null) + { + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + _resourceGroupName = resourceGroupName; + _entityName = entityName ?? commandGroupName; + + Options.Add( + "json=", + $"The updated {_entityName} in JSON format; this can be produced using `seqcli {commandGroupName} list --json`", + p => _json = ArgumentString.Normalize(p)); + + Options.Add( + "json-stdin", + $"Read the updated {_entityName} as JSON from `STDIN`", + _ => _jsonStdin = true); + + _connection = Enable(); + } + + protected override async Task Run() + { + var connection = _connectionFactory.Connect(_connection); + + if (_json == null && !_jsonStdin) + { + Log.Error("One of either `--json` or `--json-stdin` must be specified"); + return 1; + } + + var json = _json ?? await Console.In.ReadToEndAsync(); + + if (!JsonTemplateParser.TryParse(json, out var template, out var error, out _)) + { + Log.Error("The {EntityName} JSON could not be parsed: {Error}", _entityName, error); + return 1; + } + + if (template is not JsonTemplateObject obj || + !obj.Members.TryGetValue("Id", out var idValue) || + idValue is not JsonTemplateString id) + { + Log.Error("The {EntityName} JSON must be an object literal with a valid string `Id` property", _entityName); + return 1; + } + + var templateName = "JSON"; + var entityTemplate = new EntityTemplate(_resourceGroupName, templateName, template); + var state = new TemplateImportState(); + state.AddOrUpdateCreatedEntityId(templateName, id.Value); + await TemplateSetImporter.ImportAsync([entityTemplate], connection, new Dictionary(), state, merge: false); + + return 0; + } +} diff --git a/src/SeqCli/Cli/Commands/Signal/UpdateCommand.cs b/src/SeqCli/Cli/Commands/Signal/UpdateCommand.cs new file mode 100644 index 00000000..de734755 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Signal/UpdateCommand.cs @@ -0,0 +1,25 @@ +// Copyright © Datalust Pty Ltd and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Seq.Api; +using SeqCli.Connection; + +namespace SeqCli.Cli.Commands.Signal; + +[Command("signal", "update", + "Update an existing signal", + Example="seqcli signal update --json '{...}'")] +class UpdateCommand(SeqConnectionFactory connectionFactory): + Shared.UpdateCommand(connectionFactory, "signal", nameof(SeqConnection.Signals)); + \ No newline at end of file diff --git a/src/SeqCli/Forwarder/Web/RequestProcessingException.cs b/src/SeqCli/Cli/Commands/User/UpdateCommand.cs similarity index 59% rename from src/SeqCli/Forwarder/Web/RequestProcessingException.cs rename to src/SeqCli/Cli/Commands/User/UpdateCommand.cs index bfef07af..d440037c 100644 --- a/src/SeqCli/Forwarder/Web/RequestProcessingException.cs +++ b/src/SeqCli/Cli/Commands/User/UpdateCommand.cs @@ -1,4 +1,4 @@ -// Copyright © Datalust Pty Ltd +// Copyright © Datalust Pty Ltd and Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,18 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.Net; +using Seq.Api; +using SeqCli.Connection; -namespace SeqCli.Forwarder.Web; +namespace SeqCli.Cli.Commands.User; -class RequestProcessingException : Exception -{ - public RequestProcessingException(string message, HttpStatusCode statusCode = HttpStatusCode.BadRequest) - : base(message) - { - StatusCode = statusCode; - } - - public HttpStatusCode StatusCode { get; } -} \ No newline at end of file +[Command("user", "update", + "Update an existing user", + Example="seqcli user update --json '{...}'")] +class UpdateCommand(SeqConnectionFactory connectionFactory): + Shared.UpdateCommand(connectionFactory, "user", nameof(SeqConnection.Users)); + \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Workspace/UpdateCommand.cs b/src/SeqCli/Cli/Commands/Workspace/UpdateCommand.cs new file mode 100644 index 00000000..c457506d --- /dev/null +++ b/src/SeqCli/Cli/Commands/Workspace/UpdateCommand.cs @@ -0,0 +1,25 @@ +// Copyright © Datalust Pty Ltd and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Seq.Api; +using SeqCli.Connection; + +namespace SeqCli.Cli.Commands.Workspace; + +[Command("workspace", "update", + "Update an existing workspace", + Example="seqcli workspace update --json '{...}'")] +class UpdateCommand(SeqConnectionFactory connectionFactory): + Shared.UpdateCommand(connectionFactory, "workspace", nameof(SeqConnection.Workspaces)); + \ No newline at end of file diff --git a/src/SeqCli/Cli/Features/EntityIdentityFeature.cs b/src/SeqCli/Cli/Features/EntityIdentityFeature.cs index e15dbcc6..a6f9dab2 100644 --- a/src/SeqCli/Cli/Features/EntityIdentityFeature.cs +++ b/src/SeqCli/Cli/Features/EntityIdentityFeature.cs @@ -14,6 +14,7 @@ using System; using System.Collections.Generic; +using SeqCli.Util; namespace SeqCli.Cli.Features; @@ -22,8 +23,6 @@ class EntityIdentityFeature : CommandFeature readonly string _entityName; readonly string _verb; - string? _title, _id; - public EntityIdentityFeature(string entityName, string verb) { _entityName = entityName ?? throw new ArgumentNullException(nameof(entityName)); @@ -35,12 +34,12 @@ public override void Enable(OptionSet options) options.Add( "t=|title=", $"The title of the {_entityName}(s) to {_verb}", - t => _title = t); + t => Title = ArgumentString.Normalize(t)); options.Add( "i=|id=", $"The id of a single {_entityName} to {_verb}", - t => _id = t); + t => Id = ArgumentString.Normalize(t)); } public override IEnumerable GetUsageErrors() @@ -49,7 +48,7 @@ public override IEnumerable GetUsageErrors() yield return "Only one of either `title` or `id` can be specified"; } - public string? Title => string.IsNullOrWhiteSpace(_title) ? null : _title.Trim(); + public string? Title { get; private set; } - public string? Id => string.IsNullOrWhiteSpace(_id) ? null : _id.Trim(); + public string? Id { get; private set; } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Features/OutputFormatFeature.cs b/src/SeqCli/Cli/Features/OutputFormatFeature.cs index ac6a237f..fd797db1 100644 --- a/src/SeqCli/Cli/Features/OutputFormatFeature.cs +++ b/src/SeqCli/Cli/Features/OutputFormatFeature.cs @@ -14,15 +14,14 @@ using System; using System.Collections.Generic; -using Destructurama; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; using Seq.Api.Model; using SeqCli.Config; using SeqCli.Csv; -using SeqCli.Levels; using SeqCli.Output; +using SeqCli.Util; using Serilog; using Serilog.Core; using Serilog.Events; @@ -123,7 +122,7 @@ public void WriteEntity(Entity entity) // way to write colorized JSON ;) var writer = new LoggerConfiguration() - .Destructure.JsonNetTypes() + .Destructure.With() .Enrich.With() .WriteTo.Console( outputTemplate: "{@Message:j}{NewLine}", @@ -135,7 +134,7 @@ public void WriteEntity(Entity entity) else { var dyn = (dynamic) jo; - Console.WriteLine($"{entity.Id} {dyn.Title ?? dyn.Name ?? dyn.Username}"); + Console.WriteLine($"{entity.Id} {dyn.Title ?? dyn.Name ?? dyn.Username ?? dyn.Expression}"); } } diff --git a/src/SeqCli/Program.cs b/src/SeqCli/Program.cs index f23952a4..4c58631f 100644 --- a/src/SeqCli/Program.cs +++ b/src/SeqCli/Program.cs @@ -64,7 +64,7 @@ static async Task Main(string[] args) } finally { - Log.CloseAndFlush(); + await Log.CloseAndFlushAsync(); } } } \ No newline at end of file diff --git a/src/SeqCli/Properties/launchSettings.json b/src/SeqCli/Properties/launchSettings.json new file mode 100644 index 00000000..6ff080fb --- /dev/null +++ b/src/SeqCli/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "SeqCli": { + "commandName": "Project", + "commandLineArgs": "signal update --json-stdin" + } + } +} diff --git a/src/SeqCli/Sample/Loader/Simulation.cs b/src/SeqCli/Sample/Loader/Simulation.cs index 56f5a37d..fba0a939 100644 --- a/src/SeqCli/Sample/Loader/Simulation.cs +++ b/src/SeqCli/Sample/Loader/Simulation.cs @@ -17,7 +17,6 @@ using Seq.Api; using SeqCli.Ingestion; using Serilog; -using SerilogTracing; namespace SeqCli.Sample.Loader; diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index fbb39cf1..c02b8f51 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -10,7 +10,8 @@ true seqcli - default + Major + latest enable false false @@ -41,8 +42,8 @@ - + @@ -51,12 +52,11 @@ - - + diff --git a/src/SeqCli/Syntax/DurationMoniker.cs b/src/SeqCli/Syntax/DurationMoniker.cs index 59919600..c02a2398 100644 --- a/src/SeqCli/Syntax/DurationMoniker.cs +++ b/src/SeqCli/Syntax/DurationMoniker.cs @@ -50,7 +50,7 @@ public static TimeSpan ToTimeSpan(string duration) // This is not at all robust; we could use a decent duration parser for use here in `seqcli`. - if (duration.EndsWith("ms")) + if (duration.EndsWith("ms", StringComparison.Ordinal)) return TimeSpan.FromMilliseconds(int.Parse(duration[..^2])); var value = int.Parse(duration[..^1], CultureInfo.InvariantCulture); diff --git a/src/SeqCli/Templates/Export/EntityName.cs b/src/SeqCli/Templates/Export/EntityName.cs index 34512914..c4bd5c40 100644 --- a/src/SeqCli/Templates/Export/EntityName.cs +++ b/src/SeqCli/Templates/Export/EntityName.cs @@ -15,9 +15,16 @@ public static string FromEntityType(Type entityType) public static string ToResourceGroup(string resource) { - if (!resource.EndsWith("y")) - return resource + "s"; + if (resource.EndsWith('y')) + { + return resource.TrimEnd('y') + "ies"; + } - return resource.TrimEnd('y') + "ies"; + if (resource.EndsWith('x')) + { + return resource + "es"; + } + + return resource + "s"; } } \ No newline at end of file diff --git a/src/SeqCli/Templates/Export/TemplateSetExporter.cs b/src/SeqCli/Templates/Export/TemplateSetExporter.cs index 051a3cda..57d8d059 100644 --- a/src/SeqCli/Templates/Export/TemplateSetExporter.cs +++ b/src/SeqCli/Templates/Export/TemplateSetExporter.cs @@ -8,6 +8,7 @@ using Seq.Api.Model; using Seq.Api.Model.Alerting; using Seq.Api.Model.Dashboarding; +using Seq.Api.Model.Indexing; using Seq.Api.Model.Retention; using Seq.Api.Model.Signals; using Seq.Api.Model.SqlQueries; @@ -78,8 +79,16 @@ await ExportTemplates( () => _connection.RetentionPolicies.ListAsync(), retentionPolicy => retentionPolicy.Id.Replace("retentionpolicy-", ""), templateValueMap); - } + await ExportTemplates( + id => _connection.ExpressionIndexes.FindAsync(id), + () => _connection.ExpressionIndexes.ListAsync(), + expressionIndex => expressionIndex.Expression.All(char.IsLetterOrDigit) + ? expressionIndex.Expression + : expressionIndex.Id.Replace("expressionindex-", ""), + templateValueMap); + } + async Task ExportTemplates( Func> findEntity, Func>> listEntities, diff --git a/src/SeqCli/Templates/Import/EntityTemplateFile.cs b/src/SeqCli/Templates/Import/EntityTemplate.cs similarity index 100% rename from src/SeqCli/Templates/Import/EntityTemplateFile.cs rename to src/SeqCli/Templates/Import/EntityTemplate.cs diff --git a/src/SeqCli/Templates/Import/EntityTemplateFileLoader.cs b/src/SeqCli/Templates/Import/EntityTemplateLoader.cs similarity index 95% rename from src/SeqCli/Templates/Import/EntityTemplateFileLoader.cs rename to src/SeqCli/Templates/Import/EntityTemplateLoader.cs index 949daf07..39a0f857 100644 --- a/src/SeqCli/Templates/Import/EntityTemplateFileLoader.cs +++ b/src/SeqCli/Templates/Import/EntityTemplateLoader.cs @@ -42,6 +42,7 @@ public static bool Load(string path, [MaybeNullWhen(false)] out EntityTemplate t if (root is not JsonTemplateObject rootDictionary || !rootDictionary.Members.TryGetValue("$entity", out var resourceToken) || resourceToken is not JsonTemplateString resource || + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract resource.Value is null) { template = null; diff --git a/src/SeqCli/Templates/Import/GenericEntity.cs b/src/SeqCli/Templates/Import/GenericEntity.cs index d86a28f0..48838362 100644 --- a/src/SeqCli/Templates/Import/GenericEntity.cs +++ b/src/SeqCli/Templates/Import/GenericEntity.cs @@ -21,4 +21,5 @@ class GenericEntity : Entity { public string? Title { get; set; } public string? Name { get; set; } + public string? Expression { get; set; } } \ No newline at end of file diff --git a/src/SeqCli/Templates/Import/TemplateSetImporter.cs b/src/SeqCli/Templates/Import/TemplateSetImporter.cs index 8ac069c8..399e4bcf 100644 --- a/src/SeqCli/Templates/Import/TemplateSetImporter.cs +++ b/src/SeqCli/Templates/Import/TemplateSetImporter.cs @@ -38,8 +38,9 @@ static class TemplateSetImporter TemplateImportState state, bool merge) { - var ordering = new[] {"users", "signals", "apps", "appinstances", - "dashboards", "sqlqueries", "workspaces", "retentionpolicies", "alerts"}.ToList(); + var ordering = new List {"users", "signals", "apps", "appinstances", + "dashboards", "sqlqueries", "workspaces", "retentionpolicies", + "alerts", "expressionindexes"}; var sorted = templates.OrderBy(t => ordering.IndexOf(t.ResourceGroup)); @@ -74,21 +75,40 @@ static class TemplateSetImporter var resourceGroupLink = template.ResourceGroup + "Resources"; var link = apiRoot.Links.Single(l => resourceGroupLink.Equals(l.Key, StringComparison.OrdinalIgnoreCase)); var resourceGroup = await connection.Client.GetAsync(apiRoot, link.Key); + + // ExpressionIndexes with mapped ids or identical expressions are assumed to be equivalent. + var immutableTarget = template.ResourceGroup.Equals("ExpressionIndexes", StringComparison.OrdinalIgnoreCase); if (state.TryGetCreatedEntityId(template.Name, out var existingId) && await CheckEntityExistenceAsync(connection, resourceGroup, existingId)) { asObject["Id"] = existingId; - await UpdateEntityAsync(connection, resourceGroup, asObject, existingId); - Log.Information("Updated existing entity {EntityId} from {TemplateName}", existingId, template.Name); + if (immutableTarget) + { + Log.Information("No work required for existing immutable entity {EntityId} from {TemplateName}", existingId, template.Name); + } + else + { + await UpdateEntityAsync(connection, resourceGroup, asObject, existingId); + Log.Information("Updated existing entity {EntityId} from {TemplateName}", existingId, template.Name); + } } else if (merge && !state.TryGetCreatedEntityId(template.Name, out _) && await TryFindMergeTargetAsync(connection, resourceGroup, asObject) is { } mergedId) { asObject["Id"] = mergedId; - await UpdateEntityAsync(connection, resourceGroup, asObject, mergedId); - state.AddOrUpdateCreatedEntityId(template.Name, mergedId); - Log.Information("Merged and updated existing entity {EntityId} from {TemplateName}", existingId, template.Name); + + if (immutableTarget) + { + Log.Information("Adding merge entry for existing immutable entity {EntityId} from {TemplateName}", existingId, template.Name); + state.AddOrUpdateCreatedEntityId(template.Name, mergedId); + } + else + { + await UpdateEntityAsync(connection, resourceGroup, asObject, mergedId); + state.AddOrUpdateCreatedEntityId(template.Name, mergedId); + Log.Information("Merged and updated existing entity {EntityId} from {TemplateName}", existingId, template.Name); + } } else { @@ -103,20 +123,24 @@ await TryFindMergeTargetAsync(connection, resourceGroup, asObject) is { } merged static async Task TryFindMergeTargetAsync(SeqConnection connection, ResourceGroup resourceGroup, IDictionary entity) { if (!entity.TryGetValue("Title", out var nameOrTitleValue) && - !entity.TryGetValue("Name", out nameOrTitleValue) || + !entity.TryGetValue("Name", out nameOrTitleValue) && + !entity.TryGetValue("Expression", out nameOrTitleValue)|| nameOrTitleValue is not string nameOrTitle) { return null; } - // O(Ntemplates*Nentities) - easy target for optimization with some caching. - var candidates = await connection.Client.GetAsync>(resourceGroup, "Items", - new Dictionary + var parameters = resourceGroup.Links["Items"].Template.Contains("shared") + ? new Dictionary { ["shared"] = true - }); + } + : null; + + // O(Ntemplates*Nentities) - easy target for optimization with some caching. + var candidates = await connection.Client.GetAsync>(resourceGroup, "Items", parameters); - return candidates.FirstOrDefault(e => e.Title == nameOrTitle || e.Name == nameOrTitle)?.Id; + return candidates.FirstOrDefault(e => e.Title == nameOrTitle || e.Name == nameOrTitle || e.Expression == nameOrTitle)?.Id; } static async Task CreateEntityAsync(SeqConnection connection, ResourceGroup resourceGroup, object entity) diff --git a/src/SeqCli/Util/JsonNetDestructuringPolicy.cs b/src/SeqCli/Util/JsonNetDestructuringPolicy.cs new file mode 100644 index 00000000..8d9bf7bc --- /dev/null +++ b/src/SeqCli/Util/JsonNetDestructuringPolicy.cs @@ -0,0 +1,91 @@ +// Copyright 2015 Destructurama Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Newtonsoft.Json.Linq; +using Serilog.Core; +using Serilog.Events; + +namespace SeqCli.Util; + +sealed class JsonNetDestructuringPolicy : IDestructuringPolicy +{ + public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, [NotNullWhen(true)] out LogEventPropertyValue? result) + { + switch (value) + { + case JObject jo: + result = Destructure(jo, propertyValueFactory); + return true; + case JArray ja: + result = Destructure(ja, propertyValueFactory); + return true; + case JValue jv: + result = Destructure(jv, propertyValueFactory); + return true; + } + + result = null; + return false; + } + + static LogEventPropertyValue Destructure(JValue jv, ILogEventPropertyValueFactory propertyValueFactory) + { + return propertyValueFactory.CreatePropertyValue(jv.Value!, destructureObjects: true); + } + + static SequenceValue Destructure(JArray ja, ILogEventPropertyValueFactory propertyValueFactory) + { + var elems = ja.Select(t => propertyValueFactory.CreatePropertyValue(t, destructureObjects: true)); + return new SequenceValue(elems); + } + + static LogEventPropertyValue Destructure(JObject jo, ILogEventPropertyValueFactory propertyValueFactory) + { + string? typeTag = null; + var props = new List(jo.Count); + + foreach (var prop in jo.Properties()) + { + if (prop.Name == "$type") + { + if (prop.Value is JValue typeVal && typeVal.Value is string v) + { + typeTag = v; + continue; + } + } + else if (!LogEventProperty.IsValidName(prop.Name)) + { + return DestructureToDictionaryValue(jo, propertyValueFactory); + } + + props.Add(new LogEventProperty(prop.Name, propertyValueFactory.CreatePropertyValue(prop.Value, destructureObjects: true))); + } + + return new StructureValue(props, typeTag); + } + + static DictionaryValue DestructureToDictionaryValue(JObject jo, ILogEventPropertyValueFactory propertyValueFactory) + { + var elements = jo.Properties().Select( + prop => new KeyValuePair( + new ScalarValue(prop.Name), + propertyValueFactory.CreatePropertyValue(prop.Value, destructureObjects: true)) + ); + return new DictionaryValue(elements); + } +} \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/App/AppBasicsTestCase.cs b/test/SeqCli.EndToEnd/App/AppBasicsTestCase.cs index 638ca8b0..d8c8d63f 100644 --- a/test/SeqCli.EndToEnd/App/AppBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/App/AppBasicsTestCase.cs @@ -23,6 +23,9 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun exit = runner.Exec("app update", "--all"); Assert.Equal(0, exit); + exit = runner.Exec("app uninstall", "--package-id Seq.App.EmailPlus"); + Assert.Equal(0, exit); + return Task.CompletedTask; } } diff --git a/test/SeqCli.EndToEnd/Args.cs b/test/SeqCli.EndToEnd/Args.cs index f3b0bd97..4e1701ea 100644 --- a/test/SeqCli.EndToEnd/Args.cs +++ b/test/SeqCli.EndToEnd/Args.cs @@ -1,18 +1,14 @@ -using System.Linq; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text.RegularExpressions; +#nullable enable + namespace SeqCli.EndToEnd; -public class Args +public class Args(params string[] args) { - readonly string[] _args; - - public Args(params string[] args) - { - _args = args; - } - - public Regex[] TestCases() => _args + public Regex[] TestCases() => args .Where(arg => !arg.StartsWith("--")) .Select(ToArgRegex) .ToArray(); @@ -20,7 +16,17 @@ public Regex[] TestCases() => _args // Simple replacement so `Events.*` becomes `Events\..*` static Regex ToArgRegex(string arg) => new(arg.Replace(".", "\\.").Replace("*", ".*")); - public bool Multiuser() => _args.Any(a => a == "--license-certificate-stdin"); + public bool Multiuser() => args.Any(a => a == "--license-certificate-stdin"); - public bool UseDockerSeq() => _args.Any(a => a == "--docker-server"); -} \ No newline at end of file + public bool UseDockerSeq([NotNullWhen(true)] out string? imageTag) + { + if (args.Any(a => a == "--docker-server")) + { + imageTag = args.Any(a => a == "--pre") ? "preview" : "latest"; + return true; + } + + imageTag = null; + return false; + } +} diff --git a/test/SeqCli.EndToEnd/Indexes/ExpressionIndexBasicsTestCase.cs b/test/SeqCli.EndToEnd/Indexes/ExpressionIndexBasicsTestCase.cs new file mode 100644 index 00000000..4e7497d4 --- /dev/null +++ b/test/SeqCli.EndToEnd/Indexes/ExpressionIndexBasicsTestCase.cs @@ -0,0 +1,36 @@ +using System.Linq; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Indexes; + +[CliTestCase(MinimumApiVersion = "2024.3.0")] +public class ExpressionIndexBasicsTestCase: ICliTestCase +{ + public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + const string expr = "@Resource.service.name"; + var exit = runner.Exec("expressionindex create", $"-e {expr}"); + Assert.Equal(0, exit); + + var entity = (await connection.ExpressionIndexes.ListAsync()).Single(e => e.Expression == expr); + Assert.Equal(expr, entity.Expression); + + exit = runner.Exec("expressionindex list"); + Assert.Equal(0, exit); + + Assert.Contains(expr, runner.LastRunProcess!.Output); + Assert.Contains(entity.Id, runner.LastRunProcess.Output); + + exit = runner.Exec("expressionindex remove", $"-i {entity.Id}"); + Assert.Equal(0, exit); + + exit = runner.Exec("expressionindex list"); + Assert.Equal(0, exit); + + Assert.DoesNotContain(entity.Id, runner.LastRunProcess.Output); + } +} \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Indexes/IndexesTestCase.cs b/test/SeqCli.EndToEnd/Indexes/IndexesTestCase.cs new file mode 100644 index 00000000..f87608b8 --- /dev/null +++ b/test/SeqCli.EndToEnd/Indexes/IndexesTestCase.cs @@ -0,0 +1,34 @@ +using System.Linq; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Indexes; + +[CliTestCase(MinimumApiVersion = "2024.3.0")] +public class IndexesTestCase: ICliTestCase +{ + public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + const string expr = "Magic123"; + var exit = runner.Exec("expressionindex create", $"-e {expr}"); + Assert.Equal(0, exit); + + var expressionIndex = (await connection.ExpressionIndexes.ListAsync()).Single(e => e.Expression == expr); + var signal = (await connection.Signals.ListAsync(shared: true)).First(s => !s.IsIndexSuppressed); + var indexForSignal = (await connection.Indexes.ListAsync()).First(i => i.IndexedEntityId == signal.Id); + + exit = runner.Exec("index list --json"); + Assert.Equal(0, exit); + Assert.Contains(expressionIndex.Id, runner.LastRunProcess!.Output); + Assert.Contains(signal.Id, runner.LastRunProcess!.Output); + + exit = runner.Exec($"index suppress -i {indexForSignal.Id}"); + Assert.Equal(0, exit); + + signal = await connection.Signals.FindAsync(signal.Id); + Assert.True(signal.IsIndexSuppressed); + } +} diff --git a/test/SeqCli.EndToEnd/Profile/ProfileCreateListRemoveTestCase.cs b/test/SeqCli.EndToEnd/Profile/ProfileCreateListRemoveTestCase.cs index 17661e59..9adc50ed 100644 --- a/test/SeqCli.EndToEnd/Profile/ProfileCreateListRemoveTestCase.cs +++ b/test/SeqCli.EndToEnd/Profile/ProfileCreateListRemoveTestCase.cs @@ -14,13 +14,13 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun Assert.Equal(0, create); Assert.Equal(0, runner.Exec("profile list", disconnected: true)); - Assert.Contains("test (https://seq.example.com)", runner.LastRunProcess.Output); + Assert.Contains("test (https://seq.example.com)", runner.LastRunProcess!.Output); Assert.Equal(0, runner.Exec("config", disconnected: true)); Assert.Contains("profiles[test].serverUrl", runner.LastRunProcess.Output); Assert.Contains("https://seq.example.com", runner.LastRunProcess.Output); Assert.Contains("profiles[test].apiKey", runner.LastRunProcess.Output); - Assert.Contains("123", runner.LastRunProcess.Output); + Assert.Contains("pd.", runner.LastRunProcess.Output); var remove = runner.Exec("profile remove", "-n Test", disconnected: true); Assert.Equal(0, remove); diff --git a/test/SeqCli.EndToEnd/Properties/launchSettings.json b/test/SeqCli.EndToEnd/Properties/launchSettings.json new file mode 100644 index 00000000..231d5959 --- /dev/null +++ b/test/SeqCli.EndToEnd/Properties/launchSettings.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "SeqCli.EndToEnd (Seq Executable)": { + "commandName": "Project" + }, + "SeqCli.EndToEnd (datalust/seq:latest)": { + "commandName": "Project", + "commandLineArgs": "--docker-server" + }, + "SeqCli.EndToEnd (datalust/seq:preview)": { + "commandName": "Project", + "commandLineArgs": "--docker-server --pre" + } + } +} diff --git a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj index c5ee3ea4..23c1a761 100644 --- a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj +++ b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj @@ -5,9 +5,7 @@ net8.0 - - - + diff --git a/test/SeqCli.EndToEnd/Shared/UpdateCommandTests.cs b/test/SeqCli.EndToEnd/Shared/UpdateCommandTests.cs new file mode 100644 index 00000000..6f72d949 --- /dev/null +++ b/test/SeqCli.EndToEnd/Shared/UpdateCommandTests.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Shared; + +class UpdateCommandTests(TestConfiguration configuration): ICliTestCase +{ + public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + // Ensure there's at least one API key... + var apiKey = await connection.ApiKeys.TemplateAsync(); + apiKey.Title = "Test"; + await connection.ApiKeys.AddAsync(apiKey); + + var exit = runner.Exec("app install", "--package-id Seq.App.EmailPlus"); + Assert.Equal(0, exit); + + // One app instance... + var app = (await connection.Apps.ListAsync()).Single(); + + var title = Guid.NewGuid().ToString("N"); + exit = runner.Exec("appinstance create", $"-t {title} --app {app.Id} --stream -p To=example@example.com -p From=example@example.com -p Host=localhost"); + Assert.Equal(0, exit); + + // One retention policy... + var retentionPolicy = await connection.RetentionPolicies.TemplateAsync(); + retentionPolicy.RetentionTime = TimeSpan.FromDays(100); + await connection.RetentionPolicies.AddAsync(retentionPolicy); + + // One workspace... + var workspace = await connection.Workspaces.TemplateAsync(); + workspace.OwnerId = null; + await connection.Workspaces.AddAsync(workspace); + + foreach (var commandGroup in new[] + { + "apikey", + "appinstance", + "feed", + "retention", + "signal", + "user", + "workspace" + }) + { + try + { + ListFirstThenUpdate(runner, commandGroup); + } + catch (Exception ex) + { + throw new Exception($"Failed in `{commandGroup}` command group.", ex); + } + } + } + + void ListFirstThenUpdate(CliCommandRunner runner, string commandGroup) + { + var exit = runner.Exec($"{commandGroup} list", "--json"); + Assert.Equal(0, exit); + + var json = new StringReader(runner.LastRunProcess!.Output).ReadLine()?.Trim(); + Assert.StartsWith("{", json); + Assert.EndsWith("}", json); + + using var process = configuration.SpawnCliProcess($"{commandGroup} update", "--json-stdin", supplyInput: true); + process.WriteLineStdin(json); + process.CompleteStdin(); + + exit = process.WaitForExit(CliCommandRunner.DefaultExecTimeout); + Assert.Equal(0, exit); + } +} diff --git a/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs b/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs index 1f7bb834..5d9bd00f 100644 --- a/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs +++ b/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs @@ -23,6 +23,7 @@ public CaptiveProcess( string args = null, IDictionary environment = null, bool captureOutput = true, + bool supplyInput = false, string stopCommandFullExePath = null, string stopCommandArgs = null) { @@ -36,6 +37,7 @@ public CaptiveProcess( UseShellExecute = false, RedirectStandardError = captureOutput, RedirectStandardOutput = captureOutput, + RedirectStandardInput = supplyInput, WindowStyle = ProcessWindowStyle.Hidden, CreateNoWindow = true, ErrorDialog = false, @@ -45,9 +47,9 @@ public CaptiveProcess( if (environment != null) { - foreach (var kvp in environment) + foreach (var (name, value) in environment) { - startInfo.Environment.Add(kvp.Key, kvp.Value); + startInfo.Environment.Add(name, value); } } @@ -57,7 +59,7 @@ public CaptiveProcess( if (captureOutput) { - _process.OutputDataReceived += (o, e) => + _process.OutputDataReceived += (_, e) => { if (e.Data == null) _outputComplete.Set(); @@ -66,7 +68,7 @@ public CaptiveProcess( }; _process.BeginOutputReadLine(); - _process.ErrorDataReceived += (o, e) => + _process.ErrorDataReceived += (_, e) => { if (e.Data == null) _errorComplete.Set(); @@ -77,6 +79,16 @@ public CaptiveProcess( } } + public void WriteLineStdin(string s) + { + _process.StandardInput.WriteLine(s); + } + + public void CompleteStdin() + { + _process.StandardInput.Close(); + } + void WriteOutput(string o) { lock (_sync) diff --git a/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs b/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs index f17e6335..0ee0c4b6 100644 --- a/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs +++ b/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs @@ -4,21 +4,15 @@ namespace SeqCli.EndToEnd.Support; -public class CliCommandRunner +public class CliCommandRunner(TestConfiguration configuration) { - readonly TestConfiguration _configuration; - static readonly TimeSpan DefaultExecTimeout = TimeSpan.FromSeconds(10); + public static readonly TimeSpan DefaultExecTimeout = TimeSpan.FromSeconds(10); public ITestProcess? LastRunProcess { get; private set; } - public CliCommandRunner(TestConfiguration configuration) - { - _configuration = configuration; - } - public int Exec(string command, string? args = null, bool disconnected = false) { - using var process = _configuration.SpawnCliProcess(command, args, skipServerArg: disconnected); + using var process = configuration.SpawnCliProcess(command, args, skipServerArg: disconnected); LastRunProcess = process; return process.WaitForExit(DefaultExecTimeout); } diff --git a/test/SeqCli.EndToEnd/Support/IsolatedTestCase.cs b/test/SeqCli.EndToEnd/Support/IsolatedTestCase.cs index d9b20ba1..0447d6fa 100644 --- a/test/SeqCli.EndToEnd/Support/IsolatedTestCase.cs +++ b/test/SeqCli.EndToEnd/Support/IsolatedTestCase.cs @@ -22,7 +22,8 @@ public IsolatedTestCase( Lazy logger, CliCommandRunner commandRunner, Lazy licenseSetup, - ICliTestCase testCase) + ICliTestCase testCase, + TestConfiguration configuration) { _serverProcess = serverProcess; _connection = connection; @@ -30,10 +31,12 @@ public IsolatedTestCase( _commandRunner = commandRunner; _licenseSetup = licenseSetup; _testCase = testCase ?? throw new ArgumentNullException(nameof(testCase)); + Configuration = configuration; } public string Description => _testCase.GetType().Name; public string Output => _commandRunner.LastRunProcess?.Output ??_lastRunProcess?.Output ?? ""; + public TestConfiguration Configuration { get; } void ForceStartup() { diff --git a/test/SeqCli.EndToEnd/Support/IsolatedTestCaseRegistrationSource.cs b/test/SeqCli.EndToEnd/Support/IsolatedTestCaseRegistrationSource.cs index 8733415a..b4d6f01a 100644 --- a/test/SeqCli.EndToEnd/Support/IsolatedTestCaseRegistrationSource.cs +++ b/test/SeqCli.EndToEnd/Support/IsolatedTestCaseRegistrationSource.cs @@ -32,7 +32,8 @@ public IEnumerable RegistrationsFor(Service service, Fun ctx.Resolve>(), ctx.Resolve(), ctx.Resolve>(), - tc); + tc, + ctx.Resolve()); }), new CurrentScopeLifetime(), InstanceSharing.None, diff --git a/test/SeqCli.EndToEnd/Support/LicenseCliSetupTestCase.cs b/test/SeqCli.EndToEnd/Support/LicenseSetup.cs similarity index 82% rename from test/SeqCli.EndToEnd/Support/LicenseCliSetupTestCase.cs rename to test/SeqCli.EndToEnd/Support/LicenseSetup.cs index fe6c44a3..67dcfabc 100644 --- a/test/SeqCli.EndToEnd/Support/LicenseCliSetupTestCase.cs +++ b/test/SeqCli.EndToEnd/Support/LicenseSetup.cs @@ -5,18 +5,13 @@ namespace SeqCli.EndToEnd.Support; -public class LicenseSetup +public class LicenseSetup(Args args) { - readonly bool _enabled; + readonly bool _enabled = args.Multiuser(); bool _attempted; string _certificate; - public LicenseSetup(Args args) - { - _enabled = args.Multiuser(); - } - public async Task SetupAsync( SeqConnection connection, ILogger logger) diff --git a/test/SeqCli.EndToEnd/Support/TestConfiguration.cs b/test/SeqCli.EndToEnd/Support/TestConfiguration.cs index fd872954..dd715813 100644 --- a/test/SeqCli.EndToEnd/Support/TestConfiguration.cs +++ b/test/SeqCli.EndToEnd/Support/TestConfiguration.cs @@ -1,32 +1,27 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; namespace SeqCli.EndToEnd.Support; -public class TestConfiguration +public class TestConfiguration(Args args) { - readonly Args _args; - - public TestConfiguration(Args args) - { - _args = args; - } - - static int ServerListenPort => 9989; + static int _nextServerPort = 9989; + readonly int _serverListenPort = Interlocked.Increment(ref _nextServerPort); #pragma warning disable CA1822 - public string ServerListenUrl => $"http://localhost:{ServerListenPort}"; + public string ServerListenUrl => $"http://localhost:{_serverListenPort}"; #pragma warning restore CA1822 - string EquivalentBaseDirectory { get; } = AppDomain.CurrentDomain.BaseDirectory + static string EquivalentBaseDirectory { get; } = AppDomain.CurrentDomain.BaseDirectory .Replace(Path.Combine("test", "SeqCli.EndToEnd"), Path.Combine("src", "SeqCli")); - public string TestedBinary => Path.Combine(EquivalentBaseDirectory, "seqcli.dll"); + public static string TestedBinary => Path.Combine(EquivalentBaseDirectory, "seqcli.dll"); - public bool IsMultiuser => _args.Multiuser(); + public bool IsMultiuser => args.Multiuser(); - public CaptiveProcess SpawnCliProcess(string command, string additionalArgs = null, Dictionary environment = null, bool skipServerArg = false) + public CaptiveProcess SpawnCliProcess(string command, string additionalArgs = null, Dictionary environment = null, bool skipServerArg = false, bool supplyInput = false) { if (command == null) throw new ArgumentNullException(nameof(command)); @@ -34,8 +29,7 @@ public CaptiveProcess SpawnCliProcess(string command, string additionalArgs = nu if (!skipServerArg) commandWithArgs += $" --server=\"{ServerListenUrl}\""; - var args = $"{TestedBinary} {commandWithArgs}"; - return new CaptiveProcess("dotnet", args, environment); + return new CaptiveProcess("dotnet", $"{TestedBinary} {commandWithArgs}", environment, supplyInput: supplyInput); } public CaptiveProcess SpawnServerProcess(string storagePath) @@ -43,11 +37,13 @@ public CaptiveProcess SpawnServerProcess(string storagePath) if (storagePath == null) throw new ArgumentNullException(nameof(storagePath)); var commandWithArgs = $"run --listen=\"{ServerListenUrl}\" --storage=\"{storagePath}\""; - if (_args.UseDockerSeq()) + if (args.UseDockerSeq(out var imageTag)) { var containerName = Guid.NewGuid().ToString("n"); - return new CaptiveProcess("docker", $"run --name {containerName} -it --rm -e ACCEPT_EULA=Y -p {ServerListenPort}:80 datalust/seq:latest", stopCommandFullExePath: "docker", stopCommandArgs: $"stop {containerName}"); + const string containerRuntime = "docker"; + return new CaptiveProcess(containerRuntime, $"run --name {containerName} -it --rm -e ACCEPT_EULA=Y -p {_serverListenPort}:80 datalust/seq:{imageTag}", stopCommandFullExePath: containerRuntime, stopCommandArgs: $"stop {containerName}"); } + return new CaptiveProcess("seq", commandWithArgs); } } diff --git a/test/SeqCli.EndToEnd/Support/TestDriver.cs b/test/SeqCli.EndToEnd/Support/TestDriver.cs index aba533a5..2d53b8b9 100644 --- a/test/SeqCli.EndToEnd/Support/TestDriver.cs +++ b/test/SeqCli.EndToEnd/Support/TestDriver.cs @@ -9,39 +9,36 @@ namespace SeqCli.EndToEnd.Support; class TestDriver { - readonly TestConfiguration _configuration; readonly IEnumerable>>> _cases; public TestDriver( - TestConfiguration configuration, IEnumerable>>> cases) { - _configuration = configuration; _cases = cases; } public async Task Run() { Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($"TESTING {_configuration.TestedBinary}"); + Console.WriteLine($"TESTING {TestConfiguration.TestedBinary}"); Console.ResetColor(); int count = 0, passedCount = 0, skippedCount = 0; var failed = new List(); - foreach (var testCaseFactory in _cases.OrderBy(c => Guid.NewGuid())) + foreach (var testCaseFactory in _cases.OrderBy(_ => Guid.NewGuid())) { count++; await using var testCase = testCaseFactory.Value(); Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($"RUNNING {testCase.Value.Description.PadRight(50)}"); + Console.WriteLine($"RUNNING {testCase.Value.Description,-50}"); Console.ResetColor(); var isMultiuser = testCaseFactory.Metadata.TryGetValue("Multiuser", out var multiuser) && true.Equals(multiuser); testCaseFactory.Metadata.TryGetValue("MinimumApiVersion", out var minSeqVersion); - if (isMultiuser != _configuration.IsMultiuser || minSeqVersion != null && + if (isMultiuser != testCase.Value.Configuration.IsMultiuser || minSeqVersion != null && !await testCase.Value.IsSupportedApiVersion((string)minSeqVersion)) { skippedCount++; diff --git a/test/SeqCli.EndToEnd/Templates/TemplateExportImportTestCase.cs b/test/SeqCli.EndToEnd/Templates/TemplateExportImportTestCase.cs index f97b7fdb..1b5dc311 100644 --- a/test/SeqCli.EndToEnd/Templates/TemplateExportImportTestCase.cs +++ b/test/SeqCli.EndToEnd/Templates/TemplateExportImportTestCase.cs @@ -8,7 +8,7 @@ namespace SeqCli.EndToEnd.Templates; -[CliTestCase(MinimumApiVersion = "2021.3.6336")] +[CliTestCase(MinimumApiVersion = "2024.3.0")] public class TemplateExportImportTestCase : ICliTestCase { readonly TestDataFolder _testDataFolder; @@ -43,7 +43,7 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm await File.WriteAllTextAsync(exportedFilename, content); - exit = runner.Exec("template import", $"-i \"{_testDataFolder.Path}\""); + exit = runner.Exec("template import", $"-i \"{_testDataFolder.Path}\" --merge"); Assert.Equal(0, exit); var created = Assert.Single(await connection.Signals.ListAsync(shared: true), s => s.Title == newTitle); diff --git a/test/SeqCli.EndToEnd/TestDriverModule.cs b/test/SeqCli.EndToEnd/TestDriverModule.cs index adc23757..e0bbaf72 100644 --- a/test/SeqCli.EndToEnd/TestDriverModule.cs +++ b/test/SeqCli.EndToEnd/TestDriverModule.cs @@ -43,9 +43,9 @@ protected override void Load(ContainerBuilder builder) return m; }); - builder.RegisterType().SingleInstance(); + builder.RegisterType().InstancePerOwned(); - builder.RegisterType().SingleInstance(); + builder.RegisterType().InstancePerOwned(); builder.RegisterType().InstancePerOwned(); builder.RegisterType(); builder.RegisterType(); diff --git a/test/SeqCli.Tests/Cli/CommandLineHostTests.cs b/test/SeqCli.Tests/Cli/CommandLineHostTests.cs index ee72b1ef..ade3c393 100644 --- a/test/SeqCli.Tests/Cli/CommandLineHostTests.cs +++ b/test/SeqCli.Tests/Cli/CommandLineHostTests.cs @@ -21,10 +21,10 @@ public async Task CommandLineHostPicksCorrectCommand() { new( new Lazy(() => new ActionCommand(() => executed.Add("test"))), - new CommandMetadata {Name = "test"}), + new CommandMetadata {Name = "test", HelpText = "help"}), new( new Lazy(() => new ActionCommand(() => executed.Add("test2"))), - new CommandMetadata {Name = "test2"}) + new CommandMetadata {Name = "test2", HelpText = "help"}) }; var commandLineHost = new CommandLineHost(availableCommands); await commandLineHost.Run(["test"],new LoggingLevelSwitch()); @@ -40,7 +40,7 @@ public async Task PrereleaseCommandsAreIgnoredWithoutFlag() { new( new Lazy(() => new ActionCommand(() => executed.Add("test"))), - new CommandMetadata {Name = "test", IsPreview = true}), + new CommandMetadata {Name = "test", HelpText = "help", IsPreview = true}), }; var commandLineHost = new CommandLineHost(availableCommands); var exit = await commandLineHost.Run(["test"],new LoggingLevelSwitch()); @@ -61,10 +61,10 @@ public async Task WhenMoreThanOneSubcommandAndTheUserRunsWithSubcommandEnsurePic { new( new Lazy(() => new ActionCommand(() => executed.Add("test-subcommand1"))), - new CommandMetadata {Name = "test", SubCommand = "subcommand1"}), + new CommandMetadata {Name = "test", SubCommand = "subcommand1", HelpText = "help"}), new( new Lazy(() => new ActionCommand(() => executed.Add("test-subcommand2"))), - new CommandMetadata {Name = "test", SubCommand = "subcommand2"}) + new CommandMetadata {Name = "test", SubCommand = "subcommand2", HelpText = "help"}) }; var commandLineHost = new CommandLineHost(availableCommands); await commandLineHost.Run(["test", "subcommand2"], new LoggingLevelSwitch()); @@ -82,7 +82,7 @@ public async Task VerboseOptionSetsLoggingLevelToInformation() { new( new Lazy(() => new ActionCommand(() => { })), - new CommandMetadata {Name = "test"}) + new CommandMetadata {Name = "test", HelpText = "help"}) }; var commandLineHost = new CommandLineHost(availableCommands); diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj index 9ebc2e63..1bf867fa 100644 --- a/test/SeqCli.Tests/SeqCli.Tests.csproj +++ b/test/SeqCli.Tests/SeqCli.Tests.csproj @@ -3,9 +3,9 @@ net8.0 - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive