use std::collections::BTreeSet;
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::Context;
use carte_core::model::World;
use carte_core::{discovery, kube, slug};
use toml_edit::{DocumentMut, InlineTable, value};
use super::prompt::{self, tilde};
use crate::paths::Paths;
#[derive(Debug, Clone, PartialEq, Eq)]
struct Draft {
context: String,
name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Suggestion {
Name(String),
Clash(String),
Imported(String),
}
fn taken_names(paths: &Paths, world: &World) -> BTreeSet<String> {
world
.projects
.iter()
.flat_map(|state| {
std::iter::once(state.project.name.clone()).chain(
state
.project
.apps
.iter()
.flat_map(|app| app.settings.aliases.clone()),
)
})
.chain(world.kube_projects.iter().flat_map(|p| {
std::iter::once(p.name.clone())
.chain(p.services.values().flat_map(|s| s.aliases.clone()))
}))
.chain(
discovery::project_files(&paths.projects_dir())
.unwrap_or_default()
.into_iter()
.map(|f| f.name),
)
.collect()
}
fn suggest(
world: &World,
taken: &BTreeSet<String>,
kubeconfig: Option<&Path>,
contexts: &[String],
) -> Vec<(String, Suggestion)> {
let labels: Vec<String> = contexts.iter().map(|c| kube::context_label(c)).collect();
contexts
.iter()
.zip(&labels)
.map(|(context, label)| {
let suggestion = if let Some(project) = world
.kube_projects
.iter()
.find(|p| p.context == *context && p.kubeconfig.as_deref() == kubeconfig)
{
Suggestion::Imported(project.name.clone())
} else if taken.contains(label)
|| labels.iter().filter(|l| *l == label).count() > 1
|| slug::validate_project_name(label).is_err()
{
Suggestion::Clash(label.clone())
} else {
Suggestion::Name(label.clone())
};
(context.clone(), suggestion)
})
.collect()
}
fn check_folder(folder: &str) -> anyhow::Result<()> {
let path = Path::new(folder);
let fine = !folder.is_empty()
&& path.components().all(|c| match c {
std::path::Component::Normal(part) => !part.to_string_lossy().starts_with('.'),
_ => false,
});
if !fine {
anyhow::bail!(
"--folder {folder:?} must be a folder inside projects/, like `clusters`, not starting with a dot"
);
}
Ok(())
}
fn kubeconfig_file(given: Option<PathBuf>) -> anyhow::Result<Option<PathBuf>> {
let file = match given {
Some(path) => Some(path),
None => {
let env = std::env::var_os("KUBECONFIG").unwrap_or_default();
let files: Vec<PathBuf> = std::env::split_paths(&env)
.filter(|p| !p.as_os_str().is_empty())
.collect();
match files.as_slice() {
[] => None,
[one] => Some(one.clone()),
_ => anyhow::bail!(
"$KUBECONFIG names several files; pass the one to use with --kubeconfig"
),
}
}
};
file.map(|path| std::path::absolute(&path).context("resolving --kubeconfig"))
.transpose()
}
fn project_text(context: &str, kubeconfig: Option<&Path>) -> String {
let mut table = InlineTable::new();
table.insert("context", context.into());
if let Some(path) = kubeconfig {
table.insert("kubeconfig", tilde(path).into());
}
let mut doc = DocumentMut::new();
doc["kube"] = value(table);
doc.to_string()
}
fn read_contexts(kubeconfig: Option<&Path>) -> anyhow::Result<Vec<String>> {
let config = match kubeconfig {
Some(path) => ::kube::config::Kubeconfig::read_from(path),
None => ::kube::config::Kubeconfig::read(),
}
.context("reading the kubeconfig")?;
Ok(config.contexts.into_iter().map(|c| c.name).collect())
}
pub fn run(
paths: &Paths,
kubeconfig: Option<PathBuf>,
folder: Option<String>,
dry_run: bool,
yes: bool,
) -> anyhow::Result<ExitCode> {
if let Some(folder) = &folder {
check_folder(folder)?;
}
let kubeconfig = kubeconfig_file(kubeconfig)?;
let contexts = read_contexts(kubeconfig.as_deref())?;
if contexts.is_empty() {
println!("The kubeconfig has no contexts.");
return Ok(ExitCode::SUCCESS);
}
let world = discovery::load_world(&paths.config_dir);
let interactive = !dry_run && prompt::interactive(yes);
let mut taken = taken_names(paths, &world);
let mut drafts = Vec::new();
for (context, suggestion) in suggest(&world, &taken, kubeconfig.as_deref(), &contexts) {
let name = match suggestion {
Suggestion::Imported(project) => {
println!("{context}: already in project {project}");
continue;
}
Suggestion::Name(name) if !taken.contains(&name) => {
if interactive && !prompt::confirm(&format!("Add {context} as {name}?"), true)? {
continue;
}
name
}
Suggestion::Name(name) | Suggestion::Clash(name) => {
if !interactive {
println!("{context}: skipped, {name:?} is taken or not a valid name");
continue;
}
match ask_name(&context, &name, &taken)? {
Some(name) => name,
None => continue,
}
}
};
taken.insert(name.clone());
drafts.push(Draft { context, name });
}
if drafts.is_empty() {
println!("Nothing to import.");
return Ok(ExitCode::SUCCESS);
}
let dir = match &folder {
Some(folder) => paths.projects_dir().join(folder),
None => paths.projects_dir(),
};
for draft in &drafts {
let file = dir.join(format!("{}.toml", draft.name));
let text = project_text(&draft.context, kubeconfig.as_deref());
if dry_run {
println!("{}:\n{text}", tilde(&file));
continue;
}
fs::create_dir_all(&dir)?;
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&file)
{
Ok(mut out) => {
out.write_all(text.as_bytes())?;
println!("Wrote {}", tilde(&file));
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
println!("Skipped {}: it exists", tilde(&file));
}
Err(err) => return Err(anyhow::Error::from(err).context(file.display().to_string())),
}
}
if dry_run {
println!("Dry run: nothing was written.");
}
Ok(ExitCode::SUCCESS)
}
fn ask_name(
context: &str,
suggested: &str,
taken: &BTreeSet<String>,
) -> anyhow::Result<Option<String>> {
let mut question = format!(
"{context} would be {suggested:?}, which is taken or invalid; name it (empty skips)"
);
loop {
let answer = prompt::ask(&question, None)?;
if answer.is_empty() {
return Ok(None);
}
match slug::validate_project_name(&answer) {
Err(err) => question = format!("{err}; name it (empty skips)"),
Ok(()) if taken.contains(&answer) => {
question = format!("{answer:?} is taken too; name it (empty skips)")
}
Ok(()) => return Ok(Some(answer)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const KUBECONFIG: &str = r#"
apiVersion: v1
kind: Config
clusters:
- name: c
cluster:
server: https://127.0.0.1:6443
users:
- name: u
user:
token: secret
contexts:
- name: arn:aws:eks:eu-west-1:123456:cluster/production
context: {cluster: c, user: u}
- name: gke_acme-infra_europe-north1_staging
context: {cluster: c, user: u}
- name: shop
context: {cluster: c, user: u}
- name: kind-nova
context: {cluster: c, user: u}
- name: x
context: {cluster: c, user: u}
"#;
#[test]
fn writes_a_project_for_each_context_with_a_free_name() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
fs::create_dir_all(paths.projects_dir()).unwrap();
fs::write(
paths.projects_dir().join("shop.toml"),
"root = \"/nowhere\"\n",
)
.unwrap();
let kubeconfig = dir.path().join("kubeconfig");
fs::write(&kubeconfig, KUBECONFIG).unwrap();
fs::write(
paths.projects_dir().join("nova.toml"),
format!(
"kube = {{ context = \"kind-nova\", kubeconfig = \"{}\" }}\n",
kubeconfig.display()
),
)
.unwrap();
run(
&paths,
Some(kubeconfig.clone()),
Some("clusters".into()),
true,
true,
)
.unwrap();
assert!(!paths.projects_dir().join("clusters").exists());
run(
&paths,
Some(kubeconfig.clone()),
Some("clusters".into()),
false,
true,
)
.unwrap();
let world = discovery::load_world(&paths.config_dir);
let mut imported: Vec<(String, String, Option<String>)> = world
.kube_projects
.iter()
.map(|p| (p.name.clone(), p.context.clone(), p.folder.clone()))
.collect();
imported.sort();
assert_eq!(
imported,
vec![
("nova".into(), "kind-nova".into(), None),
(
"production".into(),
"arn:aws:eks:eu-west-1:123456:cluster/production".into(),
Some("clusters".into())
),
(
"staging".into(),
"gke_acme-infra_europe-north1_staging".into(),
Some("clusters".into())
),
],
"shop clashes with a project, x is too short, kind-nova is imported"
);
assert_eq!(
world.kube_project("staging").unwrap().kubeconfig.as_deref(),
Some(kubeconfig.as_path())
);
}
#[test]
fn checks_folders() {
assert!(check_folder("clusters").is_ok());
assert!(check_folder("work/clusters").is_ok());
for bad in ["", "../x", "/abs", ".clusters", "a/.b"] {
assert!(check_folder(bad).is_err(), "{bad}");
}
}
#[test]
fn suggests_names_and_spots_clashes() {
let world = World::default();
let contexts = vec![
"arn:aws:eks:eu-west-1:1:cluster/prod".to_string(),
"arn:aws:eks:us-east-1:2:cluster/prod".to_string(),
"kind-nova".to_string(),
];
let suggestions: Vec<Suggestion> = suggest(&world, &BTreeSet::new(), None, &contexts)
.into_iter()
.map(|(_, s)| s)
.collect();
assert_eq!(
suggestions,
vec![
Suggestion::Clash("prod".into()),
Suggestion::Clash("prod".into()),
Suggestion::Name("kind-nova".into()),
]
);
}
}