Skip to content

Commit f85102d

Browse files
committed
feat: separate product level configuration
1 parent fbf681f commit f85102d

File tree

3 files changed

+147
-69
lines changed

3 files changed

+147
-69
lines changed

rust/patchable/README.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,36 @@ For more details, run `cargo patchable --help`.
3131

3232
## Configuration
3333

34-
Patchable stores a per-version file in `docker-images/<PRODUCT>/stackable/patches/<VERSION>/patchable.toml`.
35-
It currently recognizes the following keys:
34+
Patchable uses a two-level configuration system:
3635

36+
1. A product-level config file at `docker-images/<PRODUCT>/stackable/patches/patchable.toml`
37+
2. A version-level config file at `docker-images/<PRODUCT>/stackable/patches/<VERSION>/patchable.toml`
38+
39+
The product-level config typically contains:
3740
- `upstream` - the URL of the upstream repository (such as `https://github.com/apache/druid.git`)
38-
- `base` - the commit hash of the upstream base commit (such as `7cffb81a8e124d5f218f9af16ad685acf5e9c67c`)
41+
- `mirror` - optional URL of a mirror repository (such as `https://github.com/stackabletech/druid.git`)
3942

40-
### Template
43+
The version-level config typically contains:
44+
- `base` - the commit hash of the upstream base commit
4145

42-
Instead of creating this manually, run `patchable init`:
46+
Fields in the version-level config override those in the product-level config if both are specified.
47+
48+
### Template
4349

50+
If you're adding a completely new product, you need to create the product-level config once:
4451
```toml
45-
cargo patchable init druid 28.0.0 --upstream=https://github.com/apache/druid.git --base=druid-28.0.0
52+
# docker-images/druid/stackable/patches/patchable.toml
53+
upstream = "https://github.com/apache/druid.git"
54+
mirror = "https://github.com/stackabletech/druid.git"
4655
```
4756

57+
If you just want to add a new version, simply initiatilize it with patchable:
58+
```
59+
cargo patchable init druid 28.0.0 --base=druid-28.0.0 --mirror
60+
```
61+
62+
Using the `--mirror` flag will push the base ref to the mirror URL specified in the product-level config.
63+
4864
## Glossary
4965

5066
- Images repo/directory - The checkout of stackabletech/docker-images

rust/patchable/src/main.rs

