Skip to content

Commit

Permalink
Add runner registration for repository
Browse files Browse the repository at this point in the history
  • Loading branch information
mattia committed Aug 23, 2023
1 parent 013a865 commit d173953
Show file tree
Hide file tree
Showing 14 changed files with 241 additions and 75 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import Foundation

public protocol GitHubCredentialsStore: AnyObject {
var organizationName: String? { get async }
var repositoryName: String? { get async }
var ownerName: String? { get async }
var appId: String? { get async }
var privateKey: Data? { get async }
func setOrganizationName(_ organizationName: String?) async
func setRepository(_ repositoryName: String?, withOwner ownerName: String?) async
func setAppID(_ appID: String?) async
func setPrivateKey(_ privateKeyData: Data?) async
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import RSAPrivateKey
public final actor GitHubCredentialsStoreKeychain: GitHubCredentialsStore {
private enum PasswordAccount {
static let organizationName = "github.credentials.organizationName"
static let repositoryName = "github.credentials.repositoryName"
static let ownerName = "github.credentials.ownerName"
static let appId = "github.credentials.appId"
}

Expand All @@ -18,6 +20,16 @@ public final actor GitHubCredentialsStoreKeychain: GitHubCredentialsStore {
return await keychain.password(forAccount: PasswordAccount.organizationName, belongingToService: serviceName)
}
}
public var repositoryName: String? {
get async {
return await keychain.password(forAccount: PasswordAccount.repositoryName, belongingToService: serviceName)
}
}
public var ownerName: String? {
get async {
return await keychain.password(forAccount: PasswordAccount.ownerName, belongingToService: serviceName)
}
}
public var appId: String? {
get async {
return await keychain.password(forAccount: PasswordAccount.appId, belongingToService: serviceName)
Expand All @@ -44,6 +56,18 @@ public final actor GitHubCredentialsStoreKeychain: GitHubCredentialsStore {
await keychain.removePassword(forAccount: PasswordAccount.organizationName, belongingToService: serviceName)
}
}
public func setRepository(_ repositoryName: String?, withOwner ownerName: String?) async {
if let repositoryName {
_ = await keychain.setPassword(repositoryName, forAccount: PasswordAccount.repositoryName, belongingToService: serviceName)
} else {
await keychain.removePassword(forAccount: PasswordAccount.repositoryName, belongingToService: serviceName)
}
if let ownerName {
_ = await keychain.setPassword(ownerName, forAccount: PasswordAccount.ownerName, belongingToService: serviceName)
} else {
await keychain.removePassword(forAccount: PasswordAccount.ownerName, belongingToService: serviceName)
}
}

public func setAppID(_ appID: String?) async {
if let appID {
Expand Down
6 changes: 6 additions & 0 deletions Packages/GitHub/Sources/GitHubService/GitHubRunnerScope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

public enum GitHubRunnerScope: String, CaseIterable {
case organization
case repo
}
9 changes: 6 additions & 3 deletions Packages/GitHub/Sources/GitHubService/GitHubService.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import Foundation

public protocol GitHubService {
func getAppAccessToken() async throws -> GitHubAppAccessToken
func getRunnerRegistrationToken(with appAccessToken: GitHubAppAccessToken) async throws -> GitHubRunnerRegistrationToken
func getRunnerDownloadURL(with appAccessToken: GitHubAppAccessToken) async throws -> URL
func getAppAccessToken(runnerScope: GitHubRunnerScope) async throws -> GitHubAppAccessToken
func getRunnerRegistrationToken(
with appAccessToken: GitHubAppAccessToken,
runnerScope: GitHubRunnerScope
) async throws -> GitHubRunnerRegistrationToken
func getRunnerDownloadURL(with appAccessToken: GitHubAppAccessToken, runnerScope: GitHubRunnerScope) async throws -> URL
}
85 changes: 67 additions & 18 deletions Packages/GitHub/Sources/GitHubServiceLive/GitHubServiceLive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import NetworkingService

private enum GitHubServiceLiveError: LocalizedError {
case organizationNameUnavailable
case repositoryNameUnavailable
case repositoryOwnerNameUnavailable
case appIDUnavailable
case privateKeyUnavailable
case appIsNotInstalled
Expand All @@ -15,6 +17,10 @@ private enum GitHubServiceLiveError: LocalizedError {
switch self {
case .organizationNameUnavailable:
return "The organization name is not available"
case .repositoryNameUnavailable:
return "The repository name is not available"
case .repositoryOwnerNameUnavailable:
return "The repository owner name is not available"
case .appIDUnavailable:
return "The app ID is not available"
case .privateKeyUnavailable:
Expand All @@ -37,8 +43,8 @@ public final class GitHubServiceLive: GitHubService {
self.networkingService = networkingService
}

public func getAppAccessToken() async throws -> GitHubAppAccessToken {
let appInstallation = try await getAppInstallation()
public func getAppAccessToken(runnerScope: GitHubRunnerScope) async throws -> GitHubAppAccessToken {
let appInstallation = try await getAppInstallation(runnerScope: runnerScope)
let installationID = String(appInstallation.id)
let appID = String(appInstallation.appId)
let url = baseURL.appending(path: "/app/installations/\(installationID)/access_tokens")
Expand All @@ -53,9 +59,8 @@ public final class GitHubServiceLive: GitHubService {
}
}

public func getRunnerDownloadURL(with appAccessToken: GitHubAppAccessToken) async throws -> URL {
let organizationName = try await getOrganizationName()
let url = baseURL.appending(path: "/orgs/\(organizationName)/actions/runners/downloads")
public func getRunnerDownloadURL(with appAccessToken: GitHubAppAccessToken, runnerScope: GitHubRunnerScope) async throws -> URL {
let url = try await baseURL.appending(path: runnerScope.runnerDownloadPath(using: credentialsStore))
let request = URLRequest(url: url).addingBearerToken(appAccessToken.rawValue)
let downloads = try await networkingService.load([GitHubRunnerDownload].self, from: request).map(\.value)
let os = "osx"
Expand All @@ -66,9 +71,11 @@ public final class GitHubServiceLive: GitHubService {
return download.downloadURL
}

public func getRunnerRegistrationToken(with appAccessToken: GitHubAppAccessToken) async throws -> GitHubRunnerRegistrationToken {
let organizationName = try await getOrganizationName()
let url = baseURL.appending(path: "/orgs/\(organizationName)/actions/runners/registration-token")
public func getRunnerRegistrationToken(
with appAccessToken: GitHubAppAccessToken,
runnerScope: GitHubRunnerScope
) async throws -> GitHubRunnerRegistrationToken {
let url = try await baseURL.appending(path: runnerScope.runnerRegistrationPath(using: credentialsStore))
var request = URLRequest(url: url).addingBearerToken(appAccessToken.rawValue)
request.httpMethod = "POST"
return try await networkingService.load(IntermediateGitHubRunnerRegistrationToken.self, from: request).map { parameters in
Expand All @@ -78,25 +85,18 @@ public final class GitHubServiceLive: GitHubService {
}

private extension GitHubServiceLive {
private func getAppInstallation() async throws -> GitHubAppInstallation {
private func getAppInstallation(runnerScope: GitHubRunnerScope) async throws -> GitHubAppInstallation {
let url = baseURL.appending(path: "/app/installations")
let token = try await getAppJWTToken()
let request = URLRequest(url: url).addingBearerToken(token)
let appInstallations = try await networkingService.load([GitHubAppInstallation].self, from: request).map(\.value)
let organizationName = await credentialsStore.organizationName
guard let appInstallation = appInstallations.first(where: { $0.account.login == organizationName }) else {
let loginName = await runnerScope.runnerLogin(using: credentialsStore)
guard let appInstallation = appInstallations.first(where: { $0.account.login == loginName }) else {
throw GitHubServiceLiveError.appIsNotInstalled
}
return appInstallation
}

private func getOrganizationName() async throws -> String {
guard let organizationName = await credentialsStore.organizationName else {
throw GitHubServiceLiveError.organizationNameUnavailable
}
return organizationName
}

private func getAppJWTToken() async throws -> String {
guard let privateKey = await credentialsStore.privateKey else {
throw GitHubServiceLiveError.privateKeyUnavailable
Expand All @@ -115,3 +115,52 @@ private extension URLRequest {
return mutableRequest
}
}

private extension GitHubRunnerScope {
func runnerRegistrationPath(using credentialsStore: GitHubCredentialsStore) async throws -> String {
switch self {
case .organization:
guard let organizationName = await credentialsStore.organizationName else {
throw GitHubServiceLiveError.organizationNameUnavailable
}
return "/orgs/\(organizationName)/actions/runners/registration-token"
case .repo:
guard let repositoryName = await credentialsStore.repositoryName else {
throw GitHubServiceLiveError.repositoryNameUnavailable
}
guard let ownerName = await credentialsStore.ownerName else {
throw GitHubServiceLiveError.repositoryOwnerNameUnavailable
}

return "/repos/\(ownerName)/\(repositoryName)/actions/runners/registration-token"
}
}

func runnerDownloadPath(using credentialsStore: GitHubCredentialsStore) async throws -> String {
switch self {
case .organization:
guard let organizationName = await credentialsStore.organizationName else {
throw GitHubServiceLiveError.organizationNameUnavailable
}
return "/orgs/\(organizationName)/actions/runners/downloads"
case .repo:
guard let repositoryName = await credentialsStore.repositoryName else {
throw GitHubServiceLiveError.repositoryNameUnavailable
}
guard let ownerName = await credentialsStore.ownerName else {
throw GitHubServiceLiveError.repositoryOwnerNameUnavailable
}

return "/repos/\(ownerName)/\(repositoryName)/actions/runners/downloads"
}
}

func runnerLogin(using credentialsStore: GitHubCredentialsStore) async -> String? {
switch self {
case .organization:
return await credentialsStore.organizationName
case .repo:
return await credentialsStore.ownerName
}
}
}
2 changes: 2 additions & 0 deletions Packages/Settings/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ let package = Package(
targets: [
.target(name: "Settings"),
.target(name: "SettingsStore", dependencies: [
.product(name: "GitHubService", package: "GitHub"),
"Settings"
]),
.target(name: "SettingsUI", dependencies: [
.product(name: "GitHubCredentialsStore", package: "GitHub"),
.product(name: "GitHubService", package: "GitHub"),
.product(name: "LogExporter", package: "Logging"),
"Settings",
"SettingsStore",
Expand Down
4 changes: 4 additions & 0 deletions Packages/Settings/Sources/SettingsStore/SettingsStore.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Combine
import GitHubService
import Settings
import SwiftUI

Expand All @@ -12,6 +13,7 @@ public final class SettingsStore: ObservableObject {
static let gitHubPrivateKeyName = "gitHubPrivateKeyName"
static let gitHubRunnerLabels = "gitHubRunnerLabels"
static let gitHubRunnerGroup = "gitHubRunnerGroup"
static let githubRunnerScope = "githubRunnerScope"
}

@AppStorage(AppStorageKey.applicationUIMode)
Expand All @@ -30,6 +32,8 @@ public final class SettingsStore: ObservableObject {
public var gitHubRunnerLabels = "tartelet"
@AppStorage(AppStorageKey.gitHubRunnerGroup)
public var gitHubRunnerGroup = ""
@AppStorage(AppStorageKey.githubRunnerScope)
public var githubRunnerScope: GitHubRunnerScope = .organization

public init() {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,37 +12,40 @@ struct GitHubPrivateKeyPicker: View {
}

var body: some View {
LabeledContent {
VStack {
HStack {
TextField(
L10n.Settings.Github.privateKey,
text: $filename,
prompt: Text(L10n.Settings.Github.PrivateKey.placeholder)
)
.labelsHidden()
.disabled(true)
Button {
if let fileURL = presentOpenPanel() {
onSelectFile(fileURL)
}
} label: {
Text(L10n.Settings.Github.PrivateKey.selectFile)
}.disabled(!isEnabled)
}
Text(L10n.Settings.Github.PrivateKey.scopes)
.multilineTextAlignment(.center)
.lineSpacing(4)
.padding(8)
.frame(maxWidth: .infinity)
.foregroundColor(.secondary)
.background {
RoundedRectangle(cornerRadius: 12)
.stroke(.separator, lineWidth: 1)
VStack(spacing: 16) {
LabeledContent {
VStack {
HStack {
TextField(
L10n.Settings.Github.privateKey,
text: $filename,
prompt: Text(L10n.Settings.Github.PrivateKey.placeholder)
)
.labelsHidden()
.disabled(true)
Button {
if let fileURL = presentOpenPanel() {
onSelectFile(fileURL)
}
} label: {
Text(L10n.Settings.Github.PrivateKey.selectFile)
}.disabled(!isEnabled)
}
}
} label: {
Text(L10n.Settings.Github.privateKey)
}
} label: {
Text(L10n.Settings.Github.privateKey)

Text(L10n.Settings.Github.PrivateKey.scopes)
.multilineTextAlignment(.center)
.lineSpacing(4)
.padding(8)
.frame(maxWidth: .infinity)
.foregroundColor(.secondary)
.background {
RoundedRectangle(cornerRadius: 12)
.stroke(.separator, lineWidth: 1)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,42 @@ struct GitHubSettingsView: View {

var body: some View {
Form {
TextField(L10n.Settings.Github.organizationName, text: $viewModel.organizationName)
.disabled(!viewModel.isSettingsEnabled)
TextField(L10n.Settings.Github.appId, text: $viewModel.appId)
.disabled(!viewModel.isSettingsEnabled)
GitHubPrivateKeyPicker(filename: $viewModel.privateKeyName, isEnabled: viewModel.isSettingsEnabled) { fileURL in
Task {
await viewModel.storePrivateKey(at: fileURL)
Section {
Picker(L10n.Settings.Github.runnerScope, selection: $viewModel.runnerScope) {
ForEach(RunnerScope.allCases, id: \.self) { scope in
Text(scope.rawValue.capitalized)
}
}
.pickerStyle(.segmented)

switch viewModel.runnerScope {
case .organization:
TextField(L10n.Settings.Github.organizationName, text: $viewModel.organizationName)
.disabled(!viewModel.isSettingsEnabled)
case .repo:
TextField(L10n.Settings.Github.ownerName, text: $viewModel.ownerName)
.disabled(!viewModel.isSettingsEnabled)
TextField(L10n.Settings.Github.repositoryName, text: $viewModel.repositoryName)
.disabled(!viewModel.isSettingsEnabled)
}
}
Button {
viewModel.openCreateApp()
} label: {
Text(L10n.Settings.Github.createApp)
Section {
TextField(L10n.Settings.Github.appId, text: $viewModel.appId)
.disabled(!viewModel.isSettingsEnabled)
GitHubPrivateKeyPicker(filename: $viewModel.privateKeyName, isEnabled: viewModel.isSettingsEnabled) { fileURL in
Task {
await viewModel.storePrivateKey(at: fileURL)
}
}
Button {
viewModel.openCreateApp()
} label: {
Text(L10n.Settings.Github.createApp)
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.padding()
.formStyle(.grouped)
.task {
await viewModel.loadCredentials()
}
Expand Down
Loading

0 comments on commit d173953

Please sign in to comment.