1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
mod commands;
use clap::{CommandFactory, Parser, Subcommand};
use commands::OutputFormat;
#[derive(Parser)]
#[command(
name = "kobe",
about = "Kubernetes cluster pool manager",
version = commands::cli_version()
)]
struct Cli {
/// One-off endpoint override using the selected target's auth.
#[arg(long, global = true, value_name = "URL")]
endpoint: Option<String>,
/// Named CLI target to use.
#[arg(long = "target", alias = "context", global = true, value_name = "NAME")]
target: Option<String>,
/// Output format.
#[arg(long, short = 'o', global = true, value_enum, default_value_t = OutputFormat::Text)]
output: OutputFormat,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Show status overview
Status,
/// Show CLI and endpoint versions
Version,
/// Authenticate with the Kobe service.
///
/// Default flow opens the system browser and listens on a localhost
/// callback. With --device, prints a verification URL + user code
/// for completing auth on any device with a browser — useful over
/// SSH, in CI, or on headless hosts.
Login {
/// Use the RFC 8628 Device Authorization Grant flow instead of
/// opening a local browser. Prints a URL + code for the user
/// to complete on a phone/laptop.
#[arg(long)]
device: bool,
},
/// Remove stored credentials. Also revokes the refresh + access
/// tokens at the IdP (RFC 7009) so a leaked token can't outlive
/// `kobe logout`.
Logout,
/// Lease a cluster from a pool and wait until it is ready
Lease {
/// Pool name (e.g. ci-small)
pool: Option<String>,
/// Lease TTL
#[arg(long, default_value = "1h")]
ttl: String,
/// Return immediately after creating the lease request
#[arg(long)]
no_wait: bool,
/// Maximum time to wait for the lease to become usable (e.g. 30s, 5m, 1h)
#[arg(long, value_name = "DURATION", conflicts_with = "no_wait")]
wait_timeout: Option<String>,
/// Write kubeconfig to this path (default: ~/.kube/kobe-{pool}-{short-lease}.yaml)
#[arg(long = "kubeconfig", value_name = "PATH")]
kubeconfig: Option<String>,
/// Name this lease (#107 P2). Unique among your active leases, so you can
/// reference it by name later: `kobe extend pr-106 30m`.
#[arg(long, value_name = "NAME")]
name: Option<String>,
/// Idempotent (#107 P3): with --name, reuse the existing active lease of
/// that name (extending its TTL) instead of failing on the duplicate —
/// "lease again means renew". Safe to call unconditionally at job start.
#[arg(long, requires = "name")]
ensure: bool,
/// Heartbeat-extend the lease until interrupted (#107 P3). Re-extends by
/// `--ttl` at half-TTL intervals until Ctrl-C or the server ceiling.
#[arg(long, conflicts_with = "no_wait")]
keepalive: bool,
},
/// Run a command while holding a lease, auto-releasing on exit (#107 P3).
///
/// Creates a lease, heartbeat-extends it for the command's lifetime, then
/// releases it (even on failure/signal). `kobe with-lease --ttl 1h -- kubectl get pods`.
WithLease {
/// Pool name (e.g. ci-small)
pool: Option<String>,
/// Lease TTL / heartbeat window
#[arg(long, default_value = "1h")]
ttl: String,
/// Command to run (after `--`), with the lease kubeconfig in KUBECONFIG.
#[arg(last = true, required = true)]
cmd: Vec<String>,
},
/// Extend the TTL of an active lease
///
/// TARGET selects the lease by id or pool. When omitted: if you hold a
/// single active lease it is used; otherwise you are prompted to pick one
/// (or, with `--output json`, the command errors and lists candidates).
Extend {
/// Lease id or pool to extend (optional when you hold one lease)
target: Option<String>,
/// Duration to add to the current expiry (e.g. 30m, 1h)
#[arg(long, default_value = "30m")]
ttl: String,
},
/// Release a cluster lease
Release {
/// Lease ID
lease_id: Option<String>,
},
/// Release all active leases and remove local Kobe lease kubeconfigs
Purge {
/// Skip the confirmation prompt
#[arg(long, short = 'y')]
yes: bool,
/// Only remove kubeconfigs whose lease no longer exists server-side
/// (phase Released or Expired, or absent from the server entirely).
/// Active leases are not released. Files in `~/.kube/kobe-*.yaml`
/// that Kobe never recorded itself are not touched. Use this to clean
/// up files left behind by TTL expiry.
#[arg(long)]
orphans_only: bool,
},
/// Manage CLI configuration
Config {
#[command(subcommand)]
action: Option<ConfigAction>,
},
}
#[derive(Subcommand)]
enum ConfigAction {
/// Show current configuration
View,
/// Export the saved configuration as JSON
Export {
/// Destination path, or '-' for stdout
path: Option<String>,
},
/// Import configuration from JSON
Import {
/// Source path, or '-' for stdin
path: Option<String>,
},
/// Edit configuration in the TUI
Edit {
/// Target name to edit (defaults to current target, else legacy config)
name: Option<String>,
},
/// List named targets
List,
/// Show the current named target
Current,
/// Select the current named target
Use {
/// Target name
name: String,
},
/// Create or replace a named target. By default writes to the
/// local `./.kobe.toml` so the definition follows the project;
/// pass `--global` to write to `~/.config/kobe/config.json`
/// instead (use this for endpoints you want available from any
/// directory).
Set {
/// Target name
name: String,
/// Kobe API endpoint
#[arg(long)]
endpoint: String,
/// Auth mode (none, token, oidc, ssh)
#[arg(long)]
auth: Option<String>,
/// Static bearer token for auth=token
#[arg(long)]
token: Option<String>,
/// SSH key fingerprint for auth=ssh
#[arg(long = "ssh-fingerprint")]
ssh_fingerprint: Option<String>,
/// Write to the global config file (`~/.config/kobe/config.json`)
/// instead of the local `./.kobe.toml`. Use for endpoints you
/// reuse across many projects.
#[arg(long)]
global: bool,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Reap session files whose parent shell has exited. Cheap (one
// readdir + a process-existence check per file) and idempotent;
// running it on every invocation keeps the cache directory tidy
// without needing a daemon or cron job.
commands::session::gc_dead_sessions();
let cli = Cli::parse();
let target = cli.target.as_deref();
let endpoint = cli.endpoint.as_deref();
let output = cli.output;
match cli.command {
Commands::Status => commands::status(target, endpoint, output).await,
Commands::Version => commands::version(target, endpoint, output).await,
Commands::Login { device } => commands::login(target, endpoint, device).await,
Commands::Logout => commands::logout(target, endpoint).await,
Commands::Lease {
pool,
ttl,
no_wait,
wait_timeout,
kubeconfig,
name,
ensure,
keepalive,
} => {
commands::lease_create(commands::LeaseCreateCommand {
pool: pool.as_deref(),
ttl: &ttl,
no_wait,
wait_timeout: wait_timeout.as_deref(),
kubeconfig_path: kubeconfig.as_deref(),
name: name.as_deref(),
ensure,
keepalive,
target_override: target,
endpoint_override: endpoint,
output,
})
.await
}
Commands::WithLease { pool, ttl, cmd } => {
commands::with_lease(commands::WithLeaseCommand {
pool: pool.as_deref(),
ttl: &ttl,
cmd: &cmd,
target_override: target,
endpoint_override: endpoint,
output,
})
.await
}
Commands::Extend { target: lease, ttl } => {
commands::extend(lease.as_deref(), &ttl, target, endpoint, output).await
}
Commands::Release { lease_id } => {
commands::release(lease_id.as_deref(), target, endpoint, output).await
}
Commands::Purge { yes, orphans_only } => {
commands::purge(target, endpoint, output, yes, orphans_only).await
}
Commands::Config { action } => match action {
Some(ConfigAction::View) => commands::config_show(target, output).await,
Some(ConfigAction::Export { path }) => {
commands::config_export(path.as_deref(), output).await
}
Some(ConfigAction::Import { path }) => {
commands::config_import(path.as_deref(), output).await
}
Some(ConfigAction::Edit { name }) => {
if let (Some(flag), Some(arg)) = (target, name.as_deref())
&& flag != arg
{
anyhow::bail!("Specify either --target {flag} or config edit {arg}, not both");
}
commands::config_interactive(name.as_deref().or(target))
}
Some(ConfigAction::List) => commands::config_list_targets(output).await,
Some(ConfigAction::Current) => commands::config_current_target(output).await,
Some(ConfigAction::Use { name }) => commands::config_use_target(&name, output).await,
Some(ConfigAction::Set {
name,
endpoint,
auth,
token,
ssh_fingerprint,
global,
}) => {
commands::config_set_target(
&name,
&endpoint,
auth.as_deref(),
token.as_deref(),
ssh_fingerprint.as_deref(),
global,
output,
)
.await
}
None => print_config_help(),
},
}
}
fn print_config_help() -> anyhow::Result<()> {
let mut cmd = Cli::command();
let config_cmd = cmd
.find_subcommand_mut("config")
.ok_or_else(|| anyhow::anyhow!("config command is not available"))?;
config_cmd.print_help()?;
println!();
Ok(())
}