Lines changed: 115 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ struct ProductVersion {
2626

2727
#[derive(Deserialize, Serialize)]
2828
struct ProductVersionConfig {
29-
upstream: String,
30-
#[serde(with = "utils::oid_serde")]
31-
base: Oid,
29+
upstream: Option<String>,
30+
#[serde(default, with = "utils::option_oid_serde")]
31+
base: Option<Oid>,
3232
mirror: Option<String>,
3333
}
3434

@@ -39,15 +39,77 @@ struct ProductVersionContext {
3939

4040
impl ProductVersionContext {
4141
fn load_config(&self) -> Result<ProductVersionConfig> {
42-
let path = &self.config_path();
43-
tracing::info!(
44-
config.path = ?path,
45-
"loading config"
46-
);
47-
toml::from_str::<ProductVersionConfig>(
48-
&std::fs::read_to_string(path).context(LoadConfigSnafu { path })?,
49-
)
50-
.context(ParseConfigSnafu { path })
42+
let version_config_path = &self.version_config_path();
43+
let product_config_path = &self.product_config_path();
44+
45+
// Load product-level config (required)
46+
let product_config: ProductVersionConfig = {
47+
tracing::info!(
48+
config.path = ?product_config_path,
49+
"loading product-level config"
50+
);
51+
toml::from_str::<ProductVersionConfig>(
52+
&std::fs::read_to_string(product_config_path).context(LoadConfigSnafu {
53+
path: product_config_path,
54+
})?,
55+
)
56+
.context(ParseConfigSnafu {
57+
path: product_config_path,
58+
})?
59+
};
60+
61+
// Load version-level config (optional)
62+
let version_config: Option<ProductVersionConfig> =
63+
match std::fs::read_to_string(version_config_path) {
64+
Ok(s) => {
65+
tracing::info!(
66+
config.path = ?version_config_path,
67+
"loading version-level config"
68+
);
69+
Some(
70+
toml::from_str::<ProductVersionConfig>(&s).context(ParseConfigSnafu {
71+
path: version_config_path,
72+
})?,
73+
)
74+
}
75+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
76+
Err(e) => {
77+
return Err(e).context(LoadConfigSnafu {
78+
path: version_config_path,
79+
})
80+
}
81+
};
82+
// Merge: version-level overrides product-level, validate required fields afterwards
83+
let merged = ProductVersionConfig {
84+
upstream: version_config
85+
.as_ref()
86+
.and_then(|c| c.upstream.clone())
87+
.or(product_config.upstream.clone())
88+
.or_else(|| {
89+
Err(ValidateConfigSnafu {
90+
reason: "upstream is required".to_string(),
91+
paths: vec![product_config_path, version_config_path],
92+
})
93+
.ok()
94+
}),
95+
base: version_config
96+
.as_ref()
97+
.and_then(|c| c.base)
98+
.or(product_config.base)
99+
.or_else(|| {
100+
Err(ValidateConfigSnafu {
101+
reason: "base commit is required".to_string(),
102+
paths: vec![product_config_path, version_config_path],
103+
})
104+
.ok()
105+
}),
106+
mirror: version_config
107+
.as_ref()
108+
.and_then(|c| c.mirror.clone())
109+
.or(product_config.mirror.clone()),
110+
};
111+
112+
Ok(merged)
51113
}
52114

53115
/// The root directory for files related to the product (across all versions).
@@ -63,10 +125,15 @@ impl ProductVersionContext {
63125
}
64126

65127
/// The patchable configuration file for the product version.
66-
fn config_path(&self) -> PathBuf {
128+
fn version_config_path(&self) -> PathBuf {
67129
self.patch_dir().join("patchable.toml")
68130
}
69131

132+
/// The product-level patchable configuration file
133+
fn product_config_path(&self) -> PathBuf {
134+
self.product_dir().join("stackable/patches/patchable.toml")
135+
}
136+
70137
/// The directory containing all ephemeral data used by patchable for the product (across all versions).
71138
///
72139
/// Should be gitignored, and can safely be deleted as long as all relevant versions have been `patchable export`ed.
@@ -141,20 +208,11 @@ enum Cmd {
141208
#[clap(flatten)]
142209
pv: ProductVersion,
143210

144-
/// The upstream URL (such as https://github.com/apache/druid.git)
145-
#[clap(long)]
146-
upstream: String,
147-
148211
/// The upstream commit-ish (such as druid-28.0.0) that the patch series applies to
149212
///
150213
/// Refs (such as tags and branches) will be resolved to commit IDs.
151214
#[clap(long)]
152215
base: String,
153-
154-
/// Assume a mirror exists at stackabletech/<repo_name> and push the base ref to it.
155-
/// The mirror URL will be stored in patchable.toml, and used instead of the original upstream.
156-
#[clap(long)]
157-
mirror: bool,
158216
},
159217

160218
/// Shows the patch directory for a given product version
@@ -190,6 +248,8 @@ pub enum Error {
190248
source: toml::de::Error,
191249
path: PathBuf,
192250
},
251+
#[snafu(display("failed to validate config: {reason:?} (used config files: {paths:?})"))]
252+
ValidateConfig { reason: String, paths: Vec<PathBuf> },
193253
#[snafu(display("failed to serialize config"))]
194254
SerializeConfig { source: toml::ser::Error },
195255
#[snafu(display("failed to create patch dir at {path:?}"))]
@@ -203,8 +263,6 @@ pub enum Error {
203263
path: PathBuf,
204264
},
205265

206-
#[snafu(display("failed to parse upstream URL {url:?} to extract repository name"))]
207-
ParseUpstreamUrl { url: String },
208266
#[snafu(display("failed to add temporary mirror remote for {url:?}"))]
209267
AddMirrorRemote { source: git2::Error, url: String },
210268
#[snafu(display("failed to push commit {commit} (as {refspec}) to mirror {url:?}"))]
@@ -302,10 +360,14 @@ fn main() -> Result<()> {
302360
let product_repo = repo::ensure_bare_repo(&product_repo_root)
303361
.context(OpenProductRepoForCheckoutSnafu)?;
304362

363+
// Presence of `base` and `upstream` already validated by load_config, so unwrap is safe here
305364
let base_commit = repo::resolve_and_fetch_commitish(
306365
&product_repo,
307-
&config.base.to_string(),
308-
config.mirror.as_deref().unwrap_or(&config.upstream),
366+
&config.base.unwrap().to_string(),
367+
config
368+
.mirror
369+
.as_deref()
370+
.unwrap_or(config.upstream.as_ref().unwrap()),
309371
)
310372
.context(FetchBaseCommitSnafu)?;
311373
let base_branch = ctx.base_branch();
@@ -378,7 +440,8 @@ fn main() -> Result<()> {
378440
path: product_worktree_root,
379441
})?;
380442

381-
let base_commit = config.base;
443+
// Presence of `base` already validated by load_config, so unwrap is safe here
444+
let base_commit = config.base.unwrap();
382445
let original_leaf_commit = product_version_repo
383446
.head()
384447
.and_then(|c| c.peel_to_commit())
@@ -417,9 +480,7 @@ fn main() -> Result<()> {
417480

418481
Cmd::Init {
419482
pv,
420-
upstream,
421483
base,
422-
mirror,
423484
} => {
424485
let ctx = ProductVersionContext {
425486
pv,
@@ -434,77 +495,75 @@ fn main() -> Result<()> {
434495
.in_scope(|| repo::ensure_bare_repo(&product_repo_root))
435496
.context(OpenProductRepoForCheckoutSnafu)?;
436497

498+
let config = ctx.load_config()?;
499+
// Presence of `upstream` already validated by load_config, so unwrap is safe here
500+
let upstream = config.upstream.unwrap();
501+
437502
// --base can be a reference, but patchable.toml should always have a resolved commit id,
438503
// so that it cannot be changed under our feet (without us knowing so, anyway...).
439504
tracing::info!(?base, "resolving base commit-ish");
440-
let base_commit = repo::resolve_and_fetch_commitish(&product_repo, &base, &upstream).context(FetchBaseCommitSnafu)?;
441-
let mut upstream_mirror = None;
442-
443-
if mirror {
444-
// Parse e.g. "https://github.com/apache/druid.git" into "druid"
445-
let repo_name = upstream.split('/').last().map(|repo| repo.trim_end_matches(".git")).filter(|name| !name.is_empty()).context(ParseUpstreamUrlSnafu { url: &upstream })?;
446-
447-
let mirror_url = format!("https://github.com/stackabletech/{repo_name}.git");
448-
tracing::info!(mirror_url, "using mirror repository");
505+
let base_commit = repo::resolve_and_fetch_commitish(&product_repo, &base, &upstream)
506+
.context(FetchBaseCommitSnafu)?;
449507

508+
if let Some(mirror_url) = config.mirror {
450509
// Add mirror remote
451-
let mut mirror_remote = product_repo
452-
.remote_anonymous(&mirror_url)
453-
.context(AddMirrorRemoteSnafu { url: mirror_url.clone() })?;
510+
let mut mirror_remote =
511+
product_repo
512+
.remote_anonymous(&mirror_url)
513+
.context(AddMirrorRemoteSnafu {
514+
url: mirror_url.clone(),
515+
})?;
454516

455517
// Push the base commit to the mirror
456518
tracing::info!(commit = %base_commit, base = base, url = mirror_url, "pushing commit to mirror");
457519
let mut callbacks = git2::RemoteCallbacks::new();
458520
callbacks.credentials(|url, username_from_url, _allowed_types| {
459521
git2::Cred::credential_helper(
460-
&git2::Config::open_default().unwrap(), // Use default git config
522+
&git2::Config::open_default()
523+
.expect("failed to open default Git configuration"), // Use default git config,
461524
url,
462525
username_from_url,
463526
)
464527
});
465528

466529
// Add progress tracking for push operation
467-
let (span_push, mut quant_push) = utils::setup_progress_tracking(tracing::info_span!("pushing"));
530+
let (span_push, mut quant_push) =
531+
utils::setup_progress_tracking(tracing::info_span!("pushing"));
468532
let _ = span_push.enter();
469533

470534
callbacks.push_transfer_progress(move |current, total, _| {
471535
if total > 0 {
472-
quant_push.update_span_progress(
473-
current,
474-
total,
475-
&span_push,
476-
);
536+
quant_push.update_span_progress(current, total, &span_push);
477537
}
478538
});
479539

480540
let mut push_options = git2::PushOptions::new();
481541
push_options.remote_callbacks(callbacks);
482542

543+
// Always push the commit as a Git tag named like the value of `base`
483544
let refspec = format!("{base_commit}:refs/tags/{base}");
484545
tracing::info!(refspec, "constructed push refspec");
485546

486547
mirror_remote
487548
.push(&[&refspec], Some(&mut push_options))
488549
.context(PushToMirrorSnafu {
489-
url: &mirror_url,
550+
url: mirror_url.clone(),
490551
refspec: &refspec,
491552
commit: base_commit,
492553
})?;
493554

494555
tracing::info!("successfully pushed base ref to mirror");
495-
496-
upstream_mirror = Some(mirror_url);
497556
};
498557

499558
tracing::info!(?base, base.commit = ?base_commit, "resolved base commit");
500559

501-
tracing::info!("saving configuration");
560+
tracing::info!("saving version-level configuration");
502561
let config = ProductVersionConfig {
503-
upstream,
504-
mirror: upstream_mirror,
505-
base: base_commit,
562+
upstream: None,
563+
mirror: None,
564+
base: Some(base_commit),
506565
};
507-
let config_path = ctx.config_path();
566+
let config_path = ctx.version_config_path();
508567
if let Some(config_dir) = config_path.parent() {
509568
std::fs::create_dir_all(config_dir)
510569
.context(CreatePatchDirSnafu { path: config_dir })?;

rust/patchable/src/utils.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,19 +72,22 @@ pub fn raw_git_cmd(repo: &Repository) -> std::process::Command {
7272
cmd
7373
}
7474

75-
/// Implements (equivalents of) the [`serde`] traits over [`git2::Oid`].
75+
/// Implements (equivalents of) the [`serde`] traits over [`Option<git2::Oid>`].
7676
///
7777
/// For use with `#[serde(with = ...)]`.
78-
pub mod oid_serde {
78+
pub mod option_oid_serde {
7979
use git2::Oid;
8080
use serde::{Deserialize, Deserializer, Serialize, Serializer};
8181

82-
pub fn serialize<S: Serializer>(value: &Oid, ser: S) -> Result<S::Ok, S::Error> {
83-
value.to_string().serialize(ser)
82+
pub fn serialize<S: Serializer>(value: &Option<Oid>, ser: S) -> Result<S::Ok, S::Error> {
83+
value.as_ref().map(|oid| oid.to_string()).serialize(ser)
8484
}
85-
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Oid, D::Error> {
86-
String::deserialize(de)
87-
.and_then(|oid| Oid::from_str(&oid).map_err(<D::Error as serde::de::Error>::custom))
85+
86+
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Option<Oid>, D::Error> {
87+
Option::<String>::deserialize(de).map(|opt| {
88+
opt.map(|s| Oid::from_str(&s).map_err(<D::Error as serde::de::Error>::custom))
89+
.transpose()
90+
})?
8891
}
8992
}
9093

0 commit comments

Comments
 (0)