Skip to content

Commit 51b674b

Browse files
authored
Merge pull request #5 from bittrance/github-app-repo-access
Access private GitHub repos using GitHub app credentials
2 parents f515310 + 62addf2 commit 51b674b

File tree

10 files changed

+1068
-874
lines changed

10 files changed

+1068
-874
lines changed

Cargo.lock

Lines changed: 755 additions & 785 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ edition = "2021"
99

1010
[dependencies]
1111
clap = { version = "4.1.4", features = ["derive"] }
12-
gix = { version = "0.55.2", features = ["default", "blocking-network-client", "blocking-http-transport-reqwest-native-tls", "serde"] }
12+
gix = { git = "https://github.com/Byron/gitoxide", rev = "281fda06", features = ["default", "blocking-network-client", "blocking-http-transport-reqwest-native-tls", "serde"] }
1313
humantime = "2.1.0"
1414
jwt-simple = "0.11.7"
1515
reqwest = { version = "0.11.20", default-features = false, features = ["blocking", "default-tls", "serde_json", "gzip", "deflate", "json"] }

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ The plan forward, roughly in falling priority:
1919
- [x] changed task config should override state loaded from disk
2020
- [x] docker packaging
2121
- [ ] readme with design and deployment options
22+
- [ ] branch patterns allows a task to react to changes on many branches
2223
- [ ] intelligent gitconfig handling
2324
- [ ] allow git commands in workdir (but note that this means two tasks can no longer point to the same repo without additional changeas)
2425
- [ ] useful logging (log level, json)
2526
- [ ] lock state so that many kitops instances can collaborate
2627
- [ ] support Amazon S3 as state store
2728
- [ ] support Azure Blob storage as state store
28-
- [ ] GitHub app for checking out private repo
29+
- [x] GitHub app for checking out private repo

src/errors.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ pub enum GitOpsError {
5454
GitHubNetworkError(reqwest::Error),
5555
#[error("GitHub App is installed but does not have write permissions for commit statuses")]
5656
GitHubPermissionsError,
57+
#[cfg(test)]
58+
#[error("Test error")]
59+
TestError,
5760
}
5861

