Skip to content

Commit 35b2677

Browse files
committed
aptlyctl: Add import commands for repos and publishes
Add import functionality to create repositories and publishes from JSON or YAML configuration files or stdin: # Import from file aptlyctl repo import repos.json aptlyctl publish import publishes.yaml # Import from stdin cat repos.json | aptlyctl repo import Conflicts are handled as follows: - Before doing anything, we verify that none of the repos/publishes we are about to create exist; if any do, we abort. - If --skip-existing is in use, we allow repos/publishes to exist; we don't touch them though. - We never delete or recreate anything, as this poses a risk of data loss. Both JSON and YAML are supported as input formats. If format is unspecified, stdin is always parsed as JSON. For files, format is selected by the extension. Fixes: #39 Signed-off-by: Andrej Shadura <andrew.shadura@collabora.co.uk>
1 parent 9a5e95d commit 35b2677

File tree

2 files changed

+211
-3
lines changed

2 files changed

+211
-3
lines changed

aptlyctl/src/publish.rs

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
use std::{io::stdout, process::ExitCode};
1+
use std::{
2+
fs,
3+
io::{stdin, stdout, Read},
4+
path::PathBuf,
5+
process::ExitCode,
6+
};
27

38
use aptly_rest::{api::publish, AptlyRest};
49
use clap::{Parser, Subcommand, ValueEnum};
510
use color_eyre::Result;
6-
use tracing::{debug, info};
11+
use tracing::{debug, info, warn};
712

813
use crate::OutputFormat;
914

@@ -88,10 +93,23 @@ pub struct PublishDropOpts {
8893
ignore_if_missing: bool,
8994
}
9095

