Skip to content

Commit 1e64c8c

Browse files
committed
feat: repo install clarification - source or published version selector
1 parent 28f670a commit 1e64c8c

2 files changed

Lines changed: 268 additions & 25 deletions

File tree

README.md

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,40 @@ How it works:
147147
- Applies a GitHub `ProviderConfig` named `default` unless `--refresh` is used.
148148
- Supports overrides for namespace, Secret name, ProviderConfig name, provider name, and provider package.
149149

150+
## Quick Start
151+
152+
```bash
153+
# Build and load a Crossplane configuration package from an Upbound-format XRD project
154+
hops config install --path /path/to/project
155+
156+
# Install from a GitHub repo; interactive TTY runs ask whether to build from source
157+
# or use a published version (non-interactive runs default to source build)
158+
hops config install --repo hops-ops/helm-certmanager
159+
160+
# Force reload from source (deletes existing ConfigurationRevision(s) first)
161+
hops config install --repo hops-ops/helm-certmanager --reload
162+
163+
# Apply a pinned remote package version directly (no clone/build)
164+
hops config install --repo hops-ops/helm-certmanager --version v0.1.0
165+
166+
# Remove a configuration and prune orphaned package dependencies
167+
hops config uninstall --repo hops-ops/helm-certmanager
168+
169+
# Generate apis/*/configuration.yaml from upbound.yaml for validation
170+
hops validate generate-configuration --path /path/to/project
171+
172+
# Observe an existing XR into a manifest
173+
hops xr observe --kind AutoEKSCluster --name pat-local --namespace default --aws-region us-east-2
174+
175+
# Render adoption patches for managed resources under an existing XR
176+
hops xr adopt --kind AutoEKSCluster --name pat-local --namespace default
177+
178+
# Convert an observed/adopted XR into a managed manifest
179+
hops xr manage --kind AutoEKSCluster --name pat-local --namespace default
180+
181+
# Render patches that remove Delete from management policies
182+
hops xr orphan --kind AutoEKSCluster --name pat-local --namespace default
183+
```
150184
## Config packages
151185

152186
`config install` and `config uninstall` operate on the currently connected Kubernetes cluster.
@@ -165,7 +199,8 @@ hops config install
165199
# Build from an explicit local Upbound-format XRD project path
166200
hops config install --path /path/to/project
167201

168-
# Build from a cached GitHub repo checkout containing an Upbound-format XRD project
202+
# Install from a GitHub repo; interactive TTY runs ask whether to build from source
203+
# or use a published version
169204
hops config install --repo hops-ops/aws-auto-eks-cluster
170205

171206
# Force a source reload before re-applying
@@ -195,6 +230,8 @@ Notes:
195230

196231
- `--reload` only applies to source installs: `--path` or `--repo` without `--version`.
197232
- `--skip-dependency-resolution` sets `spec.skipDependencyResolution=true` on the generated `Configuration`.
233+
- `config install --repo ...` now prompts in interactive terminals to choose between cloning/building from source or applying a published package version. Published-version prompts suggest the latest discovered tag by default and still accept arbitrary tags such as `pr-<gitsha>`.
234+
- Non-interactive `config install --repo ...` keeps the previous default behavior and builds from source.
198235
- `config install --repo ... --version ...` skips clone/build and applies the remote package directly.
199236
- `config uninstall --repo ...` derives the configuration name as `<org>-<repo>`.
200237

@@ -225,10 +262,13 @@ Notes:
225262
- Applies Crossplane `Configuration` resources pointing at `registry.crossplane-system.svc.cluster.local:5000/...`
226263
- Supports `--skip-dependency-resolution`
227264
- `config install --repo <org/repo> [--reload]`
228-
- Source-build mode intended for a local control plane because it depends on the local registry flow
229-
- Uses local repo cache at `~/.hops/local/repo-cache/<org>/<repo>`
230-
- Clones on first use, then fetches/pulls on subsequent runs
231-
- Runs the same build/load/push/apply flow as `--path`
265+
- Interactive terminals prompt for install mode: source build or published version
266+
- Published-version installs suggest the latest discovered tag by default and accept custom tags such as `pr-<gitsha>`
267+
- Non-interactive runs and `--reload` continue to use the source-build flow
268+
- Source-build mode is intended for a local control plane because it depends on the local registry flow
269+
- Source builds use local repo cache at `~/.hops/local/repo-cache/<org>/<repo>`
270+
- Source builds clone on first use, then fetch/pull on subsequent runs
271+
- Source builds run the same build/load/push/apply flow as `--path`
232272
- `--reload`
233273
- Forces source-based config install (`--path` or `--repo` without `--version`) to delete existing `ConfigurationRevision` resources and matching `Function`/`FunctionRevision` package resources from the same sources, then re-apply the `Configuration`
234274
- Useful when re-running a config and you want Crossplane to re-create the current revision from source

