Rust implementation of OCI Distribution Spec with granular access control
1use clap::{Parser, Subcommand};
2use reqwest::blocking::Client;
3use serde_json::json;
4use std::process;
5
6#[derive(Parser)]
7#[command(name = "grainctl")]
8#[command(about = "CLI tool for administering the grain OCI registry", long_about = None)]
9#[command(version)]
10struct Cli {
11 #[command(subcommand)]
12 command: Commands,
13}
14
15#[derive(Subcommand)]
16enum Commands {
17 /// User management
18 User {
19 #[command(subcommand)]
20 command: UserCommands,
21 },
22
23 /// Run garbage collection
24 Gc {
25 #[arg(long, default_value = "false")]
26 dry_run: bool,
27
28 #[arg(long, default_value = "24")]
29 grace_period_hours: u64,
30
31 #[arg(long, env = "GRAIN_URL")]
32 url: String,
33
34 #[arg(long, env = "GRAIN_ADMIN_USER")]
35 username: String,
36
37 #[arg(long, env = "GRAIN_ADMIN_PASSWORD")]
38 password: String,
39 },
40}
41
42#[derive(Subcommand)]
43enum UserCommands {
44 /// List all users
45 List {
46 #[arg(long, env = "GRAIN_URL")]
47 url: String,
48
49 #[arg(long, env = "GRAIN_ADMIN_USER")]
50 username: String,
51
52 #[arg(long, env = "GRAIN_ADMIN_PASSWORD")]
53 password: String,
54 },
55
56 /// Create a new user
57 Create {
58 /// Username for the new user
59 user: String,
60
61 /// Password for the new user
62 #[arg(long)]
63 pass: String,
64
65 #[arg(long, env = "GRAIN_URL")]
66 url: String,
67
68 #[arg(long, env = "GRAIN_ADMIN_USER")]
69 username: String,
70
71 #[arg(long, env = "GRAIN_ADMIN_PASSWORD")]
72 password: String,
73 },
74
75 /// Delete a user
76 Delete {
77 /// Username to delete
78 user: String,
79
80 #[arg(long, env = "GRAIN_URL")]
81 url: String,
82
83 #[arg(long, env = "GRAIN_ADMIN_USER")]
84 username: String,
85
86 #[arg(long, env = "GRAIN_ADMIN_PASSWORD")]
87 password: String,
88 },
89
90 /// Add permission to a user
91 AddPermission {
92 /// Target username
93 user: String,
94
95 /// Repository pattern (e.g., "myorg/myrepo" or "myorg/*")
96 #[arg(long)]
97 repository: String,
98
99 /// Tag pattern (e.g., "latest" or "v*")
100 #[arg(long)]
101 tag: String,
102
103 /// Actions (comma-separated: pull,push,delete)
104 #[arg(long)]
105 actions: String,
106
107 #[arg(long, env = "GRAIN_URL")]
108 url: String,
109
110 #[arg(long, env = "GRAIN_ADMIN_USER")]
111 username: String,
112
113 #[arg(long, env = "GRAIN_ADMIN_PASSWORD")]
114 password: String,
115 },
116}
117
118fn main() {
119 let cli = Cli::parse();
120
121 if let Err(e) = execute_command(&cli.command) {
122 eprintln!("Error: {}", e);
123 process::exit(1);
124 }
125}
126
127fn execute_command(cmd: &Commands) -> Result<(), Box<dyn std::error::Error>> {
128 match cmd {
129 Commands::User { command } => execute_user_command(command),
130 Commands::Gc {
131 dry_run,
132 grace_period_hours,
133 url,
134 username,
135 password,
136 } => execute_gc_command(*dry_run, *grace_period_hours, url, username, password),
137 }
138}
139
140fn execute_user_command(cmd: &UserCommands) -> Result<(), Box<dyn std::error::Error>> {
141 let client = Client::new();
142
143 match cmd {
144 UserCommands::List {
145 url,
146 username,
147 password,
148 } => {
149 let response = client
150 .get(format!("{}/admin/users", url))
151 .basic_auth(username, Some(password))
152 .send()?;
153
154 if !response.status().is_success() {
155 let status = response.status();
156 let text = response
157 .text()
158 .unwrap_or_else(|_| String::from("No response body"));
159 return Err(format!("{} - {}", status, text).into());
160 }
161
162 let users: serde_json::Value = response.json()?;
163 println!("{}", serde_json::to_string_pretty(&users)?);
164 Ok(())
165 }
166
167 UserCommands::Create {
168 user,
169 pass,
170 url,
171 username,
172 password,
173 } => {
174 let body = json!({
175 "username": user,
176 "password": pass,
177 "permissions": []
178 });
179
180 let response = client
181 .post(format!("{}/admin/users", url))
182 .basic_auth(username, Some(password))
183 .json(&body)
184 .send()?;
185
186 if !response.status().is_success() {
187 let status = response.status();
188 let text = response
189 .text()
190 .unwrap_or_else(|_| String::from("No response body"));
191 return Err(format!("{} - {}", status, text).into());
192 }
193
194 println!("User '{}' created successfully", user);
195 Ok(())
196 }
197
198 UserCommands::Delete {
199 user,
200 url,
201 username,
202 password,
203 } => {
204 let response = client
205 .delete(format!("{}/admin/users/{}", url, user))
206 .basic_auth(username, Some(password))
207 .send()?;
208
209 if !response.status().is_success() {
210 let status = response.status();
211 let text = response
212 .text()
213 .unwrap_or_else(|_| String::from("No response body"));
214 return Err(format!("{} - {}", status, text).into());
215 }
216
217 println!("User '{}' deleted successfully", user);
218 Ok(())
219 }
220
221 UserCommands::AddPermission {
222 user,
223 repository,
224 tag,
225 actions,
226 url,
227 username,
228 password,
229 } => {
230 let actions_vec: Vec<String> =
231 actions.split(',').map(|s| s.trim().to_string()).collect();
232
233 let body = json!({
234 "repository": repository,
235 "tag": tag,
236 "actions": actions_vec
237 });
238
239 let response = client
240 .post(format!("{}/admin/users/{}/permissions", url, user))
241 .basic_auth(username, Some(password))
242 .json(&body)
243 .send()?;
244
245 if !response.status().is_success() {
246 let status = response.status();
247 let text = response
248 .text()
249 .unwrap_or_else(|_| String::from("No response body"));
250 return Err(format!("{} - {}", status, text).into());
251 }
252
253 println!(
254 "Permission added to user '{}': {} on {}:{}",
255 user, actions, repository, tag
256 );
257 Ok(())
258 }
259 }
260}
261
262fn execute_gc_command(
263 dry_run: bool,
264 grace_period_hours: u64,
265 url: &str,
266 username: &str,
267 password: &str,
268) -> Result<(), Box<dyn std::error::Error>> {
269 let client = Client::new();
270
271 let response = client
272 .post(format!(
273 "{}/admin/gc?dry_run={}&grace_period_hours={}",
274 url, dry_run, grace_period_hours
275 ))
276 .basic_auth(username, Some(password))
277 .send()?;
278
279 if !response.status().is_success() {
280 let status = response.status();
281 let text = response
282 .text()
283 .unwrap_or_else(|_| String::from("No response body"));
284 return Err(format!("{} - {}", status, text).into());
285 }
286
287 let stats: serde_json::Value = response.json()?;
288 println!("{}", serde_json::to_string_pretty(&stats)?);
289 Ok(())
290}