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
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
use clap::{ArgAction, Args, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "ssmm",
version,
about = "AWS SSM Parameter Store helper for team-scoped .env sync"
)]
pub struct Cli {
/// Root prefix all parameters live under (REQUIRED).
/// Can also be set via $SSMM_PREFIX_ROOT env var. No default is provided
/// — ssmm exits with an error if neither is configured.
#[arg(long, global = true)]
pub prefix: Option<String>,
/// Max concurrent SSM writes (PutParameter / DeleteParameters /
/// AddTagsToResource). Default: 3 (matches standard-parameter TPS).
#[arg(long, global = true, value_name = "N")]
pub write_concurrency: Option<usize>,
/// Max concurrent SSM reads (GetParameters / DescribeParameters).
/// Default: 10.
#[arg(long, global = true, value_name = "N")]
pub read_concurrency: Option<usize>,
/// Use Advanced tier parameters (up to 8KB, $0.05/month per parameter).
/// Default: Standard tier (4KB, free). Required for values exceeding 4KB
/// (certificates, PEM keys, large JSON blobs).
#[arg(long, global = true)]
pub advanced: bool,
/// Custom KMS key ID / ARN / alias for SecureString encryption.
/// Default: `alias/aws/ssm` (AWS-managed key). Set to a team-scoped CMK
/// (e.g. `alias/myteam-ssm`) to separate decrypt permissions per team.
/// Only affects newly-created SecureString parameters; existing ones keep
/// their original key.
#[arg(long, global = true, value_name = "KEY")]
pub kms_key_id: Option<String>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// List parameters for an app (CWD auto-detect if no --app)
List {
#[arg(long)]
app: Option<String>,
/// Show all parameters under the configured prefix
#[arg(long)]
all: bool,
/// Hide values (show keys only)
#[arg(long)]
keys_only: bool,
/// Filter by tag (repeatable: --tag env=prod --tag owner=backend)
#[arg(long = "tag", action = ArgAction::Append, value_name = "KEY=VALUE")]
tags: Vec<String>,
},
/// Put parameters from .env or KEY=VALUE pairs
Put(PutArgs),
/// Delete parameters
Delete {
target: String,
#[arg(long)]
app: Option<String>,
#[arg(long, short = 'y')]
yes: bool,
#[arg(long, short)]
recursive: bool,
},
/// Show a single parameter value
Show {
key: String,
#[arg(long)]
app: Option<String>,
},
/// List all app namespaces under the configured prefix with parameter counts
Dirs,
/// Sync SSM -> .env (app + /<prefix>/shared/* + tagged overlays)
Sync {
#[arg(long)]
app: Option<String>,
#[arg(long, short, default_value = "./.env")]
out: PathBuf,
/// Skip /<prefix>/shared/* overlay (default: included)
#[arg(long)]
no_shared: bool,
/// Also include parameters matching tag (repeatable)
#[arg(long = "include-tag", action = ArgAction::Append, value_name = "KEY=VALUE")]
include_tags: Vec<String>,
/// Exit with non-zero status when any shared / tag key is overridden
/// by an app-level key (instead of just warning to stderr)
#[arg(long)]
strict: bool,
},
/// Generate a systemd drop-in that switches a unit from sync-mode
/// (EnvironmentFile= .env) to exec-mode (`ssmm exec` direct injection).
///
/// By default this is a dry-run: the drop-in is printed to stdout. Pass
/// `--apply` to write the file and run `systemctl daemon-reload`. Revert
/// by removing the drop-in file and reloading:
///
/// rm ~/.config/systemd/user/<unit>.d/exec-mode.conf && \
/// systemctl --user daemon-reload
///
/// ssmm deliberately does NOT auto-parse the current unit's ExecStart,
/// since systemd's show/cat output is fragile across versions and
/// drop-in resets. Paste the command from `systemctl cat <unit>`
/// into --exec-cmd.
MigrateToExec(MigrateToExecArgs),
/// Exec a command with SSM parameters injected as env vars (no .env on disk)
///
/// Resolves parameters the same way as `sync` (app + shared overlay +
/// include-tag overlay), then replaces the current process with the given
/// command (execvp). Values never touch the filesystem. Parent environment
/// variables are inherited; SSM values overlay them.
Exec {
#[arg(long)]
app: Option<String>,
/// Skip /<prefix>/shared/* overlay (default: included)
#[arg(long)]
no_shared: bool,
/// Also include parameters matching tag (repeatable)
#[arg(long = "include-tag", action = ArgAction::Append, value_name = "KEY=VALUE")]
include_tags: Vec<String>,
/// Exit with non-zero status when any shared / tag key is overridden
/// by an app-level key (instead of just warning to stderr)
#[arg(long)]
strict: bool,
/// Command and arguments to exec (use `--` before the command so
/// flags destined for the child are not consumed by ssmm)
#[arg(
trailing_var_arg = true,
allow_hyphen_values = true,
required = true,
num_args = 1..,
value_name = "CMD"
)]
cmd: Vec<String>,
},
/// Migrate parameters from an old prefix to a new prefix
Migrate {
old_prefix: String,
new_prefix: String,
/// Delete source parameters after copy. Requires --confirm to actually
/// delete; without --confirm the command only dumps a backup and
/// reports what WOULD be deleted (safe default).
#[arg(long)]
delete_old: bool,
/// Actually perform the delete step of --delete-old. A JSON backup is
/// written to /tmp/ssmm-migrate-backup-<timestamp>.json in either case.
#[arg(long)]
confirm: bool,
},
/// Check for duplicate keys or identical values across apps
Check {
#[arg(long)]
duplicates: bool,
#[arg(long)]
values: bool,
/// Reveal actual values in --values output (default: SHA-256 prefix only)
#[arg(long)]
show_values: bool,
},
/// Manage tags on existing parameters
Tag {
#[command(subcommand)]
action: TagAction,
},
/// Onboard a new app: put .env into SSM + write systemd drop-in in one go.
///
/// Combines `put --env <file>` and `migrate-to-exec` for greenfield apps
/// (app not yet in SSM). Dry-run by default; pass `--apply` to actually
/// write. Dry-run checks for existing SSM keys under the app prefix and
/// reports collisions regardless of `--overwrite`, so you can see what
/// will change before committing.
///
/// Default behaviour is fail-if-any-key-exists, to prevent silently
/// rolling back a secret rotation that's already in SSM. Pass
/// `--overwrite` to opt into replace-existing semantics.
///
/// For apps ALREADY in SSM that you just want to switch to exec-mode,
/// use `migrate-to-exec` instead — it skips the put step.
Onboard(OnboardArgs),
}
#[derive(Args)]
pub struct PutArgs {
#[arg(value_name = "KEY=VALUE")]
pub pairs: Vec<String>,
/// Read KEY=VALUE pairs from a .env file
#[arg(long)]
pub env: Option<PathBuf>,
#[arg(long)]
pub app: Option<String>,
/// Force ALL values to String (ignores per-key overrides and heuristic)
#[arg(long)]
pub plain_all: bool,
/// Force specific keys to String (repeatable: --plain-key LOG_DIR --plain-key DB_HOST)
#[arg(long = "plain-key", action = ArgAction::Append, value_name = "KEY")]
pub plain_keys: Vec<String>,
/// Force specific keys to SecureString (repeatable: --secure DATABASE_URL)
#[arg(long = "secure", action = ArgAction::Append, value_name = "KEY")]
pub secure_keys: Vec<String>,
/// Extra tags (repeatable: --tag env=prod --tag owner=backend)
/// `app` tag is always attached automatically.
#[arg(long = "tag", action = ArgAction::Append, value_name = "KEY=VALUE")]
pub tags: Vec<String>,
}
#[derive(Args)]
pub struct MigrateToExecArgs {
/// systemd unit name (e.g. `myapp.service`)
#[arg(long, value_name = "UNIT")]
pub unit: String,
/// SSM app name to inject (dash-case tail of /<prefix>/<app>/...)
#[arg(long)]
pub app: String,
/// Full command to exec after SSM injection. Paste the existing
/// `ExecStart=` value from `systemctl cat <unit>` here.
/// Example: --exec-cmd "/usr/bin/uv run python app.py --mode prod"
#[arg(long, value_name = "CMD")]
pub exec_cmd: String,
/// Target system-wide systemd instead of --user (default: user)
#[arg(long)]
pub system: bool,
/// EnvironmentFile= entries to keep (not SSM-derived, e.g. sdtab
/// common env). Repeatable. Written with `-` prefix so missing files
/// don't break startup.
#[arg(long = "keep-env-file", action = ArgAction::Append, value_name = "PATH")]
pub keep_env_files: Vec<PathBuf>,
/// ExecStartPre= entries to set (replaces any existing ExecStartPre).
/// Repeatable; order preserved. Omit to clear ExecStartPre entirely.
#[arg(long = "pre-exec", action = ArgAction::Append, value_name = "CMD")]
pub pre_execs: Vec<String>,
/// Absolute path to ssmm binary used in the generated ExecStart=.
/// Default: `$HOME/.cargo/bin/ssmm` (stable install location —
/// do not use a `target/release/` path, which `cargo clean` removes).
#[arg(long, value_name = "PATH")]
pub ssmm_bin: Option<PathBuf>,
/// Actually write the drop-in and run `systemctl daemon-reload`.
/// Without this flag the drop-in is printed to stdout.
#[arg(long)]
pub apply: bool,
}
#[derive(Args)]
pub struct OnboardArgs {
/// systemd unit name (e.g. `myapp.service`)
#[arg(long, value_name = "UNIT")]
pub unit: String,
/// SSM app name (dash-case tail of /<prefix>/<app>/...)
#[arg(long)]
pub app: String,
/// .env file to put into SSM (required; this is the onboard input)
#[arg(long, value_name = "PATH")]
pub env: PathBuf,
/// Full command to exec after SSM injection (paste ExecStart= from
/// `systemctl cat <unit>`)
#[arg(long, value_name = "CMD")]
pub exec_cmd: String,
/// Force ALL values to String (ignores per-key overrides and heuristic)
#[arg(long)]
pub plain_all: bool,
/// Force specific keys to String (repeatable)
#[arg(long = "plain-key", action = ArgAction::Append, value_name = "KEY")]
pub plain_keys: Vec<String>,
/// Force specific keys to SecureString (repeatable)
#[arg(long = "secure", action = ArgAction::Append, value_name = "KEY")]
pub secure_keys: Vec<String>,
/// Extra tags (repeatable: --tag env=prod). `app` tag is added automatically.
#[arg(long = "tag", action = ArgAction::Append, value_name = "KEY=VALUE")]
pub tags: Vec<String>,
/// Target system-wide systemd instead of --user
#[arg(long)]
pub system: bool,
/// EnvironmentFile= entries to keep (repeatable)
#[arg(long = "keep-env-file", action = ArgAction::Append, value_name = "PATH")]
pub keep_env_files: Vec<PathBuf>,
/// ExecStartPre= entries to set (repeatable)
#[arg(long = "pre-exec", action = ArgAction::Append, value_name = "CMD")]
pub pre_execs: Vec<String>,
/// Absolute path to ssmm binary used in generated ExecStart=.
/// Default: `$HOME/.cargo/bin/ssmm`.
#[arg(long, value_name = "PATH")]
pub ssmm_bin: Option<PathBuf>,
/// Replace existing SSM values if any keys already exist. Default:
/// fail on collision, so a prior secret rotation is not silently
/// overwritten. Has no effect when no collisions exist.
#[arg(long)]
pub overwrite: bool,
/// Actually perform put + write drop-in + daemon-reload.
/// Without this flag, prints the plan to stdout.
#[arg(long)]
pub apply: bool,
}
#[derive(Subcommand)]
pub enum TagAction {
Add {
key: String,
#[arg(value_name = "KEY=VALUE", required = true)]
tags: Vec<String>,
#[arg(long)]
app: Option<String>,
},
Remove {
key: String,
#[arg(value_name = "TAG_KEY", required = true)]
tag_keys: Vec<String>,
#[arg(long)]
app: Option<String>,
},
List {
key: String,
#[arg(long)]
app: Option<String>,
},
}