ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
1 changed files with 145336 additions and 0 deletions
577
services/mirrored-image/k8s/bootstrap/gateway.fsx
Normal file
577
services/mirrored-image/k8s/bootstrap/gateway.fsx
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
#load "bootstrap-lib.fsx"
|
||||
|
||||
open System
|
||||
open System.Collections.Generic
|
||||
open System.IO
|
||||
open System.Net
|
||||
open System.Net.Http
|
||||
open System.Net.Http.Headers
|
||||
open System.Net.Http.Json
|
||||
open System.Net.Security
|
||||
open System.Security.Cryptography
|
||||
open System.Security.Cryptography.X509Certificates
|
||||
open System.Text
|
||||
open System.Text.Json
|
||||
open System.Text.Json.Nodes
|
||||
open Bootstrap
|
||||
|
||||
type VaultConfig =
|
||||
{ URL: string
|
||||
Role: string
|
||||
JwtPath: string
|
||||
CaPath: string }
|
||||
|
||||
type QuayConfig =
|
||||
{ URL: string
|
||||
ZitadelAPI: string
|
||||
ZitadelIssuer: string
|
||||
CaPath: string
|
||||
LoginKey: string }
|
||||
|
||||
type KubeConfig =
|
||||
{ URL: string
|
||||
TokenPath: string
|
||||
CaPath: string
|
||||
Namespace: string
|
||||
Secret: string }
|
||||
|
||||
let private require condition message =
|
||||
if not condition then
|
||||
invalidOp message
|
||||
|
||||
let private stringNode (value: string) = JsonValue.Create(value) :> JsonNode
|
||||
|
||||
let private jsonObject (values: (string * JsonNode) list) =
|
||||
let result = JsonObject()
|
||||
|
||||
for name, value in values do
|
||||
result[name] <- value
|
||||
|
||||
result
|
||||
|
||||
let private property (name: string) (root: JsonElement) =
|
||||
let mutable value = Unchecked.defaultof<JsonElement>
|
||||
require (root.TryGetProperty(name, &value)) $"response omitted {name}"
|
||||
value
|
||||
|
||||
let private stringProp name root =
|
||||
let value = property name root
|
||||
require (value.ValueKind = JsonValueKind.String) $"response {name} is not a string"
|
||||
|
||||
value.GetString()
|
||||
|> Option.ofObj
|
||||
|> Option.defaultWith (fun () -> invalidOp $"response {name} is empty")
|
||||
|
||||
let private readJson operation status (response: HttpResponseMessage) =
|
||||
use response = response
|
||||
require (response.StatusCode = status) $"{operation} failed with HTTP {int response.StatusCode}"
|
||||
use document = JsonDocument.Parse(response.Content.ReadAsStream())
|
||||
document.RootElement.Clone()
|
||||
|
||||
let private expect operation status (response: HttpResponseMessage) =
|
||||
use response = response
|
||||
require (response.StatusCode = status) $"{operation} failed with HTTP {int response.StatusCode}"
|
||||
|
||||
let private handler caPath allowRedirect =
|
||||
let handler = new HttpClientHandler(AllowAutoRedirect = allowRedirect)
|
||||
|
||||
if not (String.IsNullOrWhiteSpace caPath) then
|
||||
let root = X509Certificate2.CreateFromPem(File.ReadAllText caPath)
|
||||
|
||||
handler.ServerCertificateCustomValidationCallback <-
|
||||
fun _ certificate peerChain errors ->
|
||||
if errors = SslPolicyErrors.None then
|
||||
true
|
||||
elif isNull certificate || errors <> SslPolicyErrors.RemoteCertificateChainErrors then
|
||||
false
|
||||
else
|
||||
use chain = new X509Chain()
|
||||
chain.ChainPolicy.TrustMode <- X509ChainTrustMode.CustomRootTrust
|
||||
chain.ChainPolicy.CustomTrustStore.Add root |> ignore
|
||||
chain.ChainPolicy.RevocationMode <- X509RevocationMode.NoCheck
|
||||
|
||||
if not (isNull peerChain) then
|
||||
for element in peerChain.ChainElements do
|
||||
chain.ChainPolicy.ExtraStore.Add element.Certificate |> ignore
|
||||
|
||||
chain.Build certificate
|
||||
|
||||
handler
|
||||
|
||||
let private client caPath allowRedirect =
|
||||
let client = new HttpClient(handler caPath allowRedirect, true)
|
||||
client.Timeout <- TimeSpan.FromSeconds 30.
|
||||
client
|
||||
|
||||
let private sessionClient caPath =
|
||||
let handler = handler caPath false
|
||||
handler.CookieContainer <- CookieContainer()
|
||||
let client = new HttpClient(handler, true)
|
||||
client.Timeout <- TimeSpan.FromSeconds 30.
|
||||
client
|
||||
|
||||
let private sendRequest
|
||||
(client: HttpClient)
|
||||
(method: HttpMethod)
|
||||
(endpoint: string)
|
||||
(bearer: string option)
|
||||
(basic: (string * string) option)
|
||||
(csrf: string option)
|
||||
(body: JsonNode option)
|
||||
(host: string option)
|
||||
(headers: (string * string) list)
|
||||
=
|
||||
use message = new HttpRequestMessage(method, endpoint)
|
||||
|
||||
host |> Option.iter (fun value -> message.Headers.Host <- value)
|
||||
headers |> List.iter (fun (name, value) -> message.Headers.Add(name, value))
|
||||
|
||||
match bearer, basic with
|
||||
| Some token, _ -> message.Headers.Authorization <- AuthenticationHeaderValue("Bearer", token)
|
||||
| _, Some(user, token) ->
|
||||
let encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{token}"))
|
||||
message.Headers.Authorization <- AuthenticationHeaderValue("Basic", encoded)
|
||||
| _ -> ()
|
||||
|
||||
csrf |> Option.iter (fun token -> message.Headers.Add("X-CSRF-Token", token))
|
||||
|
||||
body
|
||||
|> Option.iter (fun value ->
|
||||
let content =
|
||||
new StringContent(value.ToJsonString(), Encoding.UTF8, "application/json")
|
||||
|
||||
content.Headers.ContentType.CharSet <- null
|
||||
message.Content <- content)
|
||||
|
||||
client.Send message
|
||||
|
||||
let private request client method endpoint bearer basic csrf body =
|
||||
sendRequest client method endpoint bearer basic csrf body None []
|
||||
|
||||
let private endpoint (root: string) (path: string) =
|
||||
$"{root.TrimEnd('/')}/{path.TrimStart('/')}"
|
||||
|
||||
let private base64Url (content: byte array) =
|
||||
Convert.ToBase64String content
|
||||
|> _.TrimEnd('=')
|
||||
|> _.Replace('+', '-')
|
||||
|> _.Replace('/', '_')
|
||||
|
||||
let private systemToken zitadel (key: string) =
|
||||
let issued = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||
|
||||
let header = JsonSerializer.SerializeToUtf8Bytes {| alg = "RS256" |} |> base64Url
|
||||
|
||||
let claims =
|
||||
JsonSerializer.SerializeToUtf8Bytes
|
||||
{| iss = "login-client"
|
||||
sub = "login-client"
|
||||
aud = zitadel
|
||||
iat = issued
|
||||
exp = issued + 300L |}
|
||||
|> base64Url
|
||||
|
||||
let unsigned = $"{header}.{claims}"
|
||||
use rsa = RSA.Create()
|
||||
rsa.ImportFromPem key
|
||||
|
||||
let signature =
|
||||
rsa.SignData(Encoding.UTF8.GetBytes unsigned, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)
|
||||
|> base64Url
|
||||
|
||||
$"{unsigned}.{signature}"
|
||||
|
||||
let readKubeKeyWith (client: HttpClient) (config: KubeConfig) =
|
||||
let token = File.ReadAllText(config.TokenPath).Trim()
|
||||
require (not (String.IsNullOrWhiteSpace token)) "service account token is empty"
|
||||
|
||||
let path =
|
||||
$"api/v1/namespaces/{Uri.EscapeDataString config.Namespace}/secrets/{Uri.EscapeDataString config.Secret}"
|
||||
|
||||
let response =
|
||||
request client HttpMethod.Get (endpoint config.URL path) (Some token) None None None
|
||||
|
||||
let data =
|
||||
readJson "read ZITADEL login key" HttpStatusCode.OK response |> property "data"
|
||||
|
||||
let encoded = stringProp "tls.key" data
|
||||
Encoding.UTF8.GetString(Convert.FromBase64String encoded)
|
||||
|
||||
let readKubeKey (config: KubeConfig) =
|
||||
readKubeKeyWith (client config.CaPath true) config
|
||||
|
||||
let private vaultRequest
|
||||
(client: HttpClient)
|
||||
(method: HttpMethod)
|
||||
(endpoint: string)
|
||||
(token: string)
|
||||
(body: JsonNode option)
|
||||
=
|
||||
use message = new HttpRequestMessage(method, endpoint)
|
||||
message.Headers.Add("X-Vault-Token", token)
|
||||
body |> Option.iter (fun value -> message.Content <- JsonContent.Create value)
|
||||
client.Send message
|
||||
|
||||
let makeStoreWith (client: HttpClient) (config: VaultConfig) =
|
||||
let jwt = File.ReadAllText(config.JwtPath).Trim()
|
||||
require (not (String.IsNullOrWhiteSpace jwt)) "service account token is empty"
|
||||
|
||||
let payload = jsonObject [ "role", stringNode config.Role; "jwt", stringNode jwt ]
|
||||
|
||||
let response =
|
||||
request client HttpMethod.Post (endpoint config.URL "v1/auth/kubernetes/login") None None None (Some payload)
|
||||
|
||||
let login = readJson "Vault login" HttpStatusCode.OK response
|
||||
let token = login |> property "auth" |> stringProp "client_token"
|
||||
|
||||
let read (path: string) =
|
||||
let response =
|
||||
vaultRequest client HttpMethod.Get (endpoint config.URL $"v1/{path}") token None
|
||||
|
||||
let root = readJson "Vault read" HttpStatusCode.OK response
|
||||
let data = root |> property "data" |> property "data"
|
||||
|
||||
data.EnumerateObject()
|
||||
|> Seq.map (fun item -> item.Name, item.Value.GetString())
|
||||
|> Seq.choose (fun (name, value) -> value |> Option.ofObj |> Option.map (fun text -> name, text))
|
||||
|> Map.ofSeq
|
||||
|
||||
let write (path: string) (values: Map<string, string>) =
|
||||
let data = JsonObject()
|
||||
|
||||
for KeyValue(name, value) in values do
|
||||
data[name] <- stringNode value
|
||||
|
||||
let payload = jsonObject (List.singleton ("data", data))
|
||||
|
||||
let response =
|
||||
vaultRequest client HttpMethod.Post (endpoint config.URL $"v1/{path}") token (Some payload)
|
||||
|
||||
expect "Vault write" HttpStatusCode.OK response
|
||||
|
||||
{ Read = read; Write = write }
|
||||
|
||||
let makeStore (config: VaultConfig) =
|
||||
makeStoreWith (client config.CaPath true) config
|
||||
|
||||
let private query values =
|
||||
let pairs = values |> List.map (fun (name, value) -> KeyValuePair(name, value))
|
||||
use content = new FormUrlEncodedContent(pairs)
|
||||
content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
|
||||
let private assignments quayURL (session: Session) =
|
||||
let client = session.State :?> HttpClient
|
||||
|
||||
let response =
|
||||
request client HttpMethod.Get (endpoint quayURL "/api/v1/user/assignedauthorization") None None None None
|
||||
|
||||
readJson "list assignments" HttpStatusCode.OK response
|
||||
|> property "authorizations"
|
||||
|> _.EnumerateArray()
|
||||
|> Seq.map _.Clone()
|
||||
|> Seq.toList
|
||||
|
||||
let private assignment quayURL app items =
|
||||
let matches =
|
||||
items
|
||||
|> List.filter (fun item -> item |> property "application" |> stringProp "clientId" = app.Client)
|
||||
|
||||
match matches with
|
||||
| [] -> None
|
||||
| [ item ] ->
|
||||
let scopes =
|
||||
item
|
||||
|> property "scopes"
|
||||
|> _.EnumerateArray()
|
||||
|> Seq.map (stringProp "scope")
|
||||
|> Seq.sort
|
||||
|> Seq.toList
|
||||
|
||||
require (scopes = List.sort app.Scopes) "assigned authorization has different scopes"
|
||||
|
||||
require
|
||||
(stringProp "redirectUri" item = endpoint quayURL "/oauth/localapp")
|
||||
"assigned authorization has a different redirect URI"
|
||||
|
||||
require (stringProp "responseType" item = "token") "assigned authorization has a different response type"
|
||||
Some item
|
||||
| _ -> invalidOp "multiple assigned authorizations use the same client ID"
|
||||
|
||||
let makeQuayWith (adminClient: HttpClient) makeSession (config: QuayConfig) =
|
||||
let quayURL path = endpoint config.URL path
|
||||
let zitadelURL path = endpoint config.ZitadelIssuer path
|
||||
let issuer = Uri config.ZitadelIssuer
|
||||
let apiRoot = Uri config.ZitadelAPI
|
||||
|
||||
let zitadelRequest client method url bearer body =
|
||||
let source = Uri url
|
||||
let builder = UriBuilder apiRoot
|
||||
builder.Path <- source.AbsolutePath
|
||||
builder.Query <- source.Query.TrimStart('?')
|
||||
|
||||
sendRequest
|
||||
client
|
||||
method
|
||||
(builder.Uri.ToString())
|
||||
bearer
|
||||
None
|
||||
None
|
||||
body
|
||||
(Some issuer.Host)
|
||||
[ ("X-Zitadel-Public-Host", issuer.Host) ]
|
||||
|
||||
let login user password loginAuth =
|
||||
let client = makeSession ()
|
||||
|
||||
let csrfRoot =
|
||||
request client HttpMethod.Get (quayURL "/csrf_token") None None None None
|
||||
|
||||
let csrf =
|
||||
readJson "get Quay CSRF token" HttpStatusCode.OK csrfRoot
|
||||
|> stringProp "csrf_token"
|
||||
|
||||
let payload = jsonObject (List.singleton ("kind", stringNode "login"))
|
||||
|
||||
let externalResponse =
|
||||
request
|
||||
client
|
||||
HttpMethod.Post
|
||||
(quayURL "/api/v1/externallogin/zitadel")
|
||||
None
|
||||
None
|
||||
(Some csrf)
|
||||
(Some payload)
|
||||
|
||||
let external = readJson "start Quay login" HttpStatusCode.OK externalResponse
|
||||
let authURL = stringProp "auth_url" external
|
||||
use started = zitadelRequest client HttpMethod.Get authURL None None
|
||||
|
||||
require
|
||||
(int started.StatusCode >= 300 && int started.StatusCode < 400)
|
||||
$"start ZITADEL login failed with HTTP {int started.StatusCode}"
|
||||
|
||||
require (not (isNull started.Headers.Location)) "ZITADEL login returned no redirect"
|
||||
let loginURL = Uri(Uri(zitadelURL "/"), started.Headers.Location)
|
||||
let requestID = loginURL.Query.TrimStart('?').Split('&')
|
||||
|
||||
let requestID =
|
||||
requestID
|
||||
|> Array.tryPick (fun item ->
|
||||
match item.Split('=', 2) with
|
||||
| [| name; value |] when name = "authRequest" -> Some(Uri.UnescapeDataString value)
|
||||
| _ -> None)
|
||||
|> Option.defaultWith (fun () -> invalidOp "ZITADEL redirect omitted authRequest")
|
||||
|
||||
let userCheck =
|
||||
jsonObject (
|
||||
List.singleton (
|
||||
"checks",
|
||||
jsonObject (List.singleton ("user", jsonObject (List.singleton ("loginName", stringNode user))))
|
||||
)
|
||||
)
|
||||
|
||||
let createdResponse =
|
||||
zitadelRequest client HttpMethod.Post (zitadelURL "/v2/sessions") (Some loginAuth) (Some userCheck)
|
||||
|
||||
let created =
|
||||
readJson "create ZITADEL session" HttpStatusCode.Created createdResponse
|
||||
|
||||
let sessionID = stringProp "sessionId" created
|
||||
|
||||
let passwordCheck =
|
||||
jsonObject (
|
||||
List.singleton (
|
||||
"checks",
|
||||
jsonObject (
|
||||
List.singleton ("password", jsonObject (List.singleton ("password", stringNode password)))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
let checkedResponse =
|
||||
zitadelRequest
|
||||
client
|
||||
HttpMethod.Patch
|
||||
(zitadelURL $"/v2/sessions/{Uri.EscapeDataString sessionID}")
|
||||
(Some loginAuth)
|
||||
(Some passwordCheck)
|
||||
|
||||
let passwordResult =
|
||||
readJson "check ZITADEL password" HttpStatusCode.OK checkedResponse
|
||||
|
||||
let sessionToken = stringProp "sessionToken" passwordResult
|
||||
|
||||
let finish =
|
||||
jsonObject (
|
||||
List.singleton (
|
||||
"session",
|
||||
jsonObject [ "sessionId", stringNode sessionID; "sessionToken", stringNode sessionToken ]
|
||||
)
|
||||
)
|
||||
|
||||
let finishResponse =
|
||||
zitadelRequest
|
||||
client
|
||||
HttpMethod.Post
|
||||
(zitadelURL $"/v2/oidc/auth_requests/{Uri.EscapeDataString requestID}")
|
||||
(Some loginAuth)
|
||||
(Some finish)
|
||||
|
||||
let finished = readJson "finish ZITADEL request" HttpStatusCode.OK finishResponse
|
||||
let callback = stringProp "callbackUrl" finished
|
||||
use callbackResponse = request client HttpMethod.Get callback None None None None
|
||||
|
||||
require
|
||||
(int callbackResponse.StatusCode >= 200 && int callbackResponse.StatusCode < 400)
|
||||
$"finish Quay login failed with HTTP {int callbackResponse.StatusCode}"
|
||||
|
||||
{ User = user; State = client }
|
||||
|
||||
let admin method path token body operation status =
|
||||
request adminClient method (quayURL path) (Some token) None None body
|
||||
|> expect operation status
|
||||
|
||||
let adminJson method path token body operation =
|
||||
request adminClient method (quayURL path) (Some token) None None body
|
||||
|> readJson operation HttpStatusCode.OK
|
||||
|
||||
let access adminToken app =
|
||||
let teamPath =
|
||||
$"/api/v1/organization/{Uri.EscapeDataString app.Org}/team/mirrored-image"
|
||||
|
||||
let team = jsonObject (List.singleton ("role", stringNode "creator"))
|
||||
admin HttpMethod.Put teamPath adminToken (Some team) "ensure Quay team" HttpStatusCode.OK
|
||||
|
||||
let memberPath = $"{teamPath}/members/{Uri.EscapeDataString app.User}"
|
||||
admin HttpMethod.Put memberPath adminToken None "ensure Quay member" HttpStatusCode.OK
|
||||
let protoPath = $"/api/v1/organization/{Uri.EscapeDataString app.Org}/prototypes"
|
||||
|
||||
let listed =
|
||||
adminJson HttpMethod.Get protoPath adminToken None "list Quay prototypes"
|
||||
|
||||
let exists =
|
||||
listed
|
||||
|> property "prototypes"
|
||||
|> _.EnumerateArray()
|
||||
|> Seq.exists (fun item ->
|
||||
let delegateValue = property "delegate" item
|
||||
|
||||
stringProp "role" item = "write"
|
||||
&& stringProp "name" delegateValue = "mirrored-image"
|
||||
&& stringProp "kind" delegateValue = "team")
|
||||
|
||||
if not exists then
|
||||
let payload =
|
||||
jsonObject
|
||||
[ "role", stringNode "write"
|
||||
"delegate", jsonObject [ "name", stringNode "mirrored-image"; "kind", stringNode "team" ] ]
|
||||
|
||||
admin HttpMethod.Post protoPath adminToken (Some payload) "create Quay prototype" HttpStatusCode.OK
|
||||
|
||||
let application adminToken app =
|
||||
let path = $"/api/v1/organization/{Uri.EscapeDataString app.Org}/applications"
|
||||
let listed = adminJson HttpMethod.Get path adminToken None "list Quay applications"
|
||||
let redirect = quayURL "/oauth/localapp"
|
||||
|
||||
let existing =
|
||||
listed
|
||||
|> property "applications"
|
||||
|> _.EnumerateArray()
|
||||
|> Seq.tryFind (fun item -> stringProp "name" item = app.Name)
|
||||
|
||||
match existing with
|
||||
| Some item ->
|
||||
require (stringProp "redirect_uri" item = redirect) "existing application has a different redirect URI"
|
||||
stringProp "client_id" item
|
||||
| None ->
|
||||
let payload =
|
||||
jsonObject
|
||||
[ "name", stringNode app.Name
|
||||
"redirect_uri", stringNode redirect
|
||||
"application_uri", stringNode redirect ]
|
||||
|
||||
adminJson HttpMethod.Post path adminToken (Some payload) "create Quay application"
|
||||
|> stringProp "client_id"
|
||||
|
||||
let assign adminToken session app =
|
||||
if assignment config.URL app (assignments config.URL session) |> Option.isNone then
|
||||
let values =
|
||||
[ "username", app.User
|
||||
"response_type", "token"
|
||||
"client_id", app.Client
|
||||
"scope", String.concat " " app.Scopes
|
||||
"redirect_uri", quayURL "/oauth/localapp"
|
||||
"format", "json" ]
|
||||
|
||||
let assignPath = quayURL "/oauth/authorize/assignuser"
|
||||
let url = $"{assignPath}?{query values}"
|
||||
|
||||
request adminClient HttpMethod.Post url None (Some("$oauthtoken", adminToken)) None None
|
||||
|> expect "assign Quay authorization" HttpStatusCode.OK
|
||||
|
||||
assignment config.URL app (assignments config.URL session)
|
||||
|> Option.defaultWith (fun () -> invalidOp "assigned authorization is absent")
|
||||
|> ignore
|
||||
|
||||
let issue session app =
|
||||
let client = session.State :?> HttpClient
|
||||
|
||||
let assigned =
|
||||
assignment config.URL app (assignments config.URL session)
|
||||
|> Option.defaultWith (fun () -> invalidOp "assigned authorization is absent")
|
||||
|
||||
let scopes =
|
||||
assigned
|
||||
|> property "scopes"
|
||||
|> _.EnumerateArray()
|
||||
|> Seq.map (stringProp "scope")
|
||||
|> String.concat " "
|
||||
|
||||
let application = property "application" assigned
|
||||
|
||||
let values =
|
||||
[ "response_type", stringProp "responseType" assigned
|
||||
"client_id", stringProp "clientId" application
|
||||
"scope", scopes
|
||||
"redirect_uri", stringProp "redirectUri" assigned
|
||||
"assignment_uuid", stringProp "uuid" assigned ]
|
||||
|
||||
let approvePath = quayURL "/oauth/authorize"
|
||||
let approveQuery = query (values @ List.singleton ("format", "json"))
|
||||
let approveURL = $"{approvePath}?{approveQuery}"
|
||||
let approvalResponse = request client HttpMethod.Get approveURL None None None None
|
||||
|
||||
let csrf =
|
||||
readJson "load Quay authorization" HttpStatusCode.OK approvalResponse
|
||||
|> stringProp "csrf_token_val"
|
||||
|
||||
use message = new HttpRequestMessage(HttpMethod.Post, quayURL "/oauth/authorizeapp")
|
||||
|
||||
message.Content <-
|
||||
new FormUrlEncodedContent(values |> List.map (fun (name, value) -> KeyValuePair(name, value)))
|
||||
|
||||
message.Headers.Add("X-CSRF-Token", csrf)
|
||||
use response = client.Send message
|
||||
|
||||
require
|
||||
(response.StatusCode = HttpStatusCode.Found)
|
||||
$"issue Quay token failed with HTTP {int response.StatusCode}"
|
||||
|
||||
require (not (isNull response.Headers.Location)) "Quay token response omitted redirect"
|
||||
|
||||
response.Headers.Location.Fragment.TrimStart('#').Split('&')
|
||||
|> Array.tryPick (fun item ->
|
||||
match item.Split('=', 2) with
|
||||
| [| name; value |] when name = "access_token" -> Some(Uri.UnescapeDataString value)
|
||||
| _ -> None)
|
||||
|> Option.defaultWith (fun () -> invalidOp "Quay token response omitted access token")
|
||||
|
||||
{ LoginAuth = fun () -> systemToken config.ZitadelIssuer config.LoginKey
|
||||
Login = login
|
||||
Access = access
|
||||
Application = application
|
||||
Assign = assign
|
||||
Issue = issue }
|
||||
|
||||
let makeQuay (config: QuayConfig) =
|
||||
makeQuayWith (client config.CaPath false) (fun () -> sessionClient config.CaPath) config
|
||||
Loading…
Reference in a new issue