diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f642330..2678851e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,7 @@ jobs: DOTNET_CLI_TELEMETRY_OPTOUT: true DOCKER_USER: ${{ secrets.DOCKER_USER }} DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + SEQ_DOCKER_TAG: preview shell: pwsh run: | - ./build/Build.Linux.ps1 + ./build/Build.Linux.ps1 -SeqDockerTag $env:SEQ_DOCKER_TAG diff --git a/README.md b/README.md index ccd3af90..88a5f990 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,13 @@ seqcli help apikey Available commands: + - `alert` + - [`alert create`](#alert-create) — Create an alert. + - [`alert disable`](#alert-disable) — Disable one or more alerts. + - [`alert enable`](#alert-enable) — Enable one or more alerts. + - [`alert list`](#alert-list) — List alerts. + - [`alert remove`](#alert-remove) — Remove an alert from the server. + - [`alert update`](#alert-update) — Update an existing alert. - `apikey` - [`apikey create`](#apikey-create) — Create an API key for automation or ingestion. - [`apikey list`](#apikey-list) — List available API keys. @@ -221,6 +228,143 @@ Available commands: - [`workspace remove`](#workspace-remove) — Remove a workspace from the server. - [`workspace update`](#workspace-update) — Update an existing workspace. +### `alert create` + +Create an alert. + +Example: + +``` +seqcli alert create -t 'Too many errors' --select "count(*) as errors" --from stream --signal signal-m33302 --where "@Level = 'Error'" --window 5m --having "errors > 10" --notification-level Error --suppression-time 10m +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | A title for the alert | +| `--description=VALUE` | A description for the alert | +| `--from=VALUE` | The data source the alert queries; either `stream` (the default) or `series` | +| `--signal=VALUE` | A signal expression limiting the alert's input, for example `signal-1` or `signal-1,signal-2` | +| `--lateral=VALUE` | A lateral join over a set-valued function applied to the data source, in the form ` as `, for example `unnest(http.server.request.duration.buckets) as bucket`; this argument can be used multiple times | +| `--where=VALUE` | A predicate that selects the events the alert will consider | +| `--select=VALUE` | A measurement the alert condition will test, for example `count(*) as errors`; this argument can be used multiple times | +| `--group-by=VALUE` | An expression to group measurements by, for example `ServiceName` or `ServiceName ci as service`; the `ci` modifier makes the grouping case-insensitive; this argument can be used multiple times | +| `--window=VALUE` | The measurement window over which the alert condition is evaluated, as a duration, for example `1m` or `1h` | +| `--having=VALUE` | The alert condition; a predicate over the grouped measurements, for example `errors > 10` | +| `--notification-level=VALUE` | The level of the notifications raised by the alert, for example `Warning` or `Error` | +| `--suppression-time=VALUE` | A duration for which notifications are suppressed after the alert triggers, for example `10m` or `1h` | +| `--notification-app=VALUE` | The id of an app instance that will be notified when the alert triggers; this argument can be used multiple times | +| `--protected` | Specify that the alert is editable only by administrators | +| `--disabled` | Create the alert in a disabled state; disabled alerts are not processed and do not send notifications | +| `-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) | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + +### `alert disable` + +Disable one or more alerts. + +Example: + +``` +seqcli alert disable -t 'Too many errors' +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | The title of the alert(s) to disable | +| `-i`, `--id=VALUE` | The id of a single alert to disable | +| `-o`, `--owner=VALUE` | The id of the user to disable alerts for; by default, shared alerts are disabled | +| `-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 | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + +### `alert enable` + +Enable one or more alerts. + +Example: + +``` +seqcli alert enable -t 'Too many errors' +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | The title of the alert(s) to enable | +| `-i`, `--id=VALUE` | The id of a single alert to enable | +| `-o`, `--owner=VALUE` | The id of the user to enable alerts for; by default, shared alerts are enabled | +| `-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 | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + +### `alert list` + +List alerts. + +Example: + +``` +seqcli alert list +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | The title of the alert(s) to list | +| `-i`, `--id=VALUE` | The id of a single alert to list | +| `-o`, `--owner=VALUE` | The id of the user to list alerts for; by default, shared alerts are listed | +| `--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) | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | +| `-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 | + +### `alert remove` + +Remove an alert from the server. + +Example: + +``` +seqcli alert remove -t 'Too many errors' +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | The title of the alert(s) to remove | +| `-i`, `--id=VALUE` | The id of a single alert to remove | +| `-o`, `--owner=VALUE` | The id of the user to remove alerts for; by default, shared alerts are removed | +| `-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 | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + +### `alert update` + +Update an existing alert. + +Example: + +``` +seqcli alert update --json '{...}' +``` + +| Option | Description | +| ------ | ----------- | +| `--json=VALUE` | The updated alert in JSON format; this can be produced using `seqcli alert list --json` | +| `--json-stdin` | Read the updated alert 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 | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + ### `apikey create` Create an API key for automation or ingestion. diff --git a/build/Build.Linux.ps1 b/build/Build.Linux.ps1 index 1d3279b8..cc550b3d 100644 --- a/build/Build.Linux.ps1 +++ b/build/Build.Linux.ps1 @@ -1,3 +1,7 @@ +param( + [string] $SeqDockerTag = "latest" +) + Push-Location $PSScriptRoot/../ . ./build/Build.Common.ps1 @@ -23,8 +27,8 @@ function Execute-Tests if ($LASTEXITCODE -ne 0) { exit 1 } cd ./test/SeqCli.EndToEnd/ - docker pull datalust/seq:latest - & dotnet run -f $framework -- --docker-server + docker pull datalust/seq:$SeqDockerTag + & dotnet run -f $framework -- --docker-server --docker-tag=$SeqDockerTag if ($LASTEXITCODE -ne 0) { cd ../.. diff --git a/src/SeqCli/Cli/Commands/Alert/CreateCommand.cs b/src/SeqCli/Cli/Commands/Alert/CreateCommand.cs new file mode 100644 index 00000000..7e0faee5 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/CreateCommand.cs @@ -0,0 +1,223 @@ +// Copyright © Datalust 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.Alerting; +using Seq.Api.Model.LogEvents; +using Seq.Api.Model.Shared; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Mapping; +using SeqCli.Signals; +using SeqCli.Syntax; +using SeqCli.Util; + +namespace SeqCli.Cli.Commands.Alert; + +[Command("alert", "create", "Create an alert", + Example = "seqcli alert create -t 'Too many errors' --select \"count(*) as errors\" --from stream --signal signal-m33302 " + + "--where \"@Level = 'Error'\" --window 5m --having \"errors > 10\" --notification-level Error --suppression-time 10m")] +class CreateCommand : Command +{ + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly StoragePathFeature _storagePath; + + readonly List _lateral = new(); + readonly List _select = new(); + readonly List _groupBy = new(); + readonly List _notificationApps = new(); + + string? _title, _description, _from, _signal, _where, _having, _window, _notificationLevel, _suppressionTime; + bool _isProtected, _isDisabled; + + public CreateCommand() + { + Options.Add( + "t=|title=", + "A title for the alert", + t => _title = ArgumentString.Normalize(t)); + + Options.Add( + "description=", + "A description for the alert", + d => _description = ArgumentString.Normalize(d)); + + Options.Add( + "from=", + "The data source the alert queries; either `stream` (the default) or `series`", + f => _from = ArgumentString.Normalize(f)); + + Options.Add( + "signal=", + "A signal expression limiting the alert's input, for example `signal-1` or `signal-1,signal-2`", + s => _signal = ArgumentString.Normalize(s)); + + Options.Add( + "lateral=", + "A lateral join over a set-valued function applied to the data source, in the form ` as `, for example `unnest(http.server.request.duration.buckets) as bucket`; this argument can be used multiple times", + l => _lateral.Add(ArgumentString.Normalize(l) ?? throw new ArgumentException("Lateral joins require a value."))); + + Options.Add( + "where=", + "A predicate that selects the events the alert will consider", + w => _where = ArgumentString.Normalize(w)); + + Options.Add( + "select=", + "A measurement the alert condition will test, for example `count(*) as errors`; this argument can be used multiple times", + s => _select.Add(ArgumentString.Normalize(s) ?? throw new ArgumentException("Measurements require a value."))); + + Options.Add( + "group-by=", + "An expression to group measurements by, for example `ServiceName` or `ServiceName ci as service`; the `ci` modifier makes the grouping case-insensitive; this argument can be used multiple times", + g => _groupBy.Add(ArgumentString.Normalize(g) ?? throw new ArgumentException("Groupings require a value."))); + + Options.Add( + "window=", + "The measurement window over which the alert condition is evaluated, as a duration, for example `1m` or `1h`", + w => _window = ArgumentString.Normalize(w)); + + Options.Add( + "having=", + "The alert condition; a predicate over the grouped measurements, for example `errors > 10`", + h => _having = ArgumentString.Normalize(h)); + + Options.Add( + "notification-level=", + "The level of the notifications raised by the alert, for example `Warning` or `Error`", + l => _notificationLevel = ArgumentString.Normalize(l)); + + Options.Add( + "suppression-time=", + "A duration for which notifications are suppressed after the alert triggers, for example `10m` or `1h`", + s => _suppressionTime = ArgumentString.Normalize(s)); + + Options.Add( + "notification-app=", + "The id of an app instance that will be notified when the alert triggers; this argument can be used multiple times", + a => _notificationApps.Add(ArgumentString.Normalize(a) ?? throw new ArgumentException("Notification apps require a value."))); + + Options.Add( + "protected", + "Specify that the alert is editable only by administrators", + _ => _isProtected = true); + + Options.Add( + "disabled", + "Create the alert in a disabled state; disabled alerts are not processed and do not send notifications", + _ => _isDisabled = true); + + _connection = Enable(); + _output = Enable(); + _storagePath = Enable(); + } + + protected override async Task Run() + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var alert = await connection.Alerts.TemplateAsync(); + alert.OwnerId = null; + + alert.Title = _title; + alert.Description = _description; + alert.IsProtected = _isProtected; + alert.IsDisabled = _isDisabled; + + if (_from != null) + alert.DataSource = Enum.Parse(_from, ignoreCase: true); + + if (_signal != null) + alert.SignalExpression = SignalExpressionParser.ParseExpression(_signal); + + if (_lateral.Any()) + { + alert.Joins.Clear(); + foreach (var lateral in _lateral) + alert.Joins.Add(ParseLateral(lateral)); + } + + if (_where != null) + alert.Where = (await connection.Expressions.ToStrictAsync(_where)).StrictExpression; + + if (_select.Any()) + { + alert.Select.Clear(); + foreach (var measurement in _select) + alert.Select.Add(ParseColumn(measurement)); + } + + if (_groupBy.Any()) + { + alert.GroupBy.Clear(); + foreach (var grouping in _groupBy) + alert.GroupBy.Add(ParseGrouping(grouping)); + } + + if (_window != null) + alert.TimeGrouping = DurationMoniker.ToTimeSpan(_window); + + if (_having != null) + alert.Having = _having; + + if (_notificationLevel != null) + alert.NotificationLevel = Enum.Parse(LevelMapping.ToFullLevelName(_notificationLevel)); + + if (_suppressionTime != null) + alert.SuppressionTime = DurationMoniker.ToTimeSpan(_suppressionTime); + + foreach (var appInstanceId in _notificationApps) + alert.NotificationChannels.Add(new NotificationChannelPart { NotificationAppInstanceId = appInstanceId }); + + alert = await connection.Alerts.AddAsync(alert); + + _output.GetOutputFormat(config).WriteEntity(alert); + + return 0; + } + + static ColumnPart ParseColumn(string measurement) + { + var parsed = AliasedExpressionParser.ParseExpression(measurement); + return new ColumnPart { Value = parsed.Expression, Label = parsed.Alias }; + } + + static GroupingColumnPart ParseGrouping(string grouping) + { + var parsed = AliasedExpressionParser.ParseExpression(grouping, allowCaseInsensitive: true); + return new GroupingColumnPart + { + Value = parsed.Expression, + Label = parsed.Alias, + IsCaseInsensitive = parsed.IsCaseInsensitive + }; + } + + static JoinPart ParseLateral(string lateral) + { + var parsed = AliasedExpressionParser.ParseExpression(lateral); + return new JoinPart + { + Kind = JoinKind.Lateral, + SetFunctionCall = parsed.Expression, + Alias = parsed.Alias + }; + } +} diff --git a/src/SeqCli/Cli/Commands/Alert/DisableCommand.cs b/src/SeqCli/Cli/Commands/Alert/DisableCommand.cs new file mode 100644 index 00000000..3671e8c8 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/DisableCommand.cs @@ -0,0 +1,19 @@ +// Copyright © Datalust 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. + +namespace SeqCli.Cli.Commands.Alert; + +[Command("alert", "disable", "Disable one or more alerts", + Example = "seqcli alert disable -t 'Too many errors'")] +class DisableCommand() : SetEnabledCommand(disable: true); diff --git a/src/SeqCli/Cli/Commands/Alert/EnableCommand.cs b/src/SeqCli/Cli/Commands/Alert/EnableCommand.cs new file mode 100644 index 00000000..90428733 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/EnableCommand.cs @@ -0,0 +1,19 @@ +// Copyright © Datalust 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. + +namespace SeqCli.Cli.Commands.Alert; + +[Command("alert", "enable", "Enable one or more alerts", + Example = "seqcli alert enable -t 'Too many errors'")] +class EnableCommand() : SetEnabledCommand(disable: false); diff --git a/src/SeqCli/Cli/Commands/Alert/ListCommand.cs b/src/SeqCli/Cli/Commands/Alert/ListCommand.cs new file mode 100644 index 00000000..2f72ede8 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/ListCommand.cs @@ -0,0 +1,55 @@ +// Copyright © Datalust 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.Linq; +using System.Threading.Tasks; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; + +namespace SeqCli.Cli.Commands.Alert; + +[Command("alert", "list", "List alerts", Example="seqcli alert list")] +class ListCommand : Command +{ + readonly EntityIdentityFeature _entityIdentity; + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly EntityOwnerFeature _entityOwner; + readonly StoragePathFeature _storagePath; + + public ListCommand() + { + _entityIdentity = Enable(new EntityIdentityFeature("alert", "list")); + _entityOwner = Enable(new EntityOwnerFeature("alert", "list", "listed", _entityIdentity)); + _output = Enable(); + _storagePath = Enable(); + _connection = Enable(); + } + + protected override async Task Run() + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var list = _entityIdentity.Id != null ? [await connection.Alerts.FindAsync(_entityIdentity.Id)] + : + (await connection.Alerts.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) + .Where(alert => _entityIdentity.Title == null || _entityIdentity.Title == alert.Title); + + _output.GetOutputFormat(config).ListEntities(list); + + return 0; + } +} diff --git a/src/SeqCli/Cli/Commands/Alert/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Alert/RemoveCommand.cs new file mode 100644 index 00000000..cd703220 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/RemoveCommand.cs @@ -0,0 +1,69 @@ +// Copyright © Datalust 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.Linq; +using System.Threading.Tasks; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using Serilog; + +namespace SeqCli.Cli.Commands.Alert; + +[Command("alert", "remove", "Remove an alert from the server", + Example = "seqcli alert remove -t 'Too many errors'")] +class RemoveCommand : Command +{ + readonly EntityIdentityFeature _entityIdentity; + readonly ConnectionFeature _connection; + readonly EntityOwnerFeature _entityOwner; + readonly StoragePathFeature _storagePath; + + public RemoveCommand() + { + _entityIdentity = Enable(new EntityIdentityFeature("alert", "remove")); + _entityOwner = Enable(new EntityOwnerFeature("alert", "remove", "removed", _entityIdentity)); + _connection = Enable(); + _storagePath = Enable(); + } + + protected override async Task Run() + { + if (_entityIdentity.Title == null && _entityIdentity.Id == null) + { + Log.Error("A `title` or `id` must be specified"); + return 1; + } + + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var toRemove = _entityIdentity.Id != null ? [await connection.Alerts.FindAsync(_entityIdentity.Id)] + : + (await connection.Alerts.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) + .Where(alert => _entityIdentity.Title == alert.Title) + .ToArray(); + + if (!toRemove.Any()) + { + Log.Error("No matching alert was found"); + return 1; + } + + foreach (var alert in toRemove) + await connection.Alerts.RemoveAsync(alert); + + return 0; + } +} diff --git a/src/SeqCli/Cli/Commands/Alert/SetEnabledCommand.cs b/src/SeqCli/Cli/Commands/Alert/SetEnabledCommand.cs new file mode 100644 index 00000000..3c55a12c --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/SetEnabledCommand.cs @@ -0,0 +1,74 @@ +// Copyright © Datalust 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.Linq; +using System.Threading.Tasks; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using Serilog; + +namespace SeqCli.Cli.Commands.Alert; + +abstract class SetEnabledCommand : Command +{ + readonly bool _disable; + readonly EntityIdentityFeature _entityIdentity; + readonly EntityOwnerFeature _entityOwner; + readonly ConnectionFeature _connection; + readonly StoragePathFeature _storagePath; + + protected SetEnabledCommand(bool disable) + { + _disable = disable; + var verb = disable ? "disable" : "enable"; + var pastParticiple = disable ? "disabled" : "enabled"; + _entityIdentity = Enable(new EntityIdentityFeature("alert", verb)); + _entityOwner = Enable(new EntityOwnerFeature("alert", verb, pastParticiple, _entityIdentity)); + _connection = Enable(); + _storagePath = Enable(); + } + + protected override async Task Run() + { + if (_entityIdentity.Title == null && _entityIdentity.Id == null) + { + Log.Error("A `title` or `id` must be specified"); + return 1; + } + + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var toUpdate = _entityIdentity.Id != null ? [await connection.Alerts.FindAsync(_entityIdentity.Id)] + : + (await connection.Alerts.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) + .Where(alert => _entityIdentity.Title == alert.Title) + .ToArray(); + + if (!toUpdate.Any()) + { + Log.Error("No matching alert was found"); + return 1; + } + + foreach (var alert in toUpdate.Where(alert => alert.IsDisabled != _disable)) + { + alert.IsDisabled = _disable; + await connection.Alerts.UpdateAsync(alert); + } + + return 0; + } +} diff --git a/src/SeqCli/Cli/Commands/Alert/UpdateCommand.cs b/src/SeqCli/Cli/Commands/Alert/UpdateCommand.cs new file mode 100644 index 00000000..392ff3c3 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/UpdateCommand.cs @@ -0,0 +1,23 @@ +// Copyright © Datalust 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; + +namespace SeqCli.Cli.Commands.Alert; + +[Command("alert", "update", + "Update an existing alert", + Example="seqcli alert update --json '{...}'")] +class UpdateCommand(): + Shared.UpdateCommand("alert", nameof(SeqConnection.Alerts)); diff --git a/src/SeqCli/Syntax/AliasedExpression.cs b/src/SeqCli/Syntax/AliasedExpression.cs new file mode 100644 index 00000000..ed1fbd53 --- /dev/null +++ b/src/SeqCli/Syntax/AliasedExpression.cs @@ -0,0 +1,27 @@ +// Copyright © Datalust 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. + +namespace SeqCli.Syntax; + +/// +/// A loose and abstract `{expression} ci as {alias}`. +/// +readonly struct AliasedExpression(string expression, string? alias, bool isCaseInsensitive) +{ + public string Expression { get; } = expression; + + public string? Alias { get; } = alias; + + public bool IsCaseInsensitive { get; } = isCaseInsensitive; +} diff --git a/src/SeqCli/Syntax/AliasedExpressionParser.cs b/src/SeqCli/Syntax/AliasedExpressionParser.cs new file mode 100644 index 00000000..d3084d84 --- /dev/null +++ b/src/SeqCli/Syntax/AliasedExpressionParser.cs @@ -0,0 +1,57 @@ +// Copyright © Datalust 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.Text.RegularExpressions; + +namespace SeqCli.Syntax; + +static partial class AliasedExpressionParser +{ + // " as " + [GeneratedRegex(@"\s+as\s+(?[\p{L}_][\p{L}\p{N}_]*)$", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex AsAlias(); + + // " ci" + [GeneratedRegex(@"\s+ci$", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex Ci(); + + public static AliasedExpression ParseExpression(string fragment, bool allowCaseInsensitive = false) + { + if (fragment == null) throw new ArgumentNullException(nameof(fragment)); + + var expression = fragment.Trim(); + + string? alias = null; + var aliasMatch = AsAlias().Match(expression); + if (aliasMatch.Success) + { + alias = aliasMatch.Groups["alias"].Value; + expression = expression[..aliasMatch.Index].TrimEnd(); + } + + var isCaseInsensitive = false; + if (allowCaseInsensitive) + { + var ciMatch = Ci().Match(expression); + if (ciMatch.Success) + { + isCaseInsensitive = true; + expression = expression[..ciMatch.Index].TrimEnd(); + } + } + + return expression.Length == 0 ? throw new FormatException($"The fragment `{fragment}` has no expression.") : new AliasedExpression(expression, alias, isCaseInsensitive); + } +} diff --git a/test/SeqCli.EndToEnd/Alert/AlertBasicsTestCase.cs b/test/SeqCli.EndToEnd/Alert/AlertBasicsTestCase.cs new file mode 100644 index 00000000..7653e273 --- /dev/null +++ b/test/SeqCli.EndToEnd/Alert/AlertBasicsTestCase.cs @@ -0,0 +1,49 @@ +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Alert; + +[CliTestCase(MinimumApiVersion = "2026.1.0")] +public class AlertBasicsTestCase : ICliTestCase +{ + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + var alert = await connection.Alerts.TemplateAsync(); + alert.Title = "TestBasicsAlert"; + alert.OwnerId = null; + alert = await connection.Alerts.AddAsync(alert); + + var exit = runner.Exec("alert list", "-i alert-none"); + Assert.Equal(1, exit); + + exit = runner.Exec("alert list", "-t TestBasicsAlert"); + Assert.Equal(0, exit); + + var output = runner.LastRunProcess?.Output; + Assert.Equal($"{alert.Id} TestBasicsAlert", output?.Trim()); + + exit = runner.Exec("alert disable", "-t TestBasicsAlert"); + Assert.Equal(0, exit); + + alert = await connection.Alerts.FindAsync(alert.Id); + Assert.True(alert.IsDisabled); + + exit = runner.Exec("alert enable", "-t TestBasicsAlert"); + Assert.Equal(0, exit); + + alert = await connection.Alerts.FindAsync(alert.Id); + Assert.False(alert.IsDisabled); + + exit = runner.Exec("alert remove", "-t TestBasicsAlert"); + Assert.Equal(0, exit); + + exit = runner.Exec("alert list", $"-i {alert.Id}"); + Assert.Equal(1, exit); + } +} diff --git a/test/SeqCli.EndToEnd/Alert/AlertCreateAllArgsTestCase.cs b/test/SeqCli.EndToEnd/Alert/AlertCreateAllArgsTestCase.cs new file mode 100644 index 00000000..ddae472f --- /dev/null +++ b/test/SeqCli.EndToEnd/Alert/AlertCreateAllArgsTestCase.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Seq.Api; +using Seq.Api.Model.LogEvents; +using Seq.Api.Model.Shared; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Alert; + +[CliTestCase(MinimumApiVersion = "2026.1.0")] +public class AlertCreateAllArgsTestCase : ICliTestCase +{ + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + // An app instance is required to exercise `--notification-app`. + var exit = runner.Exec("app install", "--package-id Seq.App.EmailPlus"); + Assert.Equal(0, exit); + + var app = (await connection.Apps.ListAsync()).Single(); + + exit = runner.Exec("appinstance create", + $"-t AlertNotify --app {app.Id} -p To=example@example.com -p From=example@example.com -p Host=localhost"); + Assert.Equal(0, exit); + + var appInstance = (await connection.AppInstances.ListAsync()).Single(); + + // `stream` alert + exit = runner.Exec("alert create", + "-t AllArgsAlert --description \"Covers every alert create option\" --from stream " + + "--signal signal-m33301 --where \"@Level = 'Error'\" " + + "--select \"count(*) as errors\" --select \"max(Elapsed) as slowest\" " + + "--group-by \"ServiceName ci as service\" --group-by \"Environment\" " + + "--window 5m --having \"errors > 10\" --notification-level Error --suppression-time 10m " + + $"--notification-app {appInstance.Id} --protected --disabled"); + Assert.Equal(0, exit); + + var testAlert = (await connection.Alerts.ListAsync(shared: true)).First(x => x.Title == "AllArgsAlert"); + + Assert.Equal("Covers every alert create option", testAlert.Description); + Assert.Equal(DataSource.Stream, testAlert.DataSource); + Assert.NotNull(testAlert.SignalExpression); + Assert.False(string.IsNullOrWhiteSpace(testAlert.Where)); + Assert.Equal(2, testAlert.Select.Count); + Assert.Equal("errors", testAlert.Select[0].Label); + Assert.Equal("slowest", testAlert.Select[1].Label); + Assert.Equal(2, testAlert.GroupBy.Count); + Assert.Equal("ServiceName", testAlert.GroupBy[0].Value); + Assert.Equal("service", testAlert.GroupBy[0].Label); + Assert.True(testAlert.GroupBy[0].IsCaseInsensitive); + Assert.Equal("Environment", testAlert.GroupBy[1].Value); + Assert.False(testAlert.GroupBy[1].IsCaseInsensitive); + Assert.Equal(TimeSpan.FromMinutes(5), testAlert.TimeGrouping); + Assert.False(string.IsNullOrWhiteSpace(testAlert.Having)); + Assert.Equal(LogEventLevel.Error, testAlert.NotificationLevel); + Assert.Equal(TimeSpan.FromMinutes(10), testAlert.SuppressionTime); + Assert.Contains(testAlert.NotificationChannels, c => c.NotificationAppInstanceId == appInstance.Id); + Assert.True(testAlert.IsProtected); + Assert.True(testAlert.IsDisabled); + + // `series` alert + exit = runner.Exec("alert create", + "-t SeriesAlert --from series --lateral \"unnest(commit_duration.buckets) as bucket\" " + + "--where \"Has(commit_duration.buckets)\" " + + "--select \"phist(bucket.midpoint, bucket.count, 95) as p95\" " + + "--window 5m --having \"p95 > 10\" --notification-level Error --suppression-time 10m"); + Assert.Equal(0, exit); + + var seriesAlert = (await connection.Alerts.ListAsync(shared: true)).First(x => x.Title == "SeriesAlert"); + Assert.Equal(DataSource.Series, seriesAlert.DataSource); + var lateralJoin = Assert.Single(seriesAlert.Joins); + Assert.Equal(JoinKind.Lateral, lateralJoin.Kind); + Assert.Equal("unnest(commit_duration.buckets)", lateralJoin.SetFunctionCall); + Assert.Equal("bucket", lateralJoin.Alias); + } +} diff --git a/test/SeqCli.EndToEnd/Alert/AlertCreateMinimalTestCase.cs b/test/SeqCli.EndToEnd/Alert/AlertCreateMinimalTestCase.cs new file mode 100644 index 00000000..cfdf1694 --- /dev/null +++ b/test/SeqCli.EndToEnd/Alert/AlertCreateMinimalTestCase.cs @@ -0,0 +1,30 @@ +using System.Linq; +using System.Threading.Tasks; +using Seq.Api; +using Seq.Api.Model.Shared; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Alert; + +[CliTestCase(MinimumApiVersion = "2026.1.0")] +public class AlertCreateMinimalTestCase : ICliTestCase +{ + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + var exit = runner.Exec("alert create", "-t MinimalAlert"); + Assert.Equal(0, exit); + + var alert = (await connection.Alerts.ListAsync(shared: true)).First(x => x.Title == "MinimalAlert"); + + Assert.Equal("MinimalAlert", alert.Title); + Assert.Equal(DataSource.Stream, alert.DataSource); + + exit = runner.Exec("alert list", "-t MinimalAlert"); + Assert.Equal(0, exit); + } +} diff --git a/test/SeqCli.EndToEnd/Args.cs b/test/SeqCli.EndToEnd/Args.cs index 322c408a..95724243 100644 --- a/test/SeqCli.EndToEnd/Args.cs +++ b/test/SeqCli.EndToEnd/Args.cs @@ -17,12 +17,17 @@ public Regex[] TestCases() => args static Regex ToArgRegex(string arg) => new(arg.Replace(".", "\\.").Replace("*", ".*")); public bool Multiuser() => args.Any(a => a == "--license-certificate-stdin"); - + + const string DockerTagArg = "--docker-tag="; + public bool UseDockerSeq([NotNullWhen(true)] out string? imageTag, [NotNullWhen(true)] out string? containerRuntime) { if (args.Any(a => a == "--docker-server")) { - imageTag = args.Any(a => a == "--pre") ? "preview" : "latest"; + imageTag = args + .Where(a => a.StartsWith(DockerTagArg)) + .Select(a => a[DockerTagArg.Length..]) + .LastOrDefault() ?? "latest"; containerRuntime = args.Any(a => a == "--podman") ? "podman" : "docker"; return true; } diff --git a/test/SeqCli.EndToEnd/Properties/launchSettings.json b/test/SeqCli.EndToEnd/Properties/launchSettings.json index a535203b..1ffd4e2d 100644 --- a/test/SeqCli.EndToEnd/Properties/launchSettings.json +++ b/test/SeqCli.EndToEnd/Properties/launchSettings.json @@ -14,11 +14,11 @@ }, "SeqCli.EndToEnd (datalust/seq:preview)": { "commandName": "Project", - "commandLineArgs": "--docker-server --pre" + "commandLineArgs": "--docker-server --docker-tag=preview" }, "SeqCli.EndToEnd (McpMetrics*)": { "commandName": "Project", - "commandLineArgs": "--docker-server --pre McpMetrics" + "commandLineArgs": "--docker-server --docker-tag=preview McpMetrics" } } } diff --git a/test/SeqCli.EndToEnd/Shared/UpdateCommandTests.cs b/test/SeqCli.EndToEnd/Shared/UpdateCommandTests.cs index 6f72d949..b6300ccd 100644 --- a/test/SeqCli.EndToEnd/Shared/UpdateCommandTests.cs +++ b/test/SeqCli.EndToEnd/Shared/UpdateCommandTests.cs @@ -37,9 +37,16 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm var workspace = await connection.Workspaces.TemplateAsync(); workspace.OwnerId = null; await connection.Workspaces.AddAsync(workspace); - + + // One alert... + var alert = await connection.Alerts.TemplateAsync(); + alert.Title = "UpdateTestAlert"; + alert.OwnerId = null; + await connection.Alerts.AddAsync(alert); + foreach (var commandGroup in new[] { + "alert", "apikey", "appinstance", "feed", diff --git a/test/SeqCli.Tests/Syntax/AliasedExpressionParserTests.cs b/test/SeqCli.Tests/Syntax/AliasedExpressionParserTests.cs new file mode 100644 index 00000000..43cceeca --- /dev/null +++ b/test/SeqCli.Tests/Syntax/AliasedExpressionParserTests.cs @@ -0,0 +1,41 @@ +using System; +using SeqCli.Syntax; +using Xunit; + +#nullable enable + +namespace SeqCli.Tests.Syntax; + +public class AliasedExpressionParserTests +{ + [Theory] + [InlineData("count(*)", "count(*)", null)] + [InlineData("count(*) as errors", "count(*)", "errors")] + [InlineData("phist(bucket.midpoint, bucket.count, 95) as p95", "phist(bucket.midpoint, bucket.count, 95)", "p95")] + [InlineData("max(a as b)", "max(a as b)", null)] + [InlineData("ServiceName ci as service", "ServiceName ci", "service")] + [InlineData("ServiceName ci", "ServiceName ci", null)] + public void ParsesExpressionAndAlias(string fragment, string expression, string? alias) + { + var parsed = AliasedExpressionParser.ParseExpression(fragment); + + Assert.Equal(expression, parsed.Expression); + Assert.Equal(alias, parsed.Alias); + Assert.False(parsed.IsCaseInsensitive); + } + + [Theory] + [InlineData("ServiceName", "ServiceName", null, false)] + [InlineData("ServiceName ci", "ServiceName", null, true)] + [InlineData("ServiceName as service", "ServiceName", "service", false)] + [InlineData("ServiceName ci as service", "ServiceName", "service", true)] + [InlineData("StartsWith(@Message, 'a') ci as sw", "StartsWith(@Message, 'a')", "sw", true)] + public void ParsesCaseInsensitiveModifierWhenAllowed(string fragment, string expression, string? alias, bool isCaseInsensitive) + { + var parsed = AliasedExpressionParser.ParseExpression(fragment, allowCaseInsensitive: true); + + Assert.Equal(expression, parsed.Expression); + Assert.Equal(alias, parsed.Alias); + Assert.Equal(isCaseInsensitive, parsed.IsCaseInsensitive); + } +}