src/commands/config/install.rs

Lines changed: 223 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
99
use std::error::Error;
1010
use std::fs;
1111
use std::hash::{Hash, Hasher};
12-
use std::io::{Cursor, Read, Write};
12+
use std::io::{self, Cursor, IsTerminal, Read, Write};
1313
use std::path::{Path, PathBuf};
1414
use std::process::{Command, Stdio};
1515
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -127,14 +127,26 @@ struct PackageResource {
127127
spec: Option<PackageSpec>,
128128
}
129129

130+
#[derive(Clone, Debug, PartialEq, Eq)]
131+
enum RepoInstallTarget {
132+
SourceBuild,
133+
PublishedVersion(String),
134+
}
135+
136+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137+
enum RepoInstallChoice {
138+
SourceBuild,
139+
PublishedVersion,
140+
}
141+
130142
pub fn run(args: &ConfigArgs) -> Result<(), Box<dyn Error>> {
131143
validate_reload_args(args)?;
132144

133145
match (args.repo.as_deref(), args.version.as_deref()) {
134146
(Some(repo), Some(version)) => {
135147
apply_repo_version(repo, version, args.skip_dependency_resolution)
136148
}
137-
(Some(repo), None) => run_repo_clone(repo, args.reload, args.skip_dependency_resolution),
149+
(Some(repo), None) => run_repo_install(repo, args.reload, args.skip_dependency_resolution),
138150
(None, _) => run_local_path(
139151
args.path.as_deref().unwrap_or("."),
140152
args.reload,
@@ -150,12 +162,25 @@ fn validate_reload_args(args: &ConfigArgs) -> Result<(), Box<dyn Error>> {
150162
Ok(())
151163
}
152164

153-
fn run_repo_clone(
165+
fn run_repo_install(
154166
repo: &str,
155167
reload: bool,
156168
skip_dependency_resolution: bool,
157169
) -> Result<(), Box<dyn Error>> {
158170
let spec = parse_repo_spec(repo)?;
171+
match resolve_repo_install_target(&spec, reload)? {
172+
RepoInstallTarget::SourceBuild => run_repo_clone(&spec, reload, skip_dependency_resolution),
173+
RepoInstallTarget::PublishedVersion(version) => {
174+
apply_repo_version_spec(&spec, &version, skip_dependency_resolution)
175+
}
176+
}
177+
}
178+
179+
fn run_repo_clone(
180+
spec: &RepoSpec,
181+
reload: bool,
182+
skip_dependency_resolution: bool,
183+
) -> Result<(), Box<dyn Error>> {
159184
let cache_path = ensure_cached_repo_checkout(&spec)?;
160185
run_local_path(
161186
&cache_path.to_string_lossy(),
@@ -164,6 +189,154 @@ fn run_repo_clone(
164189
)
165190
}
166191

192+
fn resolve_repo_install_target(
193+
spec: &RepoSpec,
194+
reload: bool,
195+
) -> Result<RepoInstallTarget, Box<dyn Error>> {
196+
if reload || !interactive_stdio_available() {
197+
return Ok(RepoInstallTarget::SourceBuild);
198+
}
199+
200+
match prompt_for_repo_install_choice(spec)? {
201+
RepoInstallChoice::SourceBuild => Ok(RepoInstallTarget::SourceBuild),
202+
RepoInstallChoice::PublishedVersion => {
203+
let suggested = latest_published_version(spec).ok().flatten();
204+
let version = prompt_for_published_version(spec, suggested.as_deref())?;
205+
Ok(RepoInstallTarget::PublishedVersion(version))
206+
}
207+
}
208+
}
209+
210+
fn interactive_stdio_available() -> bool {
211+
io::stdin().is_terminal() && io::stdout().is_terminal()
212+
}
213+
214+
fn prompt_for_repo_install_choice(spec: &RepoSpec) -> Result<RepoInstallChoice, Box<dyn Error>> {
215+
let repo_slug = format!("{}/{}", spec.org, spec.repo);
216+
217+
loop {
218+
print!("Install {repo_slug} from source or use a published version? [published/source]: ");
219+
io::stdout().flush()?;
220+
221+
let mut input = String::new();
222+
io::stdin().read_line(&mut input)?;
223+
224+
match parse_repo_install_choice(&input) {
225+
Ok(choice) => return Ok(choice),
226+
Err(message) => {
227+
eprintln!("{message}");
228+
}
229+
}
230+
}
231+
}
232+
233+
fn prompt_for_published_version(
234+
spec: &RepoSpec,
235+
default_version: Option<&str>,
236+
) -> Result<String, Box<dyn Error>> {
237+
let repo_slug = format!("{}/{}", spec.org, spec.repo);
238+
239+
loop {
240+
let prompt = match default_version {
241+
Some(default) => format!(
242+
"Enter published version/tag for {repo_slug} [{default}] (for example `pr-<gitsha>`): "
243+
),
244+
None => format!(
245+
"Enter published version/tag for {repo_slug} (for example `v0.11.0` or `pr-<gitsha>`): "
246+
),
247+
};
248+
print!("{prompt}");
249+
io::stdout().flush()?;
250+
251+
let mut input = String::new();
252+
io::stdin().read_line(&mut input)?;
253+
254+
match resolve_published_version_input(&input, default_version) {
255+
Some(version) => return Ok(version),
256+
None => {
257+
eprintln!(
258+
"Published version cannot be empty. Enter a tag like `v0.11.0` or `pr-<gitsha>`."
259+
);
260+
}
261+
}
262+
}
263+
}
264+
265+
fn parse_repo_install_choice(input: &str) -> Result<RepoInstallChoice, String> {
266+
match input.trim().to_ascii_lowercase().as_str() {
267+
"" | "published" | "publish" | "published version" | "version" | "release" | "p" => {
268+
Ok(RepoInstallChoice::PublishedVersion)
269+
}
270+
"source" | "build" | "clone" | "source build" | "s" => Ok(RepoInstallChoice::SourceBuild),
271+
_ => Err("Enter `published` or `source`.".to_string()),
272+
}
273+
}
274+
275+
fn resolve_published_version_input(input: &str, default_version: Option<&str>) -> Option<String> {
276+
let trimmed = input.trim();
277+
if !trimmed.is_empty() {
278+
return Some(trimmed.to_string());
279+
}
280+
281+
default_version
282+
.map(str::trim)
283+
.filter(|version| !version.is_empty())
284+
.map(str::to_string)
285+
}
286+
287+
fn latest_published_version(spec: &RepoSpec) -> Result<Option<String>, Box<dyn Error>> {
288+
let repo_url = format!("https://github.com/{}/{}", spec.org, spec.repo);
289+
let output = run_cmd_output(
290+
"git",
291+
&[
292+
"ls-remote",
293+
"--sort=-version:refname",
294+
"--refs",
295+
"--tags",
296+
&repo_url,
297+
],
298+
)?;
299+
300+
for line in output.lines() {
301+
let Some((_, ref_name)) = line.split_once('\t') else {
302+
continue;
303+
};
304+
let Some(tag) = ref_name.strip_prefix("refs/tags/") else {
305+
continue;
306+
};
307+
let version = tag.trim();
308+
if !version.is_empty() {
309+
return Ok(Some(version.to_string()));
310+
}
311+
}
312+
313+
Ok(None)
314+
}
315+
316+
fn apply_repo_version_spec(
317+
spec: &RepoSpec,
318+
version: &str,
319+
skip_dependency_resolution: bool,
320+
) -> Result<(), Box<dyn Error>> {
321+
let version = version.trim();
322+
if version.is_empty() {
323+
return Err("`--version` cannot be empty".into());
324+
}
325+
326+
let package_ref = format!("ghcr.io/{}/{}:{}", spec.org, spec.repo, version);
327+
let config_name = format!(
328+
"{}-{}",
329+
sanitize_name_component(&spec.org),
330+
sanitize_name_component(&spec.repo)
331+
);
332+
apply_configuration(
333+
&config_name,
334+
&package_ref,
335+
skip_dependency_resolution,
336+
false,
337+
)
338+
}
339+
167340
fn ensure_cached_repo_checkout(spec: &RepoSpec) -> Result<PathBuf, Box<dyn Error>> {
168341
let cache_path = repo_cache_path(&spec.org, &spec.repo)?;
169342
let clone_url = format!("https://github.com/{}/{}", spec.org, spec.repo);
@@ -226,23 +399,7 @@ fn apply_repo_version(
226399
skip_dependency_resolution: bool,
227400
) -> Result<(), Box<dyn Error>> {
228401
let spec = parse_repo_spec(repo)?;
229-
let version = version.trim();
230-
if version.is_empty() {
231-
return Err("`--version` cannot be empty".into());
232-
}
233-
234-
let package_ref = format!("ghcr.io/{}/{}:{}", spec.org, spec.repo, version);
235-
let config_name = format!(
236-
"{}-{}",
237-
sanitize_name_component(&spec.org),
238-
sanitize_name_component(&spec.repo)
239-
);
240-
apply_configuration(
241-
&config_name,
242-
&package_ref,
243-
skip_dependency_resolution,
244-
false,
245-
)
402+
apply_repo_version_spec(&spec, version, skip_dependency_resolution)
246403
}
247404

248405
fn parse_repo_spec(repo: &str) -> Result<RepoSpec, Box<dyn Error>> {
@@ -1126,6 +1283,52 @@ spec:
11261283
assert!(parse_repo_spec("hops-ops/helm-certmanager/extra").is_err());
11271284
}
11281285

1286+
#[test]
1287+
fn parse_repo_install_choice_accepts_expected_inputs() {
1288+
assert_eq!(
1289+
parse_repo_install_choice("published").unwrap(),
1290+
RepoInstallChoice::PublishedVersion
1291+
);
1292+
assert_eq!(
1293+
parse_repo_install_choice("release").unwrap(),
1294+
RepoInstallChoice::PublishedVersion
1295+
);
1296+
assert_eq!(
1297+
parse_repo_install_choice("").unwrap(),
1298+
RepoInstallChoice::PublishedVersion
1299+
);
1300+
assert_eq!(
1301+
parse_repo_install_choice("source").unwrap(),
1302+
RepoInstallChoice::SourceBuild
1303+
);
1304+
assert_eq!(
1305+
parse_repo_install_choice("clone").unwrap(),
1306+
RepoInstallChoice::SourceBuild
1307+
);
1308+
}
1309+
1310+
#[test]
1311+
fn parse_repo_install_choice_rejects_unknown_input() {
1312+
assert!(parse_repo_install_choice("banana").is_err());
1313+
}
1314+
1315+
#[test]
1316+
fn resolve_published_version_input_prefers_explicit_value() {
1317+
assert_eq!(
1318+
resolve_published_version_input("pr-123abc", Some("v0.11.0")).as_deref(),
1319+
Some("pr-123abc")
1320+
);
1321+
}
1322+
1323+
#[test]
1324+
fn resolve_published_version_input_uses_default_for_blank_input() {
1325+
assert_eq!(
1326+
resolve_published_version_input(" ", Some("v0.11.0")).as_deref(),
1327+
Some("v0.11.0")
1328+
);
1329+
assert_eq!(resolve_published_version_input("", None), None);
1330+
}
1331+
11291332
#[test]
11301333
fn sanitize_name_component_normalizes_for_k8s_names() {
11311334
assert_eq!(sanitize_name_component("Hops_Ops"), "hops-ops");

0 commit comments

Comments
 (0)