Subcommands (#62)

This commit is contained in:
Patrick Stevens
2023-08-07 12:13:53 +01:00
committed by GitHub
parent 46f24a44ec
commit 9a12abe0cf
11 changed files with 340 additions and 104 deletions

View File

@@ -447,3 +447,12 @@ module GiteaConfig =
JsonConvert.DeserializeObject<SerialisedGiteaConfig> s
|> GiteaConfig.OfSerialised
let getSchema () : Stream =
let resource = "Gitea.Declarative.Lib.GiteaConfig.schema.json"
let assembly = System.Reflection.Assembly.GetExecutingAssembly ()
let stream = assembly.GetManifestResourceStream resource
match stream with
| null -> failwithf "The resource %s was not found. This is a bug in the tool." resource
| stream -> stream

View File

@@ -29,7 +29,7 @@
<Compile Include="Array.fs" />
<Compile Include="UserInput.fs" />
<Compile Include="Gitea.fs" />
<Content Include="GiteaConfig.schema.json" />
<EmbeddedResource Include="GiteaConfig.schema.json" />
<Content Include="swagger.v1.json" />
<EmbeddedResource Include="version.json" />
<None Include="..\README.md" Pack="true" PackagePath="/" />

View File

@@ -18,6 +18,8 @@ module TestSchema =
let ``Example conforms to schema`` () =
let executing = Assembly.GetExecutingAssembly().Location |> FileInfo
// We choose to refer to the path specifically here, so that the "Update" functionality
// below can't be broken by an undetected file rename.
let schemaFile =
Utils.findFileAbove "Gitea.Declarative.Lib/GiteaConfig.schema.json" executing.Directory
@@ -37,6 +39,13 @@ module TestSchema =
let jsonFile = Utils.findFileAbove "GiteaConfig.json" executing.Directory
GiteaConfig.get jsonFile |> ignore
[<Test>]
let ``Schema can be output`` () =
use schema = GiteaConfig.getSchema ()
let reader = new StreamReader (schema)
let schema = reader.ReadToEnd ()
schema.Contains "SerialisedGiteaConfig" |> shouldEqual true
[<Test>]
[<Explicit "Run this to regenerate the schema file">]
let ``Update schema file`` () =

View File

@@ -0,0 +1,20 @@
namespace Gitea.Declarative
open Argu
type ArgsEvaluator<'ret> =
abstract Eval<'a, 'b when 'b :> IArgParserTemplate> :
(ParseResults<'b> -> Result<'a, ArguParseException>) -> ('a -> Async<int>) -> 'ret
type ArgsCrate =
abstract Apply<'ret> : ArgsEvaluator<'ret> -> 'ret
[<RequireQualifiedAccess>]
module ArgsCrate =
let make<'a, 'b when 'b :> IArgParserTemplate>
(ofResult : ParseResults<'b> -> Result<'a, ArguParseException>)
(run : 'a -> Async<int>)
=
{ new ArgsCrate with
member _.Apply e = e.Eval ofResult run
}

View File

@@ -16,6 +16,11 @@
</PropertyGroup>
<ItemGroup>
<Compile Include="ArgsCrate.fs" />
<Compile Include="Result.fs" />
<Compile Include="Reconcile.fs" />
<Compile Include="OutputSchema.fs" />
<Compile Include="Verify.fs" />
<Compile Include="Program.fs" />
<None Include="..\README.md" Pack="true" PackagePath="/" />
</ItemGroup>
@@ -27,5 +32,7 @@
<ItemGroup>
<PackageReference Include="Argu" Version="6.1.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="7.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NJsonSchema" Version="10.9.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,47 @@
namespace Gitea.Declarative
open System.IO
open Argu
type OutputSchemaArgsFragment =
| Output of string
interface IArgParserTemplate with
member s.Usage =
match s with
| Output _ -> "path to the file to be written (or overwritten, if it already exists), instead of stdout"
type OutputSchemaArgs =
{
Output : FileInfo option
}
static member OfParse
(parsed : ParseResults<OutputSchemaArgsFragment>)
: Result<OutputSchemaArgs, ArguParseException>
=
try
{
Output = parsed.TryGetResult OutputSchemaArgsFragment.Output |> Option.map FileInfo
}
|> Ok
with :? ArguParseException as e ->
Error e
[<RequireQualifiedAccess>]
module OutputSchema =
let run (args : OutputSchemaArgs) : Async<int> =
async {
use stream = GiteaConfig.getSchema ()
match args.Output with
| None ->
let reader = new StreamReader (stream)
System.Console.WriteLine (reader.ReadToEnd ())
| Some output ->
use output = output.OpenWrite ()
stream.CopyTo output
return 0
}

View File

@@ -1,42 +1,7 @@
namespace Gitea.Declarative
open System
open System.IO
open System.Net.Http
open Microsoft.Extensions.Logging
open Microsoft.Extensions.Logging.Console
open Microsoft.Extensions.Options
open Argu
type ArgsFragments =
| [<ExactlyOnce ; EqualsAssignmentOrSpaced>] Config_File of string
| [<ExactlyOnce ; EqualsAssignmentOrSpaced>] Gitea_Host of string
| [<ExactlyOnce ; EqualsAssignmentOrSpaced ; CustomAppSettings "GITEA_ADMIN_API_TOKEN">] Gitea_Admin_Api_Token of
string
| [<Unique ; EqualsAssignmentOrSpaced ; CustomAppSettings "GITHUB_API_TOKEN">] GitHub_Api_Token of string
| Dry_Run
interface IArgParserTemplate with
member s.Usage =
match s with
| Config_File _ ->
"a config file, JSON, conforming to GiteaConfig.schema.json, specifying the desired Gitea configuration"
| Gitea_Host _ -> "the Gitea host, e.g. https://gitea.mydomain.com"
| Gitea_Admin_Api_Token _ ->
"a Gitea admin user's API token; can be read from the environment variable GITEA_ADMIN_API_TOKEN"
| GitHub_Api_Token _ ->
"a GitHub API token with read access to every desired sync-from-GitHub repo; can be read from the environment variable GITHUB_API_TOKEN"
| Dry_Run _ -> "don't actually perform the reconciliation"
type Args =
{
ConfigFile : FileInfo
GiteaHost : Uri
GiteaAdminApiToken : string
GitHubApiToken : string option
DryRun : bool
}
module Program =
let printUserErrors (m : Map<User, AlignmentError<UserInfo>>) =
@@ -46,88 +11,78 @@ module Program =
m
|> Map.iter (fun (User u) errMap -> errMap |> Map.iter (fun (RepoName r) err -> printfn $"%s{u}: %s{r}: {err}"))
let getUserInput (s : string) : string =
Console.Write s
Console.ReadLine ()
let subcommands =
[|
"reconcile",
("Reconcile a remote Gitea server with a declarative configuration",
ArgsCrate.make RunArgs.OfParse Reconcile.run)
"output-schema",
("Output a schema you can use to verify the `reconcile` config file",
ArgsCrate.make OutputSchemaArgs.OfParse OutputSchema.run)
"verify", ("Verify a `reconcile` configuration file", ArgsCrate.make VerifyArgs.OfParse Verify.run)
|]
|> Map.ofArray
[<EntryPoint>]
let main argv =
let parser = ArgumentParser.Create<ArgsFragments> ()
let reader = ConfigurationReader.FromEnvironmentVariables ()
// It looks like Argu doesn't really support the combination of subcommands and read-from-env-vars, so we just
// roll our own.
let parsed =
try
parser.Parse (argv, reader, raiseOnUsage = true) |> Some
with :? ArguParseException as e ->
eprintfn "%s" e.Message
None
match Array.tryHead argv with
| None
| Some "--help" ->
subcommands.Keys
|> String.concat ","
|> eprintfn "Subcommands (try each with `--help`): %s"
match parsed with
| None -> 127
| Some parsed ->
127
let args =
{
ConfigFile = parsed.GetResult ArgsFragments.Config_File |> FileInfo
GiteaHost = parsed.GetResult ArgsFragments.Gitea_Host |> Uri
GiteaAdminApiToken = parsed.GetResult ArgsFragments.Gitea_Admin_Api_Token
GitHubApiToken = parsed.TryGetResult ArgsFragments.GitHub_Api_Token
DryRun = parsed.TryGetResult ArgsFragments.Dry_Run |> Option.isSome
}
| Some commandName ->
let config = GiteaConfig.get args.ConfigFile
match Map.tryFind commandName subcommands with
| None ->
subcommands.Keys
|> String.concat ","
|> eprintfn "Unrecognised command '%s'. Subcommands (try each with `--help`): %s" commandName
let options =
let options = ConsoleLoggerOptions ()
127
{ new IOptionsMonitor<ConsoleLoggerOptions> with
member _.Get _ = options
member _.CurrentValue = options
| Some (_help, command) ->
member _.OnChange _ =
{ new IDisposable with
member _.Dispose () = ()
}
}
let argv = Array.tail argv
let config = ConfigurationReader.FromEnvironmentVariables ()
use loggerProvider = new ConsoleLoggerProvider (options)
let logger = loggerProvider.CreateLogger "Gitea.Declarative"
{ new ArgsEvaluator<_> with
member _.Eval<'a, 'b when 'b :> IArgParserTemplate>
(ofParseResult : ParseResults<'b> -> Result<'a, _>)
run
=
let parser = ArgumentParser.Create<'b> ()
use client = new HttpClient ()
client.BaseAddress <- args.GiteaHost
client.DefaultRequestHeaders.Add ("Authorization", $"token {args.GiteaAdminApiToken}")
let parsed =
try
parser.Parse (argv, config, raiseOnUsage = true) |> Some
with :? ArguParseException as e ->
e.Message.Replace ("Gitea.Declarative ", sprintf "Gitea.Declarative %s " commandName)
|> eprintfn "%s"
let client = Gitea.Client client |> IGiteaClient.fromReal
None
task {
logger.LogInformation "Checking users..."
let! userErrors = Gitea.checkUsers config client
match parsed with
| None -> Error 127
| Some parsed ->
match userErrors, args.DryRun with
| Ok (), _ -> ()
| Error errors, false -> do! Gitea.reconcileUserErrors logger getUserInput client errors
| Error errors, true ->
logger.LogError (
"Differences encountered in user configuration, but not reconciling them due to --dry-run. Errors may occur while checking repo configuration. {UserErrors}",
errors
)
match ofParseResult parsed with
| Error e ->
e.Message.Replace ("Gitea.Declarative ", sprintf "Gitea.Declarative %s " commandName)
|> eprintfn "%s"
logger.LogInformation "Checking repos..."
let! repoErrors = Gitea.checkRepos logger config client
Error 127
| Ok args ->
match repoErrors, args.DryRun with
| Ok (), _ -> ()
| Error errors, false -> do! Gitea.reconcileRepoErrors logger client args.GitHubApiToken errors
| Error errors, true ->
logger.LogError (
"Differences encountered in repo configuration, but not reconciling them due to --dry-run. {RepoErrors}",
errors
)
match userErrors, repoErrors with
| Ok (), Ok () -> return 0
| Ok (), Error _ -> return 1
| Error _, Ok () -> return 2
| Error _, Error _ -> return 3
run args |> Ok
}
|> fun t -> t.Result
|> command.Apply
|> Result.cata Async.RunSynchronously id

View File

@@ -0,0 +1,116 @@
namespace Gitea.Declarative
open System
open System.IO
open System.Net.Http
open Argu
open Microsoft.Extensions.Logging.Console
open Microsoft.Extensions.Options
open Microsoft.Extensions.Logging
type RunArgsFragment =
| [<ExactlyOnce ; EqualsAssignmentOrSpaced>] Config_File of string
| [<ExactlyOnce ; EqualsAssignmentOrSpaced>] Gitea_Host of string
| [<ExactlyOnce ; EqualsAssignmentOrSpaced ; CustomAppSettings "GITEA_ADMIN_API_TOKEN">] Gitea_Admin_Api_Token of
string
| [<Unique ; EqualsAssignmentOrSpaced ; CustomAppSettings "GITHUB_API_TOKEN">] GitHub_Api_Token of string
| Dry_Run
interface IArgParserTemplate with
member s.Usage =
match s with
| Config_File _ ->
"a config file, JSON, conforming to GiteaConfig.schema.json, specifying the desired Gitea configuration"
| Gitea_Host _ -> "the Gitea host, e.g. https://gitea.mydomain.com"
| Gitea_Admin_Api_Token _ ->
"a Gitea admin user's API token; can be read from the environment variable GITEA_ADMIN_API_TOKEN"
| GitHub_Api_Token _ ->
"a GitHub API token with read access to every desired sync-from-GitHub repo; can be read from the environment variable GITHUB_API_TOKEN"
| Dry_Run _ -> "don't actually perform the reconciliation"
type RunArgs =
{
ConfigFile : FileInfo
GiteaHost : Uri
GiteaAdminApiToken : string
GitHubApiToken : string option
DryRun : bool
}
static member OfParse (parsed : ParseResults<RunArgsFragment>) : Result<RunArgs, ArguParseException> =
try
{
ConfigFile = parsed.GetResult RunArgsFragment.Config_File |> FileInfo
GiteaHost = parsed.GetResult RunArgsFragment.Gitea_Host |> Uri
GiteaAdminApiToken = parsed.GetResult RunArgsFragment.Gitea_Admin_Api_Token
GitHubApiToken = parsed.TryGetResult RunArgsFragment.GitHub_Api_Token
DryRun = parsed.TryGetResult RunArgsFragment.Dry_Run |> Option.isSome
}
|> Ok
with :? ArguParseException as e ->
Error e
[<RequireQualifiedAccess>]
module Reconcile =
let private getUserInput (s : string) : string =
Console.Write s
Console.ReadLine ()
let run (args : RunArgs) : Async<int> =
let config = GiteaConfig.get args.ConfigFile
let options =
let options = ConsoleLoggerOptions ()
{ new IOptionsMonitor<ConsoleLoggerOptions> with
member _.Get _ = options
member _.CurrentValue = options
member _.OnChange _ =
{ new IDisposable with
member _.Dispose () = ()
}
}
async {
use loggerProvider = new ConsoleLoggerProvider (options)
let logger = loggerProvider.CreateLogger "Gitea.Declarative"
use client = new HttpClient ()
client.BaseAddress <- args.GiteaHost
client.DefaultRequestHeaders.Add ("Authorization", $"token {args.GiteaAdminApiToken}")
let client = Gitea.Client client |> IGiteaClient.fromReal
logger.LogInformation "Checking users..."
let! userErrors = Gitea.checkUsers config client
match userErrors, args.DryRun with
| Ok (), _ -> ()
| Error errors, false -> do! Gitea.reconcileUserErrors logger getUserInput client errors
| Error errors, true ->
logger.LogError (
"Differences encountered in user configuration, but not reconciling them due to --dry-run. Errors may occur while checking repo configuration. {UserErrors}",
errors
)
logger.LogInformation "Checking repos..."
let! repoErrors = Gitea.checkRepos logger config client
match repoErrors, args.DryRun with
| Ok (), _ -> ()
| Error errors, false -> do! Gitea.reconcileRepoErrors logger client args.GitHubApiToken errors
| Error errors, true ->
logger.LogError (
"Differences encountered in repo configuration, but not reconciling them due to --dry-run. {RepoErrors}",
errors
)
match userErrors, repoErrors with
| Ok (), Ok () -> return 0
| Ok (), Error _ -> return 1
| Error _, Ok () -> return 2
| Error _, Error _ -> return 3
}

View File

@@ -0,0 +1,9 @@
namespace Gitea.Declarative
[<RequireQualifiedAccess>]
module Result =
let cata<'ok, 'err, 'result> onOk onError (r : Result<'ok, 'err>) : 'result =
match r with
| Ok ok -> onOk ok
| Error e -> onError e

View File

@@ -0,0 +1,62 @@
namespace Gitea.Declarative
open System
open System.IO
open Argu
open NJsonSchema
open NJsonSchema.Validation
type VerifyArgsFragment =
| [<MainCommand>] Input of string
interface IArgParserTemplate with
member s.Usage =
match s with
| Input _ -> "path to the file to be verified, or the literal '-' to read from stdin"
type VerifyArgs =
| File of FileInfo
| Stdin
static member OfParse (parsed : ParseResults<VerifyArgsFragment>) : Result<VerifyArgs, ArguParseException> =
let input =
try
parsed.GetResult VerifyArgsFragment.Input |> Ok
with :? ArguParseException as e ->
Error e
input
|> Result.map (fun input ->
if input = "-" then
VerifyArgs.Stdin
else
VerifyArgs.File (FileInfo input)
)
[<RequireQualifiedAccess>]
module Verify =
let run (args : VerifyArgs) : Async<int> =
async {
let validator = JsonSchemaValidator ()
use schema = GiteaConfig.getSchema ()
let! ct = Async.CancellationToken
let! schema = JsonSchema.FromJsonAsync (schema, ct) |> Async.AwaitTask
use jsonStream =
match args with
| VerifyArgs.Stdin -> Console.OpenStandardInput ()
| VerifyArgs.File f -> f.OpenRead ()
let reader = new StreamReader (jsonStream)
let! json = reader.ReadToEndAsync ct |> Async.AwaitTask
let errors = validator.Validate (json, schema)
if errors.Count = 0 then
return 0
else
for error in errors do
Console.Error.WriteLine (sprintf "Error: %+A" error)
return 1
}

View File

@@ -11,16 +11,18 @@ This is a small project to allow you to specify a [Gitea](https://github.com/go-
* Optional branch protection rules
* Pull request configuration (e.g. whether rebase-merges are allowed, etc)
* Collaborators
* Reconciliation of differences between configuration and reality in the above
* Deletion of repositories, guarded by the `"deleted": true` configuration
# Arguments
Run with the `--help` argument for a full listing.
The main argument you provide is a JSON configuration file, which should conform to [the schema](./Gitea.Declarative.Lib/GiteaConfig.schema.json); there is [an example](./Gitea.Declarative.Test/GiteaConfig.json) in the tests.
(You can call `dotnet-gitea-declarative verify $CONFIG_PATH` to verify against the schema, or `dotnet-gitea-declarative output-schema` to output the schema for local tooling to consume.)
# How to build and run
* With Nix: `nix run github:Smaug123/dotnet-gitea-declarative -- --help`.
* With Nix: `nix run github:Smaug123/dotnet-gitea-declarative -- reconcile --help`.
* From source: clone the repository, and `dotnet run`.
# Demos