Skip to content

Commit 2bf81bd

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

File tree

3 files changed

+144
-51
lines changed

3 files changed

+144
-51
lines changed

rust/patchable/README.md

+22-6
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

+112-38
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(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.
@@ -190,6 +257,8 @@ pub enum Error {
190257
source: toml::de::Error,
191258
path: PathBuf,
192259
},
260+
#[snafu(display("failed to validate config: {reason:?} (used config files: {paths:?})"))]
261+
ValidateConfig { reason: String, paths: Vec<PathBuf> },
193262
#[snafu(display("failed to serialize config"))]
194263
SerializeConfig { source: toml::ser::Error },
195264
#[snafu(display("failed to create patch dir at {path:?}"))]
@@ -203,8 +272,6 @@ pub enum Error {
203272
path: PathBuf,
204273
},
205274

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

372+
// Presence of `base` and `upstream` already validated by load_config, so unwrap is safe here
305373
let base_commit = repo::resolve_and_fetch_commitish(
306374
&product_repo,
307-
&config.base.to_string(),
308-
config.mirror.as_deref().unwrap_or(&config.upstream),
375+
&config.base.unwrap().to_string(),
376+
config
377+
.mirror
378+
.as_deref()
379+
.unwrap_or(config.upstream.as_ref().unwrap()),
309380
)
310381
.context(FetchBaseCommitSnafu)?;
311382
let base_branch = ctx.base_branch();
@@ -378,7 +449,8 @@ fn main() -> Result<()> {
378449
path: product_worktree_root,
379450
})?;
380451

381-
let base_commit = config.base;
452+
// Presence of `base` already validated by load_config, so unwrap is safe here
453+
let base_commit = config.base.unwrap();
382454
let original_leaf_commit = product_version_repo
383455
.head()
384456
.and_then(|c| c.peel_to_commit())
@@ -437,43 +509,45 @@ fn main() -> Result<()> {
437509
// --base can be a reference, but patchable.toml should always have a resolved commit id,
438510
// so that it cannot be changed under our feet (without us knowing so, anyway...).
439511
tracing::info!(?base, "resolving base commit-ish");
440-
let base_commit = repo::resolve_and_fetch_commitish(&product_repo, &base, &upstream).context(FetchBaseCommitSnafu)?;
512+
let base_commit = repo::resolve_and_fetch_commitish(&product_repo, &base, &upstream)
513+
.context(FetchBaseCommitSnafu)?;
441514
let mut upstream_mirror = None;
442515

443516
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");
517+
// Load mirror URL from product-level config
518+
let mirror_url = ctx.load_config()?.mirror.context(ValidateConfigSnafu {
519+
reason: "mirror repository needs to be specified in product config when using --mirror flag".to_string(),
520+
paths: vec![ctx.product_config_path()],
521+
})?;
449522

450523
// Add mirror remote
451-
let mut mirror_remote = product_repo
452-
.remote_anonymous(&mirror_url)
453-
.context(AddMirrorRemoteSnafu { url: mirror_url.clone() })?;
524+
let mut mirror_remote =
525+
product_repo
526+
.remote_anonymous(&mirror_url)
527+
.context(AddMirrorRemoteSnafu {
528+
url: mirror_url.clone(),
529+
})?;
454530

455531
// Push the base commit to the mirror
456532
tracing::info!(commit = %base_commit, base = base, url = mirror_url, "pushing commit to mirror");
457533
let mut callbacks = git2::RemoteCallbacks::new();
458534
callbacks.credentials(|url, username_from_url, _allowed_types| {
459535
git2::Cred::credential_helper(
460-
&git2::Config::open_default().unwrap(), // Use default git config
536+
&git2::Config::open_default()
537+
.expect("failed to open default Git configuration"), // Use default git config,
461538
url,
462539
username_from_url,
463540
)
464541
});
465542

466543
// Add progress tracking for push operation
467-
let (span_push, mut quant_push) = utils::setup_progress_tracking(tracing::info_span!("pushing"));
544+
let (span_push, mut quant_push) =
545+
utils::setup_progress_tracking(tracing::info_span!("pushing"));
468546
let _ = span_push.enter();
469547

470548
callbacks.push_transfer_progress(move |current, total, _| {
471549
if total > 0 {
472-
quant_push.update_span_progress(
473-
current,
474-
total,
475-
&span_push,
476-
);
550+
quant_push.update_span_progress(current, total, &span_push);
477551
}
478552
});
479553

@@ -486,7 +560,7 @@ fn main() -> Result<()> {
486560
mirror_remote
487561
.push(&[&refspec], Some(&mut push_options))
488562
.context(PushToMirrorSnafu {
489-
url: &mirror_url,
563+
url: mirror_url.clone(),
490564
refspec: &refspec,
491565
commit: base_commit,
492566
})?;
@@ -500,11 +574,11 @@ fn main() -> Result<()> {
500574

501575
tracing::info!("saving configuration");
502576
let config = ProductVersionConfig {
503-
upstream,
577+
upstream: Some(upstream),
504578
mirror: upstream_mirror,
505-
base: base_commit,
579+
base: Some(base_commit),
506580
};
507-
let config_path = ctx.config_path();
581+
let config_path = ctx.version_config_path();
508582
if let Some(config_dir) = config_path.parent() {
509583
std::fs::create_dir_all(config_dir)
510584
.context(CreatePatchDirSnafu { path: config_dir })?;

rust/patchable/src/utils.rs

+10-7
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)