5962
impl GitOpsError {

src/git.rs

Lines changed: 135 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1-
use std::{path::Path, sync::atomic::AtomicBool, thread::scope, time::Instant};
1+
use std::{
2+
cell::RefCell,
3+
path::Path,
4+
sync::{atomic::AtomicBool, Arc},
5+
thread::scope,
6+
time::Instant,
7+
};
28

39
use gix::{
410
bstr::{BString, ByteSlice},
5-
config::tree::User,
6-
prelude::FindExt,
11+
config::tree::{
12+
gitoxide::{self, Credentials},
13+
Key, User,
14+
},
15+
objs::Data,
16+
odb::{store::Handle, Cache, Store},
17+
oid,
718
progress::Discard,
819
refs::{
920
transaction::{Change, LogChange, RefEdit},
@@ -19,17 +30,12 @@ use crate::{errors::GitOpsError, opts::CliOptions, utils::Watchdog};
1930
#[derive(Clone, Deserialize)]
2031
pub struct GitConfig {
2132
#[serde(deserialize_with = "url_from_string")]
22-
url: Url,
33+
pub url: Arc<Box<dyn UrlProvider>>,
2334
#[serde(default = "GitConfig::default_branch")]
2435
branch: String,
2536
}
2637

2738
impl GitConfig {
28-
pub fn safe_url(&self) -> String {
29-
// TODO Change to whitelist of allowed characters
30-
self.url.to_bstring().to_string().replace(['/', ':'], "_")
31-
}
32-
3339
pub fn default_branch() -> String {
3440
"main".to_owned()
3541
}
@@ -41,18 +47,45 @@ impl TryFrom<&CliOptions> for GitConfig {
4147
fn try_from(opts: &CliOptions) -> Result<Self, Self::Error> {
4248
let url = Url::try_from(opts.url.clone().unwrap()).map_err(GitOpsError::InvalidUrl)?;
4349
Ok(GitConfig {
44-
url,
50+
url: Arc::new(Box::new(DefaultUrlProvider { url })),
4551
branch: opts.branch.clone(),
4652
})
4753
}
4854
}
4955

50-
fn url_from_string<'de, D>(deserializer: D) -> Result<Url, D::Error>
56+
fn url_from_string<'de, D>(deserializer: D) -> Result<Arc<Box<dyn UrlProvider>>, D::Error>
5157
where
5258
D: Deserializer<'de>,
5359
{
5460
let s: String = Deserialize::deserialize(deserializer)?;
55-
Url::try_from(s).map_err(serde::de::Error::custom)
61+
Ok(Arc::new(Box::new(DefaultUrlProvider {
62+
url: Url::try_from(s).map_err(serde::de::Error::custom)?,
63+
})))
64+
}
65+
66+
pub trait UrlProvider: Send + Sync {
67+
fn url(&self) -> &Url;
68+
fn auth_url(&self) -> Result<Url, GitOpsError>;
69+
70+
fn safe_url(&self) -> String {
71+
// TODO Change to whitelist of allowed characters
72+
self.url().to_bstring().to_string().replace(['/', ':'], "_")
73+
}
74+
}
75+
76+
#[derive(Clone)]
77+
pub struct DefaultUrlProvider {
78+
url: Url,
79+
}
80+
81+
impl UrlProvider for DefaultUrlProvider {
82+
fn url(&self) -> &Url {
83+
&self.url
84+
}
85+
86+
fn auth_url(&self) -> Result<Url, GitOpsError> {
87+
Ok(self.url.clone())
88+
}
5689
}
5790

5891
fn clone_repo(
@@ -63,13 +96,18 @@ fn clone_repo(
6396
let watchdog = Watchdog::new(deadline);
6497
scope(|s| {
6598
s.spawn(watchdog.runner());
66-
let repo = gix::prepare_clone(config.url.clone(), target)
67-
.unwrap()
68-
.fetch_only(Discard, &watchdog)
69-
.map(|(r, _)| r)
70-
.map_err(GitOpsError::InitRepo);
99+
let maybe_repo = config.url.auth_url().and_then(|url| {
100+
gix::prepare_clone(url, target)
101+
.unwrap()
102+
.with_in_memory_config_overrides(vec![gitoxide::Credentials::TERMINAL_PROMPT
103+
.validated_assignment_fmt(&false)
104+
.unwrap()])
105+
.fetch_only(Discard, &watchdog)
106+
.map(|(r, _)| r)
107+
.map_err(GitOpsError::InitRepo)
108+
});
71109
watchdog.cancel();
72-
repo
110+
maybe_repo
73111
})
74112
}
75113

@@ -78,7 +116,7 @@ fn perform_fetch(
78116
config: &GitConfig,
79117
cancel: &AtomicBool,
80118
) -> Result<Outcome, Box<dyn std::error::Error + Send + Sync>> {
81-
repo.remote_at(config.url.clone())
119+
repo.remote_at(config.url.auth_url()?)
82120
.unwrap()
83121
.with_refspecs([BString::from(config.branch.clone())], Direction::Fetch)
84122
.unwrap()
@@ -122,6 +160,41 @@ fn fetch_repo(repo: &Repository, config: &GitConfig, deadline: Instant) -> Resul
122160
Ok(())
123161
}
124162

163+
#[derive(Clone)]
164+
struct MaybeFind<Allow: Clone, Find: Clone> {
165+
allow: std::cell::RefCell<Allow>,
166+
objects: Find,
167+
}
168+
169+
impl<Allow, Find> gix::prelude::Find for MaybeFind<Allow, Find>
170+
where
171+
Allow: FnMut(&oid) -> bool + Send + Clone,
172+
Find: gix::prelude::Find + Send + Clone,
173+
{
174+
fn try_find<'a>(
175+
&self,
176+
id: &oid,
177+
buf: &'a mut Vec<u8>,
178+
) -> Result<Option<Data<'a>>, Box<dyn std::error::Error + Send + Sync>> {
179+
if (self.allow.borrow_mut())(id) {
180+
self.objects.try_find(id, buf)
181+
} else {
182+
Ok(None)
183+
}
184+
}
185+
}
186+
187+
fn can_we_please_have_impl_in_type_alias_already() -> impl FnMut(&oid) -> bool + Send + Clone {
188+
|_| true
189+
}
190+
191+
fn make_finder(odb: Cache<Handle<Arc<Store>>>) -> impl gix::prelude::Find + Send + Clone {
192+
MaybeFind {
193+
allow: RefCell::new(can_we_please_have_impl_in_type_alias_already()),
194+
objects: odb,
195+
}
196+
}
197+
125198
fn checkout_worktree(
126199
repo: &Repository,
127200
branch: &str,
@@ -142,10 +215,11 @@ fn checkout_worktree(
142215
.unwrap();
143216
let (mut state, _) = repo.index_from_tree(&tree_id).unwrap().into_parts();
144217
let odb = repo.objects.clone().into_arc().unwrap();
218+
let db = make_finder(odb);
145219
let _outcome = gix::worktree::state::checkout(
146220
&mut state,
147221
workdir,
148-
move |oid, buf| odb.find_blob(oid, buf),
222+
db,
149223
&Discard,
150224
&Discard,
151225
&AtomicBool::default(),
@@ -173,6 +247,9 @@ where
173247
let mut gitconfig = repo.config_snapshot_mut();
174248
gitconfig.set_value(&User::NAME, "kitops").unwrap();
175249
gitconfig.set_value(&User::EMAIL, "none").unwrap();
250+
gitconfig
251+
.set_value(&Credentials::TERMINAL_PROMPT, "false")
252+
.unwrap();
176253
gitconfig.commit().unwrap();
177254
fetch_repo(&repo, config, deadline)?;
178255
repo
@@ -181,3 +258,41 @@ where
181258
};
182259
checkout_worktree(&repo, &config.branch, workdir)
183260
}
261+
262+
#[cfg(test)]
263+
mod tests {
264+
use std::{
265+
sync::Arc,
266+
time::{Duration, Instant},
267+
};
268+
269+
use crate::{
270+
errors::GitOpsError,
271+
git::{clone_repo, fetch_repo, GitConfig},
272+
testutils::TestUrl,
273+
};
274+
275+
#[test]
276+
fn clone_with_bad_url() {
277+
let config = GitConfig {
278+
url: Arc::new(Box::new(TestUrl::new(Some(GitOpsError::TestError)))),
279+
branch: "main".into(),
280+
};
281+
let deadline = Instant::now() + Duration::from_secs(61); // Fail tests that time out
282+
let target = tempfile::tempdir().unwrap();
283+
let result = clone_repo(&config, deadline, target.path());
284+
assert!(matches!(result, Err(GitOpsError::TestError)));
285+
}
286+
287+
#[test]
288+
fn fetch_with_bad_url() {
289+
let repo = gix::open(".").unwrap();
290+
let config = GitConfig {
291+
url: Arc::new(Box::new(TestUrl::new(Some(GitOpsError::TestError)))),
292+
branch: "main".into(),
293+
};
294+
let deadline = Instant::now() + Duration::from_secs(61); // Fail tests that time out
295+
let result = fetch_repo(&repo, &config, deadline);
296+
assert!(result.is_err());
297+
}
298+
}

src/opts.rs

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ use crate::{
88
receiver::logging_receiver,
99
store::{FileStore, Store},
1010
task::{
11-
github::github_watcher, gixworkload::GitWorkload, scheduled::ScheduledTask, GitTaskConfig,
11+
github::{github_watcher, GithubUrlProvider},
12+
gixworkload::GitWorkload,
13+
scheduled::ScheduledTask,
14+
GitTaskConfig,
1215
},
1316
};
1417

@@ -37,18 +40,15 @@ pub struct CliOptions {
3740
/// Environment variable for action
3841
#[clap(long)]
3942
pub environment: Vec<String>,
40-
/// GitHub App ID
43+
/// GitHub App ID for authentication with private repos and commit status updates
4144
#[clap(long)]
4245
pub github_app_id: Option<String>,
4346
/// GitHub App private key file
4447
#[clap(long)]
4548
pub github_private_key_file: Option<PathBuf>,
46-
/// Update GitHub commit status on this repo
49+
/// Turn on updating GitHub commit status updates with this context (requires auth flags)
4750
#[clap(long)]
48-
pub github_repo_slug: Option<String>,
49-
/// Use this context when updating GitHub commit status
50-
#[clap(long)]
51-
pub github_context: Option<String>,
51+
pub github_status_context: Option<String>,
5252
/// Check repo for changes at this interval (e.g. 1h, 30m, 10s)
5353
#[arg(long, value_parser = humantime::parse_duration)]
5454
pub interval: Option<Duration>,
@@ -97,10 +97,18 @@ struct ConfigFile {
9797
}
9898

9999
fn into_task(mut config: GitTaskConfig, opts: &CliOptions) -> ScheduledTask<GitWorkload> {
100-
let notify_config = config.notify.take();
100+
let github = config.github.take();
101+
let mut slug = None; // TODO Yuck!
102+
if let Some(ref github) = github {
103+
let provider = GithubUrlProvider::new(config.git.url.url().clone(), github);
104+
slug = Some(provider.repo_slug());
105+
config.upgrade_url_provider(|_| provider);
106+
}
101107
let mut work = GitWorkload::from_config(config, opts);
102-
if let Some(notify_config) = notify_config {
103-
work.watch(github_watcher(notify_config));
108+
if let Some(github) = github {
109+
if github.status_context.is_some() {
110+
work.watch(github_watcher(slug.unwrap(), github));
111+
}
104112
}
105113
let (tx, rx) = channel();
106114
work.watch(move |event| {

0 commit comments

Comments
 (0)