96+
#[derive(Parser, Debug)]
97+
pub struct PublishImportOpts {
98+
/// Path to the file containing publish definitions (reads from stdin if not provided)
99+
file: Option<PathBuf>,
100+
/// Skip published repositories that already exist
101+
#[clap(long)]
102+
skip_existing: bool,
103+
/// Input format (json or yaml). If not specified, will be inferred from file extension
104+
#[clap(long, value_enum)]
105+
format: Option<OutputFormat>,
106+
}
107+
91108
#[derive(Subcommand, Debug)]
92109
pub enum PublishCommand {
93110
Create(PublishCreateOpts),
94111
List(PublishListOpts),
112+
Import(PublishImportOpts),
95113
TestExists(PublishTestExistsOpts),
96114
Update(PublishUpdateOpts),
97115
Drop(PublishDropOpts),
@@ -151,6 +169,105 @@ impl PublishCommand {
151169
}
152170
}
153171
}
172+
PublishCommand::Import(args) => {
173+
let content = if let Some(file) = &args.file {
174+
fs::read(file)?
175+
} else {
176+
let mut buffer = Vec::<u8>::new();
177+
stdin().read_to_end(&mut buffer)?;
178+
buffer
179+
};
180+
181+
let publishes: Vec<publish::PublishedRepo> = match args.format {
182+
Some(OutputFormat::Json) => serde_json::from_slice(&content)?,
183+
Some(OutputFormat::Yaml) => serde_yaml::from_slice(&content)?,
184+
Some(OutputFormat::Name) => {
185+
return Err(color_eyre::eyre::eyre!(
186+
"Name format is not supported for import"
187+
));
188+
}
189+
None => {
190+
// Infer from file extension
191+
if let Some(file) = &args.file {
192+
match file.extension().and_then(|e| e.to_str()) {
193+
Some("yaml" | "yml") => serde_yaml::from_slice(&content)?,
194+
Some("json") => serde_json::from_slice(&content)?,
195+
_ => {
196+
return Err(color_eyre::eyre::eyre!("Unsupported file format"));
197+
}
198+
}
199+
} else {
200+
// Parse stdin always as JSON
201+
serde_json::from_slice(&content)?
202+
}
203+
}
204+
};
205+
206+
let existing = aptly.published().await?;
207+
208+
let conflicts: Vec<_> = publishes
209+
.iter()
210+
.filter(|p| {
211+
existing.iter().any(|e| {
212+
e.prefix() == p.prefix() && e.distribution() == p.distribution()
213+
})
214+
})
215+
.collect();
216+
217+
if !conflicts.is_empty() && !args.skip_existing {
218+
for p in &conflicts {
219+
warn!(
220+
"Published repository '{}/{}' already exists",
221+
p.prefix(),
222+
p.distribution()
223+
);
224+
}
225+
return Err(color_eyre::eyre::eyre!(
226+
"{} published repositories already exist; use --skip-existing to skip them",
227+
conflicts.len()
228+
));
229+
}
230+
231+
let mut created = 0;
232+
let mut skipped = 0;
233+
234+
for repo in publishes {
235+
if existing.iter().any(|e| {
236+
e.prefix() == repo.prefix() && e.distribution() == repo.distribution()
237+
}) {
238+
info!(
239+
"Published repository '{}/{}' already exists, skipping",
240+
repo.prefix(),
241+
repo.distribution()
242+
);
243+
skipped += 1;
244+
} else {
245+
aptly
246+
.publish_prefix(repo.prefix())
247+
.publish(
248+
repo.source_kind(),
249+
repo.sources(),
250+
&publish::PublishOptions {
251+
architectures: repo.architectures().to_vec(),
252+
distribution: Some(repo.distribution().to_owned()),
253+
label: Some(repo.label().to_owned()),
254+
origin: Some(repo.origin().to_owned()),
255+
not_automatic: repo.not_automatic(),
256+
but_automatic_upgrades: repo.but_automatic_upgrades(),
257+
acquire_by_hash: repo.acquire_by_hash(),
258+
skip_contents: repo.skip_contents(),
259+
..Default::default()
260+
},
261+
)
262+
.await?;
263+
debug!(?repo);
264+
info!("Created new published repository at '{}'", repo.prefix());
265+
created += 1;
266+
}
267+
}
268+
269+
info!("Import complete: {} created, {} skipped", created, skipped);
270+
}
154271
PublishCommand::TestExists(args) => {
155272
let publishes = aptly.published().await?;
156273
if !publishes

aptlyctl/src/repo.rs

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
use std::{io::stdout, process::ExitCode};
1+
use std::{
2+
fs,
3+
io::{stdin, stdout, Read},
4+
path::PathBuf,
5+
process::ExitCode,
6+
};
27

38
use aptly_rest::{api::repos, key::AptlyKey, AptlyRest, AptlyRestError};
49
use clap::{Parser, Subcommand};
@@ -171,10 +176,23 @@ pub struct RepoDropOpts {
171176
force: bool,
172177
}
173178

179+
#[derive(Parser, Debug)]
180+
pub struct RepoImportOpts {
181+
/// Path to the file containing repository definitions (reads from stdin if not provided)
182+
file: Option<PathBuf>,
183+
/// Skip repositories that already exist
184+
#[clap(long)]
185+
skip_existing: bool,
186+
/// Input format (json or yaml). If not specified, will be inferred from file extension
187+
#[clap(long, value_enum)]
188+
format: Option<OutputFormat>,
189+
}
190+
174191
#[derive(Subcommand, Debug)]
175192
pub enum RepoCommand {
176193
Create(RepoCreateOpts),
177194
List(RepoListOpts),
195+
Import(RepoImportOpts),
178196
#[clap(subcommand)]
179197
Packages(RepoPackagesCommand),
180198
TestExists(RepoTestExistsOpts),
@@ -220,6 +238,79 @@ impl RepoCommand {
220238
}
221239
}
222240

241+
RepoCommand::Import(args) => {
242+
let content = if let Some(file) = &args.file {
243+
fs::read(file)?
244+
} else {
245+
let mut buffer = Vec::<u8>::new();
246+
stdin().read_to_end(&mut buffer)?;
247+
buffer
248+
};
249+
250+
let repos: Vec<repos::Repo> = match args.format {
251+
Some(OutputFormat::Json) => serde_json::from_slice(&content)?,
252+
Some(OutputFormat::Yaml) => serde_yaml::from_slice(&content)?,
253+
Some(OutputFormat::Name) => {
254+
return Err(color_eyre::eyre::eyre!(
255+
"Name format is not supported for import"
256+
));
257+
}
258+
None => {
259+
// Infer from file extension
260+
if let Some(file) = &args.file {
261+
match file.extension().and_then(|e| e.to_str()) {
262+
Some("yaml" | "yml") => serde_yaml::from_slice(&content)?,
263+
Some("json") => serde_json::from_slice(&content)?,
264+
_ => {
265+
return Err(color_eyre::eyre::eyre!("Unsupported file format"));
266+
}
267+
}
268+
} else {
269+
// Parse stdin always as JSON
270+
serde_json::from_slice(&content)?
271+
}
272+
}
273+
};
274+
275+
let existing: std::collections::HashSet<_> = aptly
276+
.repos()
277+
.await?
278+
.into_iter()
279+
.map(|r| r.name().to_owned())
280+
.collect();
281+
282+
let conflicts: Vec<_> = repos
283+
.iter()
284+
.filter(|r| existing.contains(r.name()))
285+
.collect();
286+
287+
if !conflicts.is_empty() && !args.skip_existing {
288+
for repo in &conflicts {
289+
warn!("Repo '{}' already exists", repo.name());
290+
}
291+
return Err(color_eyre::eyre::eyre!(
292+
"{} repos already exist; use --skip-existing to skip them",
293+
conflicts.len()
294+
));
295+
}
296+
297+
let mut created = 0;
298+
let mut skipped = 0;
299+
300+
for repo in repos {
301+
if existing.contains(repo.name()) {
302+
info!("Repo '{}' already exists, skipping", repo.name());
303+
skipped += 1;
304+
} else {
305+
aptly.create_repo(&repo).await?;
306+
info!("Created repo '{}'", repo.name());
307+
created += 1;
308+
}
309+
}
310+
311+
info!("Import complete: {} created, {} skipped", created, skipped);
312+
}
313+
223314
RepoCommand::Packages(command) => return command.run(aptly).await,
224315

225316
RepoCommand::Search(args) => {

0 commit comments

Comments
 (0)