Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enterprise hosting support #53

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import Foundation

public protocol GitHubCredentialsStore: AnyObject {
var selfHostedURL: URL? { get async }
var organizationName: String? { get async }
var repositoryName: String? { get async }
var ownerName: String? { get async }
var enterpriseName: String? { get async }
var appId: String? { get async }
var privateKey: Data? { get async }
func setSelfHostedURL(_ selfHostedURL: URL?) async
func setOrganizationName(_ organizationName: String?) async
func setRepository(_ repositoryName: String?, withOwner ownerName: String?) async
func setEnterpriseName(_ enterpriseName: String?) async
func setAppID(_ appID: String?) async
func setPrivateKey(_ privateKeyData: Data?) async
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,24 @@ import RSAPrivateKey

public final actor GitHubCredentialsStoreKeychain: GitHubCredentialsStore {
private enum PasswordAccount {
static let selfHostedURL = "github.credentials.selfHostedURL"
static let organizationName = "github.credentials.organizationName"
static let repositoryName = "github.credentials.repositoryName"
static let ownerName = "github.credentials.ownerName"
static let enterpriseName = "github.credentials.enterpriseName"
static let appId = "github.credentials.appId"
}

private enum KeyTag {
static let privateKey = "github.credentials.privateKey"
}

public var selfHostedURL: URL? {
get async {
return await keychain.password(forAccount: PasswordAccount.selfHostedURL, belongingToService: serviceName)
.flatMap(URL.init(string:))
}
}
public var organizationName: String? {
get async {
return await keychain.password(forAccount: PasswordAccount.organizationName, belongingToService: serviceName)
Expand All @@ -30,6 +38,11 @@ public final actor GitHubCredentialsStoreKeychain: GitHubCredentialsStore {
return await keychain.password(forAccount: PasswordAccount.ownerName, belongingToService: serviceName)
}
}
public var enterpriseName: String? {
get async {
return await keychain.password(forAccount: PasswordAccount.enterpriseName, belongingToService: serviceName)
}
}
public var appId: String? {
get async {
return await keychain.password(forAccount: PasswordAccount.appId, belongingToService: serviceName)
Expand All @@ -49,6 +62,14 @@ public final actor GitHubCredentialsStoreKeychain: GitHubCredentialsStore {
self.serviceName = serviceName
}

public func setSelfHostedURL(_ selfHostedURL: URL?) async {
if let selfHostedURL {
_ = await keychain.setPassword(selfHostedURL.absoluteString, forAccount: PasswordAccount.selfHostedURL, belongingToService: serviceName)
} else {
await keychain.removePassword(forAccount: PasswordAccount.selfHostedURL, belongingToService: serviceName)
}
}

public func setOrganizationName(_ organizationName: String?) async {
if let organizationName {
_ = await keychain.setPassword(organizationName, forAccount: PasswordAccount.organizationName, belongingToService: serviceName)
Expand All @@ -69,6 +90,14 @@ public final actor GitHubCredentialsStoreKeychain: GitHubCredentialsStore {
}
}

public func setEnterpriseName(_ enterpriseName: String?) async {
if let enterpriseName {
_ = await keychain.setPassword(enterpriseName, forAccount: PasswordAccount.enterpriseName, belongingToService: serviceName)
} else {
await keychain.removePassword(forAccount: PasswordAccount.enterpriseName, belongingToService: serviceName)
}
}

public func setAppID(_ appID: String?) async {
if let appID {
_ = await keychain.setPassword(appID, forAccount: PasswordAccount.appId, belongingToService: serviceName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import Foundation
public enum GitHubRunnerScope: String, CaseIterable {
case organization
case repo
case enterpriseServer
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

public enum GitHubServiceVersion {
case dotCom
case enterprise(URL)
Comment on lines +4 to +5
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These enum cases don't seem to be used. Can we remove them and simplify this enum?


public enum Kind: String, CaseIterable {
case dotCom
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if there's an official name for "standard GitHub" that we can use or if that's just "GitHub.com".

case enterprise
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ private enum GitHubServiceLiveError: LocalizedError {
case organizationNameUnavailable
case repositoryNameUnavailable
case repositoryOwnerNameUnavailable
case enterpriseNameUnavailable
case appIDUnavailable
case privateKeyUnavailable
case appIsNotInstalled
Expand All @@ -21,6 +22,8 @@ private enum GitHubServiceLiveError: LocalizedError {
return "The repository name is not available"
case .repositoryOwnerNameUnavailable:
return "The repository owner name is not available"
case .enterpriseNameUnavailable:
return "The enterprise name is not available"
case .appIDUnavailable:
return "The app ID is not available"
case .privateKeyUnavailable:
Expand All @@ -34,7 +37,11 @@ private enum GitHubServiceLiveError: LocalizedError {
}

public final class GitHubServiceLive: GitHubService {
private let baseURL = URL(string: "https://api.github.com")!
private var baseURL: URL {
get async {
await credentialsStore.selfHostedURL ?? .gitHub
}
}
private let credentialsStore: GitHubCredentialsStore
private let networkingService: NetworkingService

Expand All @@ -47,7 +54,7 @@ public final class GitHubServiceLive: GitHubService {
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")
let url = await baseURL.appending(path: "/app/installations/\(installationID)/access_tokens")
guard let privateKey = await credentialsStore.privateKey else {
throw GitHubServiceLiveError.privateKeyUnavailable
}
Expand Down Expand Up @@ -86,7 +93,7 @@ public final class GitHubServiceLive: GitHubService {

private extension GitHubServiceLive {
private func getAppInstallation(runnerScope: GitHubRunnerScope) async throws -> GitHubAppInstallation {
let url = baseURL.appending(path: "/app/installations")
let url = await 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)
Expand All @@ -108,6 +115,10 @@ private extension GitHubServiceLive {
}
}

private extension URL {
static let gitHub = URL(string: "https://api.github.com")!
}

private extension URLRequest {
func addingBearerToken(_ token: String) -> URLRequest {
var mutableRequest = self
Expand All @@ -133,6 +144,11 @@ private extension GitHubRunnerScope {
}

return "/repos/\(ownerName)/\(repositoryName)/actions/runners/registration-token"
case .enterpriseServer:
guard let enterpriseName = await credentialsStore.enterpriseName else {
throw GitHubServiceLiveError.enterpriseNameUnavailable
}
return "/enterprises/\(enterpriseName)/actions/runners/registration-token"
}
}

Expand All @@ -152,6 +168,11 @@ private extension GitHubRunnerScope {
}

return "/repos/\(ownerName)/\(repositoryName)/actions/runners/downloads"
case .enterpriseServer:
guard let enterpriseName = await credentialsStore.enterpriseName else {
throw GitHubServiceLiveError.enterpriseNameUnavailable
}
return "/enterprises/\(enterpriseName)/actions/runners/downloads"
}
}

Expand All @@ -161,6 +182,8 @@ private extension GitHubRunnerScope {
return await credentialsStore.organizationName
case .repo:
return await credentialsStore.ownerName
case .enterpriseServer:
return await credentialsStore.enterpriseName
}
}
}
3 changes: 3 additions & 0 deletions Packages/Settings/Sources/SettingsStore/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public final class SettingsStore: ObservableObject {
static let gitHubRunnerLabels = "gitHubRunnerLabels"
static let gitHubRunnerGroup = "gitHubRunnerGroup"
static let githubRunnerScope = "githubRunnerScope"
static let githubServiceVersion = "githubServiceVersion"
}

@AppStorage(AppStorageKey.applicationUIMode)
Expand All @@ -34,6 +35,8 @@ public final class SettingsStore: ObservableObject {
public var gitHubRunnerGroup = ""
@AppStorage(AppStorageKey.githubRunnerScope)
public var githubRunnerScope: GitHubRunnerScope = .organization
@AppStorage(AppStorageKey.githubServiceVersion)
public var githubServiceVersion: GitHubServiceVersion.Kind = .dotCom

public init() {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ struct GitHubPrivateKeyPicker: View {
L10n.Settings.Github.PrivateKey.Scopes.organization
case .repo:
L10n.Settings.Github.PrivateKey.Scopes.repository
case .enterpriseServer:
"Check permissions: `manage_runners:enterprise`"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text needs to be localized and formatted similar to the other texts listing required permissions.

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ struct GitHubSettingsView: View {
var body: some View {
Form {
Section {
Picker("Version", selection: $viewModel.version) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text needs to be localized and accessed through the L10n constant.

ForEach(GitHubServiceVersion.Kind.allCases, id: \.self) { scope in
Text(scope.title)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to turn this picker into a segmented control to align with the Runner Scope setting?

Suggested change
}
}
.pickerStyle(.segmented)


if viewModel.version == .enterprise {
TextField(
"Self hosted URL",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text needs to be localized and accessed through the L10n constant.

text: $viewModel.selfHostedRaw,
prompt: Text("Github Service raw value")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text needs to be localized and accessed through the L10n constant.

)
.disabled(!viewModel.isSettingsEnabled)
}

Picker(L10n.Settings.Github.runnerScope, selection: $viewModel.runnerScope) {
ForEach(GitHubRunnerScope.allCases, id: \.self) { scope in
Text(scope.title)
Expand Down Expand Up @@ -41,6 +56,13 @@ struct GitHubSettingsView: View {
prompt: Text(L10n.Settings.Github.RepositoryName.prompt)
)
.disabled(!viewModel.isSettingsEnabled)
case .enterpriseServer:
TextField(
"Enterprise name",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text needs to be localized and accessed through the L10n constant.

text: $viewModel.enterpriseName,
prompt: Text("Acme Enterprise")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text needs to be localized and accessed through the L10n constant.

)
.disabled(!viewModel.isSettingsEnabled)
}
}
Section {
Expand Down Expand Up @@ -77,6 +99,19 @@ private extension GitHubRunnerScope {
L10n.Settings.RunnerScope.organization
case .repo:
L10n.Settings.RunnerScope.repository
case .enterpriseServer:
"Enterprise"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text needs to be localized and accessed through the L10n constant.

}
}
}

private extension GitHubServiceVersion.Kind {
var title: String {
switch self {
case .dotCom:
return "github.com"
case .enterprise:
return "github self hosted"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unfamiliar with enterprise GitHub setups and self-hosting so please correct me if I'm wrong but the enterprise enum case seems to refer to GitHub Enterprise Server. Would it make sense to rename the case and the title to be more explicit about this and avoid confusion with the enterprise runner scope?

}
}
}
Loading