crates/flotilla-commands/src/commands/convoy.rs
Line | Count | Source |
1 | | use clap::{Parser, Subcommand}; |
2 | | use flotilla_protocol::{Command, CommandAction}; |
3 | | |
4 | | use crate::{ |
5 | | quote::quote_value, |
6 | | resolved::{HostResolution, RepoContext}, |
7 | | Resolved, |
8 | | }; |
9 | | |
10 | | #[derive(Debug, Clone, PartialEq, Eq, Parser)] |
11 | | #[command(about = "Manage convoys")] |
12 | | pub struct ConvoyNoun { |
13 | | /// Convoy name |
14 | | pub subject: String, |
15 | | |
16 | | #[command(subcommand)] |
17 | | pub verb: ConvoyVerb, |
18 | | } |
19 | | |
20 | | #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] |
21 | | pub enum ConvoyVerb { |
22 | | /// Manage convoy tasks |
23 | | Task(ConvoyTaskNoun), |
24 | | /// Create a convoy from a workflow template |
25 | | Create { |
26 | | /// Workflow template to instantiate |
27 | | #[arg(long)] |
28 | | template: String, |
29 | | /// Input value (repeatable): --input key=value |
30 | | #[arg(long = "input", value_parser = parse_input_kv)] |
31 | | inputs: Vec<(String, String)>, |
32 | | /// Repository URL the workflow operates on |
33 | | #[arg(long = "repo")] |
34 | | repository_url: Option<String>, |
35 | | /// Git ref (branch/tag/commit) within the repository |
36 | | #[arg(long = "ref")] |
37 | | r#ref: Option<String>, |
38 | | }, |
39 | | } |
40 | | |
41 | 6 | fn parse_input_kv(raw: &str) -> Result<(String, String), String> { |
42 | 6 | let (key, value) = raw.split_once('=').ok_or_else(|| format!0 ("input must be key=value: {raw}"))?0 ; |
43 | 6 | if key.is_empty() { |
44 | 0 | return Err(format!("input key cannot be empty: {raw}")); |
45 | 6 | } |
46 | 6 | Ok((key.to_string(), value.to_string())) |
47 | 6 | } |
48 | | |
49 | | #[derive(Debug, Clone, PartialEq, Eq, Parser)] |
50 | | pub struct ConvoyTaskNoun { |
51 | | /// Task name |
52 | | pub subject: String, |
53 | | |
54 | | #[command(subcommand)] |
55 | | pub verb: ConvoyTaskVerb, |
56 | | } |
57 | | |
58 | | #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] |
59 | | pub enum ConvoyTaskVerb { |
60 | | /// Mark a convoy task complete |
61 | | Complete { |
62 | | /// Optional completion message recorded on the task |
63 | | #[arg(long)] |
64 | | message: Option<String>, |
65 | | }, |
66 | | } |
67 | | |
68 | | impl ConvoyNoun { |
69 | 6 | pub fn resolve(self) -> Result<Resolved, String> { |
70 | 6 | match self.verb { |
71 | 4 | ConvoyVerb::Task(task) => match task.verb { |
72 | 4 | ConvoyTaskVerb::Complete { message } => Ok(Resolved::NeedsContext { |
73 | 4 | command: Command { |
74 | 4 | node_id: None, |
75 | 4 | provisioning_target: None, |
76 | 4 | context_repo: None, |
77 | 4 | action: CommandAction::ConvoyTaskComplete { convoy: self.subject, task: task.subject, message }, |
78 | 4 | }, |
79 | 4 | repo: RepoContext::None, |
80 | 4 | host: HostResolution::Local, |
81 | 4 | }), |
82 | | }, |
83 | 2 | ConvoyVerb::Create { template, inputs, repository_url, r#ref } => Ok(Resolved::NeedsContext { |
84 | 2 | command: Command { |
85 | 2 | node_id: None, |
86 | 2 | provisioning_target: None, |
87 | 2 | context_repo: None, |
88 | 2 | action: CommandAction::ConvoyCreate { name: self.subject, workflow_ref: template, inputs, repository_url, r#ref }, |
89 | 2 | }, |
90 | 2 | repo: RepoContext::None, |
91 | 2 | host: HostResolution::Local, |
92 | 2 | }), |
93 | | } |
94 | 6 | } |
95 | | } |
96 | | |
97 | | impl std::fmt::Display for ConvoyNoun { |
98 | 3 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
99 | 3 | write!(f, "convoy {}", self.subject)?0 ; |
100 | 3 | match &self.verb { |
101 | 1 | ConvoyVerb::Task(task) => { |
102 | 1 | write!(f, " task {}", task.subject)?0 ; |
103 | 1 | match &task.verb { |
104 | 1 | ConvoyTaskVerb::Complete { message } => { |
105 | 1 | write!(f, " complete")?0 ; |
106 | 1 | if let Some(message0 ) = message { |
107 | 0 | write!(f, " --message {message}")?; |
108 | 1 | } |
109 | | } |
110 | | } |
111 | | } |
112 | 2 | ConvoyVerb::Create { template, inputs, repository_url, r#ref } => { |
113 | 2 | write!(f, " create --template {}", quote_value(template))?0 ; |
114 | 2 | for (k, v) in inputs { |
115 | 2 | write!(f, " --input {}", quote_value(&format!("{k}={v}")))?0 ; |
116 | | } |
117 | 2 | if let Some(url) = repository_url { |
118 | 2 | write!(f, " --repo {}", quote_value(url))?0 ; |
119 | 0 | } |
120 | 2 | if let Some(reference1 ) = r#ref { |
121 | 1 | write!(f, " --ref {}", quote_value(reference))?0 ; |
122 | 1 | } |
123 | | } |
124 | | } |
125 | 3 | Ok(()) |
126 | 3 | } |
127 | | } |
128 | | |
129 | | #[cfg(test)] |
130 | | mod tests { |
131 | | use clap::Parser; |
132 | | use flotilla_protocol::{Command, CommandAction}; |
133 | | |
134 | | use super::ConvoyNoun; |
135 | | use crate::{ |
136 | | resolved::{HostResolution, RepoContext}, |
137 | | test_utils::assert_round_trip, |
138 | | Resolved, |
139 | | }; |
140 | | |
141 | 5 | fn parse(args: &[&str]) -> ConvoyNoun { |
142 | 5 | ConvoyNoun::try_parse_from(args).expect("should parse") |
143 | 5 | } |
144 | | |
145 | | #[test] |
146 | 1 | fn convoy_task_complete_resolves() { |
147 | 1 | let resolved = parse(&["convoy", "convoy-a", "task", "implement", "complete"]).resolve().expect("resolve"); |
148 | 1 | assert_eq!(resolved, Resolved::NeedsContext { |
149 | 1 | command: Command { |
150 | 1 | node_id: None, |
151 | 1 | provisioning_target: None, |
152 | 1 | context_repo: None, |
153 | 1 | action: CommandAction::ConvoyTaskComplete { convoy: "convoy-a".into(), task: "implement".into(), message: None }, |
154 | 1 | }, |
155 | 1 | repo: RepoContext::None, |
156 | 1 | host: HostResolution::Local, |
157 | 1 | }); |
158 | 1 | } |
159 | | |
160 | | #[test] |
161 | 1 | fn convoy_task_complete_with_message_resolves() { |
162 | 1 | let resolved = parse(&["convoy", "convoy-a", "task", "implement", "complete", "--message", "done"]).resolve().expect("resolve"); |
163 | 1 | assert_eq!(resolved, Resolved::NeedsContext { |
164 | 1 | command: Command { |
165 | 1 | node_id: None, |
166 | 1 | provisioning_target: None, |
167 | 1 | context_repo: None, |
168 | 1 | action: CommandAction::ConvoyTaskComplete { |
169 | 1 | convoy: "convoy-a".into(), |
170 | 1 | task: "implement".into(), |
171 | 1 | message: Some("done".into()), |
172 | 1 | }, |
173 | 1 | }, |
174 | 1 | repo: RepoContext::None, |
175 | 1 | host: HostResolution::Local, |
176 | 1 | }); |
177 | 1 | } |
178 | | |
179 | | #[test] |
180 | 1 | fn round_trip_complete() { |
181 | 1 | assert_round_trip::<ConvoyNoun>(&["convoy", "convoy-a", "task", "implement", "complete"]); |
182 | 1 | } |
183 | | |
184 | | #[test] |
185 | 1 | fn convoy_create_resolves() { |
186 | 1 | let resolved = parse(&[ |
187 | 1 | "convoy", |
188 | 1 | "my-convoy", |
189 | 1 | "create", |
190 | 1 | "--template", |
191 | 1 | "scratch", |
192 | 1 | "--input", |
193 | 1 | "topic=demo", |
194 | 1 | "--input", |
195 | 1 | "branch=foo", |
196 | 1 | "--repo", |
197 | 1 | "https://github.com/flotilla-org/flotilla.git", |
198 | 1 | "--ref", |
199 | 1 | "main", |
200 | 1 | ]) |
201 | 1 | .resolve() |
202 | 1 | .expect("resolve"); |
203 | 1 | assert_eq!(resolved, Resolved::NeedsContext { |
204 | 1 | command: Command { |
205 | 1 | node_id: None, |
206 | 1 | provisioning_target: None, |
207 | 1 | context_repo: None, |
208 | 1 | action: CommandAction::ConvoyCreate { |
209 | 1 | name: "my-convoy".into(), |
210 | 1 | workflow_ref: "scratch".into(), |
211 | 1 | inputs: vec![("topic".into(), "demo".into()), ("branch".into(), "foo".into())], |
212 | 1 | repository_url: Some("https://github.com/flotilla-org/flotilla.git".into()), |
213 | 1 | r#ref: Some("main".into()), |
214 | 1 | }, |
215 | 1 | }, |
216 | 1 | repo: RepoContext::None, |
217 | 1 | host: HostResolution::Local, |
218 | 1 | }); |
219 | 1 | } |
220 | | |
221 | | #[test] |
222 | 1 | fn convoy_create_minimal_resolves() { |
223 | 1 | let resolved = parse(&["convoy", "scratch-1", "create", "--template", "scratch"]).resolve().expect("resolve"); |
224 | 1 | assert_eq!(resolved, Resolved::NeedsContext { |
225 | 1 | command: Command { |
226 | 1 | node_id: None, |
227 | 1 | provisioning_target: None, |
228 | 1 | context_repo: None, |
229 | 1 | action: CommandAction::ConvoyCreate { |
230 | 1 | name: "scratch-1".into(), |
231 | 1 | workflow_ref: "scratch".into(), |
232 | 1 | inputs: vec![], |
233 | 1 | repository_url: None, |
234 | 1 | r#ref: None, |
235 | 1 | }, |
236 | 1 | }, |
237 | 1 | repo: RepoContext::None, |
238 | 1 | host: HostResolution::Local, |
239 | 1 | }); |
240 | 1 | } |
241 | | |
242 | | #[test] |
243 | 1 | fn round_trip_create() { |
244 | 1 | assert_round_trip::<ConvoyNoun>(&[ |
245 | 1 | "convoy", |
246 | 1 | "my-convoy", |
247 | 1 | "create", |
248 | 1 | "--template", |
249 | 1 | "scratch", |
250 | 1 | "--input", |
251 | 1 | "topic=demo", |
252 | 1 | "--repo", |
253 | 1 | "https://example.com/repo.git", |
254 | 1 | "--ref", |
255 | 1 | "main", |
256 | 1 | ]); |
257 | 1 | } |
258 | | |
259 | | #[test] |
260 | 1 | fn create_display_quotes_values_with_whitespace() { |
261 | 1 | let parsed = parse(&[ |
262 | 1 | "convoy", |
263 | 1 | "my-convoy", |
264 | 1 | "create", |
265 | 1 | "--template", |
266 | 1 | "scratch", |
267 | 1 | "--input", |
268 | 1 | "topic=my work", |
269 | 1 | "--repo", |
270 | 1 | "https://example.com/path with space.git", |
271 | 1 | ]); |
272 | 1 | let displayed = parsed.to_string(); |
273 | 1 | assert!(displayed.contains("--input \"topic=my work\""), "expected quoted input in {displayed:?}"); |
274 | 1 | assert!(displayed.contains("--repo \"https://example.com/path with space.git\""), "expected quoted repo in {displayed:?}"); |
275 | 1 | } |
276 | | } |