Created
May 13, 2013 06:26
-
-
Save erenon/5566493 to your computer and use it in GitHub Desktop.
IRF homework snippets.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# OVERVIEW: | |
# parse command line | |
# write output header | |
# create filter string | |
# read csv -> machines | |
# for machine in machines | |
# Get-WSManInstance wmicimv2/* | |
# -ComputerName ... | |
# -Enumerate | |
# -Filter | |
# "SELECT LogFile, SourceName, EventIdentifier, Type | |
# FROM Win32_NTLogEvent | |
# WHERE LogFile = ... AND SourceName = ... AND TimeGenerated >= ... AND TimeGenerated <= ..." | |
# -Dialect WQL | |
# sort results by LogFile, SourceName, EventIdentifier | |
# | |
# set counter = 0 | |
# for result results | |
# if different than the previous | |
# write previous to output file | |
# counter++ | |
# write last | |
<# | |
.SYNOPSIS | |
Retrieves types of logged events of remote hosts. | |
.DESCRIPTION | |
It's tough to summarize the events of a large log file manually. It's even more tedious | |
if there are multiple hosts to manage. This script provides a quick glance of such log files. | |
It reads the remote host information (machine name, credentials, etc.) from a .csv file | |
then queries each remote hosts using Win RM (Get-WSManInstance). The results are grouped by | |
host and type (source, envent identifier, type) then get printed to the output .csv file. | |
.PARAMETER Machines | |
Input file containing access information about the remote machines. The file must contain the following columns: | |
* machineName | |
* port | |
* protocol | |
* user | |
* password | |
Example input file: | |
machineName,port,protocol,user,password | |
192.168.250.128,5985,http,administrator,password | |
server01,5986,https,meres,password2 | |
.PARAMETER OutFile | |
Output file of the collected event groups. The output file will contain the following fields: | |
* Machine | |
* Log | |
* Source | |
* Id | |
* Type | |
* NumberOfEvents | |
Example output file: | |
"Machine","Log","Source","Id","Type","NumberOfEvents" | |
"192.168.1.1","Application","Defrag","258","Information","15" | |
"192.168.1.1","Application","ESENT","326","Information","30" | |
"192.168.1.1","Application","ESENT","105","Information","9" | |
"server01","Application","gpupdate","98","Warning","3" | |
.PARAMETER Log | |
Log file to check. Accepted values are 'Application' and 'System'. | |
.PARAMETER Source | |
Enumeration of log sources (logging applications) to check. | |
.PARAMETER From | |
If specified, only newer than $From log enries will be considered. | |
.PARAMETER To | |
If specified, only older than $To log entries will be considered. | |
.EXAMPLE | |
Get-RemoteEventId -Machines machines.csv -OutFile out.csv | |
Query machines listed in machines.csv, write output to out.csv | |
.EXAMPLE | |
Get-RemoteEventId -Machines machines.csv -OutFile out.csv -Log Application -Source ESENT, sshd -From 2013.04.07.18:00 -To 2013.04.07.21:00 | |
Query machines listed in machines.csv, write output to out.csv; | |
Read the Application log only, consider log entries of `ESENT` and `sshd`, | |
which occured between 2013.04.07.18:00 and 2013.04.07.21:00 only. | |
.NOTES | |
* Retriving and processing of large logfiles takes time. To speed thing up, narrow your search. | |
* Upon unreachable remote host error message is printed, then the collecting proceeds. | |
* If it's permitted, the output file will be overwritten (error message is prompted otherwise). | |
* Author, maintainer and contact person of this script: Benedek Thaler <[email protected]> | |
.LINK | |
* http://technet.microsoft.com/en-us/library/hh849864.aspx | |
#> | |
Function Get-RemoteEventId | |
{ | |
[CmdletBinding()] | |
Param( | |
[Parameter(Mandatory=$true)] | |
[ValidateScript({Test-Path $_ -PathType 'Leaf'})] | |
[String] | |
$Machines | |
, | |
[Parameter(Mandatory=$true)] | |
[String] | |
$OutFile | |
, | |
[Parameter(Mandatory=$false)] | |
[ValidateSet("Application", "System")] | |
[String] | |
$Log | |
, | |
[Parameter(Mandatory=$false)] | |
[String[]] | |
$Source | |
, | |
[Parameter(Mandatory=$false)] | |
[DateTime] | |
$From | |
, | |
[Parameter(Mandatory=$false)] | |
[DateTime] | |
$To | |
) | |
Process | |
{ | |
# Small helper function to write output .csv file | |
Function Write-Outfile { | |
Param ( | |
[Parameter(Mandatory=$true)] | |
[String[]] | |
$Items | |
, | |
[Parameter(Mandatory=$false)] | |
[Boolean] | |
$Append = $true | |
) | |
Process { | |
$line = '"' + ($Items -join '","') + '"' | |
Try { | |
if ($Append) { | |
$line | Out-File -FilePath $OutFile -Encoding utf8 -Append | |
} else { | |
$line | Out-File -FilePath $OutFile -Encoding utf8 | |
} | |
} Catch { | |
Write-Error ('Failed to write outfile: ' + $OutFile) | |
Break | |
} | |
} | |
} | |
# Write output header, break if failed to write | |
Write-Outfile 'Machine', 'Log', 'Source', 'Id', 'Type', 'NumberOfEvents' -Append $false | |
# Create WQL filter | |
$filter = 'SELECT LogFile, SourceName, EventIdentifier, Type FROM Win32_NTLogEvent ' | |
# Filter by the name of the log file | |
if ($Log) { | |
$filterItems += @( "LogFile = '" + $Log + "'" ) | |
} | |
# Filter by source names, concatenated by OR | |
if ($Source) { | |
$Source | ForEach-Object { | |
$conditions += @( "(SourceName = '" + $_ + "')" ) | |
} | |
$filterItems += @( $conditions -join ' OR ' ); | |
} | |
# Filter by date | |
# WinRM/WQL understands the following format only: | |
# | |
# http://msdn.microsoft.com/en-us/library/windows/desktop/aa387237%28v=vs.85%29.aspx | |
# | |
if ($From) { | |
$wbemTime = New-Object -ComObject wbemscripting.swbemdatetime | |
$wbemTime.SetVarDate($From) | |
$filterItems += @( "TimeGenerated >= '" + $wbemTime.Value + "'" ); | |
} | |
if ($To) { | |
$wbemTime = New-Object -ComObject wbemscripting.swbemdatetime | |
$wbemTime.SetVarDate($To) | |
$filterItems += @( "TimeGenerated <= '" + $wbemTime.Value + "'" ); | |
} | |
# concat filter terms by AND | |
if ($filterItems) { | |
$filter += 'WHERE ' + ($filterItems -join ' AND ' ) | |
} | |
Write-Verbose ('Using filter: ' + $filter) | |
# Read input file | |
Import-Csv -Path $Machines | ForEach-Object { | |
# The next foreach will shadow $_, save machineName | |
$machineName = $_.machineName | |
# Assemble credential object | |
$password = ConvertTo-SecureString –String $_.password –AsPlainText -Force | |
$credential = New-Object –TypeName System.Management.Automation.PSCredential –ArgumentList $_.user, $password | |
$eventCounter = 0 | |
$prevItem = @{ LogFile = $false; SourceName = $false; EventIdentifier = $false; Type = $false } | |
# Try Get-WSManInstance query | |
# Then sort the results (to apply unique grouping) | |
# Group and count the records | |
# Write out the processed groups | |
Try { | |
$entries = Get-WSManInstance ` | |
-ComputerName $machineName ` | |
-OptionSet @{ Address = '*'; Transport = $_.protocol } ` | |
-Port $_.port ` | |
-ResourceUri 'wmicimv2/*' ` | |
-Enumerate ` | |
-Authentication Negotiate ` | |
-Credential $credential ` | |
-Filter $filter ` | |
-Dialect WQL | | |
Sort-Object LogFile, SourceName, EventIdentifier | | |
ForEach-Object { | |
# different entry -> start new group | |
if ( | |
$_.LogFile -ne $prevItem.LogFile ` | |
-or $_.SourceName -ne $prevItem.SourceName ` | |
-or $_.EventIdentifier -ne $prevItem.EventIdentifier | |
) { | |
# write out group if it contains events | |
if ($eventCounter -gt 0) { | |
# export to csv | |
Write-Outfile ` | |
$machineName, ` | |
$prevItem.LogFile, ` | |
$prevItem.SourceName, ` | |
$prevItem.EventIdentifier, ` | |
$prevItem.Type, ` | |
$eventCounter | |
Write-Verbose 'Write group' | |
} | |
# reset counter and prevItem | |
$eventCounter = 0 | |
$prevItem.LogFile = $_.LogFile | |
$prevItem.SourceName = $_.SourceName | |
$prevItem.EventIdentifier = $_.EventIdentifier | |
$prevItem.Type = $_.Type | |
} | |
# count the events of the group | |
$eventCounter++ | |
} | |
# export last group to csv | |
if ($eventCounter -gt 0) { | |
Write-Outfile ` | |
$machineName, ` | |
$prevItem.LogFile, ` | |
$prevItem.SourceName, ` | |
$prevItem.EventIdentifier, ` | |
$prevItem.Type, ` | |
$eventCounter | |
} | |
} Catch { | |
Write-Error ('Get-WSManInstance query failed for host: ' + $machineName) | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python3 | |
''' | |
IRF Homework #1 -- Nameday at Superhotels | |
Author: | |
Thaler, Benedek EDDO10 | |
Created: | |
2013. 03. 22. | |
''' | |
import sys | |
import os | |
import argparse | |
from datetime import date, datetime | |
import csv | |
import subprocess | |
''' | |
LDAP Host location | |
Uses the format which `ldapsearch -H` accepts | |
''' | |
g_ldap_host = 'ldap://192.168.1.69:389' | |
def parse_args(args): | |
''' | |
Parses command line arguments | |
Prints usage and terminates if parsing fails. | |
Args: | |
args: Command line arguments, whitout the script name, e.g: sys.argv[1:] | |
Returns: | |
argparse.Namespace object with the following field: | |
[datetime.date] d: date | |
[_io.TextIOWrapper] n: input database | |
[_io.TextIOWrapper] o: output database | |
''' | |
parser = argparse.ArgumentParser() | |
# add arguments | |
# -n: input database | |
parser.add_argument( | |
'-n', | |
nargs = 1, | |
type = argparse.FileType('rt', 1), | |
required = True, | |
help = 'Path to the nameday database', | |
metavar = 'HR_db' | |
) | |
# -o: output file | |
parser.add_argument( | |
'-o', | |
nargs = 1, | |
type = argparse.FileType('w'), | |
required = True, | |
help = 'Path to the output file', | |
metavar = 'greetings_list' | |
) | |
# -d: date | |
parser.add_argument( | |
'-d', | |
nargs = 1, | |
default = [date.today()], | |
type = make_date_from_string, | |
required = False, | |
help = 'Date of namedays to search for', | |
metavar = 'date' | |
) | |
return parser.parse_args(args) | |
def make_date_from_string(date_str): | |
''' | |
Creates datetime.date from string | |
Used by parse_args | |
Args: | |
date_str: Date string | |
Returns: | |
datetime.date: Date | |
''' | |
return datetime.strptime(date_str, '%Y.%m.%d.').date() | |
def make_csv_filter(index, value): | |
''' | |
Creates a filter function | |
Args: | |
index: list index to check | |
value: list value to compare | |
Returns: | |
Filter function | |
''' | |
def filter(list): | |
''' | |
Filter function | |
Args: | |
list: list to check | |
Returns: | |
true if the given list at the given index has the given value, false otherwise | |
''' | |
return (list[index] == value) | |
return filter | |
def read_csv(file, filter): | |
''' | |
Reads the input CSV file | |
Args: | |
file: input CSV file | |
filter: result includes opted in rows only | |
Returns: | |
Dictionary of LDAP dn keys pointing to name lists | |
Example: {'dn1': [name1, name2], 'dn2': [name3]} | |
Raises: | |
Exception: if header is invalid | |
''' | |
reader = csv.reader(file, delimiter = ',', quotechar = '"', skipinitialspace = True) | |
header = next(reader) | |
# Check header | |
if header != ['DN', 'DAY', 'NAME']: | |
raise Exception('Invalid input database header. Database schema should be: DN, DAY, NAME') | |
dn_to_names = [] | |
# for every row | |
for row in reader: | |
if (filter(row)): # check if passes filter | |
dn_to_names.append((row[0], row[2].split(';'))) # append to result | |
return dn_to_names | |
def ldap_search(host, baseDn, names): | |
''' | |
Searches names in a subtree on an LDAP host | |
Example ldapsearch command: | |
ldapsearch | |
-H ldap://192.168.1.71:389 | |
-x | |
-b "ou=Japan,ou=Asia,ou=Hotels,dc=irf,dc=local" | |
-s sub | |
"(&(objectclass=person)(|(givenName=Sarah)(givenName=Sophie)))" | |
dn mail | |
Args: | |
host: LDAP host to query | |
baseDn: Distinguished Name of the subtree to search in | |
names: list of names to search | |
Returns: | |
List of dictionaries with `dn` and `mail` keys. | |
Example: [ | |
{'dn': 'person_dn_1', 'mail': 'person_mail_1'}, | |
{'dn': 'person_dn_2', 'mail': 'person_mail_2'} | |
] | |
Raises: | |
Exception: if the LDAP query (ldapsearch) fails | |
''' | |
# build filter string | |
filter = '(&(objectclass=person)(|' | |
# append names | |
for name in names: | |
filter += '(givenName={0})'.format(name) | |
filter += '))' | |
# launch query | |
try: | |
response = subprocess.check_output( | |
[ | |
'ldapsearch', | |
'-H', host, | |
'-x', | |
'-o', 'ldif-wrap=no', | |
'-b', baseDn, | |
'-s', 'sub', | |
filter, | |
'dn', 'mail' | |
], | |
universal_newlines = True | |
) | |
except subprocess.CalledProcessError as ex: | |
raise Exception('LDAP query failed') | |
# process response | |
results = [] | |
# for every row | |
for row in response.splitlines(): | |
# drop row if starts with # | |
if (len(row) > 0 and row[0] != '#'): | |
# if starts with `dn:`, create new entry | |
if (row.find('dn:') == 0): | |
results.append({'dn' : row[4:]}) | |
# if starts with `mail:`, append to the latest entry | |
elif (row.find('mail:') == 0): | |
results[-1]['mail'] = row[6:] | |
return results | |
if __name__ == '__main__': | |
''' | |
This module reads a CSV birthday database, | |
searches an LDAP host for persons by name and DN | |
and prints a CSV of DN,EMAIL rows. | |
Command line arguments: | |
-h, --help show help message and exit | |
-n HR_db Path to the nameday database | |
-o greetings_list Path to the output file | |
-d date Date of namedays to search for | |
Input database example: | |
DN, DAY, NAME | |
"ou=France,ou=Europe,ou=Hotels,dc=irf,dc=local", "03.10", "Jean;Pierre" | |
"ou=Osaka,ou=Japan,ou=Asia,ou=Hotels,dc=irf,dc=local", "03.11", "Asao" | |
Output example: | |
"DN, EMAIL" | |
"cn=cbentley1,ou=Osaka,ou=Japan,ou=Asia,ou=Hotels,dc=irf,dc=local",[email protected] | |
"cn=cbloss,ou=Osaka,ou=Japan,ou=Asia,ou=Hotels,dc=irf,dc=local",[email protected] | |
''' | |
# read args | |
input = parse_args(sys.argv[1:]) | |
inputFile = input.n[0] | |
inputDate = input.d[0] | |
outputFile = input.o[0] | |
# read input CSV | |
try: | |
dn_to_names = read_csv(inputFile, make_csv_filter(1, inputDate.strftime('%m.%d'))) | |
except Exception as ex: | |
sys.exit(ex) | |
# write output header | |
# Please note: csv writerow (output_writer.writerow(['DN', 'EMAIL'])) won't do it | |
# We need a space after the comma to conform to the specification | |
outputFile.write('DN, EMAIL' + os.linesep) | |
# init CSV writer | |
output_writer = csv.writer(outputFile, delimiter=',', quotechar = '"') | |
# iterate on dns | |
for (dn, names) in dn_to_names: | |
# get celebrants from LDAP | |
try: | |
celebrants = ldap_search(g_ldap_host, dn, names) | |
except Exception as ex: | |
sys.exit(ex) | |
# print returned celebrants | |
for celebrant in celebrants: | |
dn = celebrant['dn'] if 'dn' in celebrant else '' | |
mail = celebrant['mail'] if 'mail' in celebrant else '' | |
output_writer.writerow([dn, mail]) | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Import-Module .\Get-RemoteEventId.ps1 | |
# this is an example for calling the script | |
Get-RemoteEventId -OutFile events.csv -Machines machines.csv | |
# Change the order of mandatory parameters, add -Source filter | |
Get-RemoteEventId -Machines machines.csv -Source "Defrag", "gpupdate" -OutFile events.csv | |
# try to add some other, important cases | |
# CORRECT USAGES | |
Write-Host 'Correct usage tests' | |
# Use -Verbose | |
Get-RemoteEventId -Machines machines.csv -OutFile out.csv -Verbose | |
# Use filter -From | |
Get-RemoteEventId -Machines machines.csv -OutFile out.csv -Verbose -From 2013.04.09.18:00 | |
# Use filter -TO | |
Get-RemoteEventId -Machines machines.csv -OutFile out.csv -Verbose -To 2013.04.09.18:00 | |
# Use both -From and -To | |
Get-RemoteEventId -Machines machines.csv -OutFile out.csv -Verbose -From 2013.04.09.18:00 -To 2013.04.09.18:30 | |
# Use filter -Log | |
Get-RemoteEventId -Machines machines.csv -OutFile out.csv -Log System | |
# Use filter -Source | |
Get-RemoteEventId -Machines machines.csv -OutFile out.csv -Source ESENT, sshd | |
# INCORRECT USAGES | |
Write-Host 'Incorrect usage tests' | |
# nonexistent input | |
Get-RemoteEventId -Machines invalid_machines.csv -OutFile out.csv | |
# output not writeable | |
Get-RemoteEventId -Machines machines.csv -OutFile C:\Windows\invalid_out.csv |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# important test cases | |
# correct usage | |
echo -e "Correct Usages:" | |
# specify input, output, date | |
./greetings.py -o greetings.csv -n names.csv -d 2015.01.21. | |
echo -ne '.' | |
# specify input, output; omit date | |
./greetings.py -n names.csv -o greeting-people.csv | |
echo -ne '.' | |
# match multiple names | |
./greetings.py -n names.csv -o greeting-people.csv -d 2015.03.13. | |
echo -ne '.' | |
# incorrect usage | |
echo -e "\n\nIncorrect Usages:\n" | |
# omit output | |
./greetings.py -n names.csv | |
# omit input | |
./greetings.py -o greetings.csv | |
# input file not found | |
./greetings.py -n invalid_names.csv -o greeting-people.csv | |
# output file not writeable | |
./greetings.py -n names.csv -o /root/greeting-people.csv | |
# invalid date format | |
./greetings.py -o greetings.csv -d 13.03.2015. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
dir="$( cd "$( dirname "$0" )" && pwd )" | |
app="java -jar $dir/../dist/irfhf3-1.0.jar" | |
id=0 | |
let "port = $$+1000" | |
cd $dir | |
# save logfile, remove orginal | |
save() { | |
cp log/log.log $1.txt | |
rm log/log.log | |
} | |
# check whether log contains the given string | |
contains() { | |
grep "$2" "$1.txt" > /dev/null && echo -ne "." && return 0 | |
echo -e "\n[FAIL] String '$2' not found in $1.log\n" | |
} | |
# silently terminates process | |
term() { | |
kill -9 $1 | |
wait $1 2> /dev/null | |
} | |
# 1 | |
((id++)) | |
$app > /dev/null 2> /dev/null | |
save $id | |
contains $id "ERROR GameEnvironment: Failed to start GameEnvironment -- expected 1 argument, 0 given" | |
# 2 | |
((id++)) | |
$app foo > /dev/null 2> /dev/null | |
save $id | |
contains $id "ERROR GameEnvironment: Failed to start GameEnvironment -- invalid port number given" | |
# 3 | |
((id++)) | |
$app 80 > /dev/null 2> /dev/null | |
save $id | |
contains $id "ERROR GameEnvironment: Failed to start GameEnvironment -- unable to open server socket" | |
# 4 | |
((id++)) | |
# no idea how to trigger IOException | |
echo -ne "." | |
# 5 | |
((id++)) | |
$app $port > /dev/null 2> /dev/null & | |
pid=$! | |
sleep 0.5 | |
term $pid | |
save $id | |
contains $id "INFO GameEnvironment: GameEnvironment Started -- listens on port $port" | |
# 6 | |
((id++)) | |
$app $port > /dev/null 2> /dev/null & | |
server_pid=$! | |
sleep 0.5 | |
telnet 127.0.0.1 $port > /dev/null 2> /dev/null <<< "q\n" | |
sleep 0.1 | |
term $server_pid | |
save $id | |
contains $id "INFO GameSession: Client connected -- " | |
# 7 | |
((id++)) | |
$app $port > /dev/null 2> /dev/null & | |
server_pid=$! | |
sleep 0.5 | |
telnet 127.0.0.1 $port > /dev/null 2> /dev/null <<< "q\n" | |
sleep 0.1 | |
term $server_pid | |
save $id | |
contains $id "ERROR GameSession: Failed to read socket, disconnecting client" | |
# 8 | |
((id++)) | |
# no idea how to trigger "failed to close socket" | |
echo -ne "." | |
# 9 | |
((id++)) | |
$app $port > /dev/null 2> /dev/null & | |
server_pid=$! | |
sleep 1 | |
(echo "x"; sleep 0.1;) | telnet 127.0.0.1 $port > /dev/null 2> /dev/null | |
term $server_pid | |
save $id | |
contains $id "DEBUG GameSession: Failed to choose game" | |
# 10 | |
((id++)) | |
$app $port > /dev/null 2> /dev/null & | |
server_pid=$! | |
sleep 1 | |
(echo "b"; sleep 0.1;) | telnet 127.0.0.1 $port > /dev/null 2> /dev/null | |
term $server_pid | |
save $id | |
contains $id "INFO GameSession: Game choosed: BattleshipGame" | |
# 11 | |
((id++)) | |
$app $port > /dev/null 2> /dev/null & | |
server_pid=$! | |
sleep 1 | |
(echo "m"; sleep 0.1;) | telnet 127.0.0.1 $port > /dev/null 2> /dev/null | |
term $server_pid | |
save $id | |
contains $id "INFO GameSession: Game choosed: MineSweeper" | |
# 12 | |
((id++)) | |
$app $port > /dev/null 2> /dev/null & | |
server_pid=$! | |
sleep 1 | |
(echo "b"; sleep 0.1; echo "name"; sleep 0.1; echo "q"; sleep 0.1) | telnet 127.0.0.1 $port > /dev/null 2> /dev/null | |
term $server_pid | |
save $id | |
contains $id "DEBUG GameSession: Game exited: BattleshipGame" | |
# 13 | |
((id++)) | |
$app $port > /dev/null 2> /dev/null & | |
server_pid=$! | |
sleep 1 | |
(echo "m"; sleep 0.1; echo ""; sleep 0.1; echo "quit"; sleep 0.1) | telnet 127.0.0.1 $port > /dev/null 2> /dev/null | |
term $server_pid | |
save $id | |
contains $id "DEBUG GameSession: Game exited: MineSweeper" | |
# 14 | |
((id++)) | |
$app $port > /dev/null 2> /dev/null & | |
server_pid=$! | |
sleep 1 | |
(echo "q"; sleep 0.1;) | telnet 127.0.0.1 $port > /dev/null 2> /dev/null | |
term $server_pid | |
save $id | |
contains $id "INFO GameSession: Client exited -- " | |
# end | |
rm -r log/ | |
echo -e '\n[DONE]' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment