Skip to content

Commit ee33988

Browse files
committed
CLI snapshot command + updated CI
1 parent 3772a25 commit ee33988

9 files changed

Lines changed: 1018 additions & 96 deletions

File tree

.claude/scheduled_tasks.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"sessionId":"752c70fd-8f83-4e36-a3c1-2848f12bcb75","pid":67212,"acquiredAt":1776432295230}

.github/workflows/release.yml

Lines changed: 15 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,7 @@
1-
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
2-
#
3-
# Copyright 2022-2024, axodotdev
4-
# SPDX-License-Identifier: MIT or Apache-2.0
5-
#
6-
# CI that:
7-
#
8-
# * checks for a Git Tag that looks like a release
9-
# * builds artifacts with dist (archives, installers, hashes)
10-
# * uploads those artifacts to temporary workflow zip
11-
# * on success, uploads the artifacts to a GitHub Release
12-
#
13-
# Note that the GitHub Release will be created with a generated
14-
# title/body based on your changelogs.
15-
161
name: Release
172
permissions:
183
"contents": "write"
194

20-
# This task will run whenever you push a git tag that looks like a version
21-
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
22-
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
23-
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
24-
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
25-
#
26-
# If PACKAGE_NAME is specified, then the announcement will be for that
27-
# package (erroring out if it doesn't have the given version or isn't dist-able).
28-
#
29-
# If PACKAGE_NAME isn't specified, then the announcement will be for all
30-
# (dist-able) packages in the workspace with that version (this mode is
31-
# intended for workspaces with only one dist-able package, or with all dist-able
32-
# packages versioned/released in lockstep).
33-
#
34-
# If you push multiple tags at once, separate instances of this workflow will
35-
# spin up, creating an independent announcement for each one. However, GitHub
36-
# will hard limit this to 3 tags per commit, as it will assume more tags is a
37-
# mistake.
38-
#
39-
# If there's a prerelease-style suffix to the version, then the release(s)
40-
# will be marked as a prerelease.
415
on:
426
pull_request:
437
push:
@@ -61,57 +25,39 @@ jobs:
6125
persist-credentials: false
6226
submodules: recursive
6327
- name: Install dist
64-
# we specify bash to get pipefail; it guards against the `curl` command
65-
# failing. otherwise `sh` won't catch that `curl` returned non-0
6628
shell: bash
6729
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
6830
- name: Cache dist
69-
uses: actions/upload-artifact@v6
31+
uses: actions/upload-artifact@v7
7032
with:
7133
name: cargo-dist-cache
7234
path: ~/.cargo/bin/dist
73-
# sure would be cool if github gave us proper conditionals...
74-
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
75-
# functionality based on whether this is a pull_request, and whether it's from a fork.
76-
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
77-
# but also really annoying to build CI around when it needs secrets to work right.)
7835
- id: plan
7936
run: |
8037
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
8138
echo "dist ran successfully"
8239
cat plan-dist-manifest.json
8340
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
8441
- name: "Upload dist-manifest.json"
85-
uses: actions/upload-artifact@v6
42+
uses: actions/upload-artifact@v7
8643
with:
8744
name: artifacts-plan-dist-manifest
8845
path: plan-dist-manifest.json
8946

90-
# Build and packages all the platform-specific things
9147
build-local-artifacts:
9248
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
93-
# Let the initial task tell us to not run (currently very blunt)
9449
needs:
9550
- plan
9651
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
9752
strategy:
9853
fail-fast: false
99-
# Target platforms/runners are computed by dist in create-release.
100-
# Each member of the matrix has the following arguments:
101-
#
102-
# - runner: the github runner
103-
# - dist-args: cli flags to pass to dist
104-
# - install-dist: expression to run to install dist on the runner
105-
#
106-
# Typically there will be:
107-
# - 1 "global" task that builds universal installers
108-
# - N "local" tasks that build each platform's binaries and platform-specific installers
10954
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
11055
runs-on: ${{ matrix.runner }}
11156
container: ${{ matrix.container && matrix.container.image || null }}
11257
env:
11358
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
11459
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
60+
AWS_LC_SYS_PREBUILT_NASM: 1
11561
permissions:
11662
"attestations": "write"
11763
"contents": "read"
@@ -133,9 +79,8 @@ jobs:
13379
fi
13480
- name: Install dist
13581
run: ${{ matrix.install_dist.run }}
136-
# Get the dist-manifest
13782
- name: Fetch local artifacts
138-
uses: actions/download-artifact@v7
83+
uses: actions/download-artifact@v8
13984
with:
14085
pattern: artifacts-*
14186
path: target/distrib/
@@ -149,14 +94,11 @@ jobs:
14994
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
15095
echo "dist ran successfully"
15196
- name: Attest
152-
uses: actions/attest-build-provenance@v3
97+
uses: actions/attest-build-provenance@v4
15398
with:
15499
subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*"
155100
- id: cargo-dist
156101
name: Post-build
157-
# We force bash here just because github makes it really hard to get values up
158-
# to "real" actions without writing to env-vars, and writing to env-vars has
159-
# inconsistent syntax between shell and powershell.
160102
shell: bash
161103
run: |
162104
# Parse out what we just built and upload it to scratch storage
@@ -166,14 +108,13 @@ jobs:
166108
167109
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
168110
- name: "Upload artifacts"
169-
uses: actions/upload-artifact@v6
111+
uses: actions/upload-artifact@v7
170112
with:
171113
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
172114
path: |
173115
${{ steps.cargo-dist.outputs.paths }}
174116
${{ env.BUILD_MANIFEST_NAME }}
175117
176-
# Build and package all the platform-agnostic(ish) things
177118
build-global-artifacts:
178119
needs:
179120
- plan
@@ -188,14 +129,13 @@ jobs:
188129
persist-credentials: false
189130
submodules: recursive
190131
- name: Install cached dist
191-
uses: actions/download-artifact@v7
132+
uses: actions/download-artifact@v8
192133
with:
193134
name: cargo-dist-cache
194135
path: ~/.cargo/bin/
195136
- run: chmod +x ~/.cargo/bin/dist
196-
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
197137
- name: Fetch local artifacts
198-
uses: actions/download-artifact@v7
138+
uses: actions/download-artifact@v8
199139
with:
200140
pattern: artifacts-*
201141
path: target/distrib/
@@ -213,7 +153,7 @@ jobs:
213153
214154
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
215155
- name: "Upload artifacts"
216-
uses: actions/upload-artifact@v6
156+
uses: actions/upload-artifact@v7
217157
with:
218158
name: artifacts-build-global
219159
path: |
@@ -242,7 +182,7 @@ jobs:
242182
name: arm-musl
243183
steps:
244184
- name: Checkout
245-
uses: actions/checkout@v4
185+
uses: actions/checkout@v6
246186

247187
- name: Build
248188
uses: houseabsolute/actions-rust-cross@v1
@@ -266,14 +206,12 @@ jobs:
266206
with:
267207
files: target/${{ matrix.target }}/release/stalwart-cli-${{ matrix.name }}.tar.gz
268208

269-
# Determines if we should publish/announce
270209
host:
271210
needs:
272211
- plan
273212
- build-local-artifacts
274213
- build-global-artifacts
275214
- extra-targets
276-
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
277215
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
278216
env:
279217
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -286,14 +224,13 @@ jobs:
286224
persist-credentials: false
287225
submodules: recursive
288226
- name: Install cached dist
289-
uses: actions/download-artifact@v7
227+
uses: actions/download-artifact@v8
290228
with:
291229
name: cargo-dist-cache
292230
path: ~/.cargo/bin/
293231
- run: chmod +x ~/.cargo/bin/dist
294-
# Fetch artifacts from scratch-storage
295232
- name: Fetch artifacts
296-
uses: actions/download-artifact@v7
233+
uses: actions/download-artifact@v8
297234
with:
298235
pattern: artifacts-*
299236
path: target/distrib/
@@ -306,14 +243,12 @@ jobs:
306243
cat dist-manifest.json
307244
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
308245
- name: "Upload dist-manifest.json"
309-
uses: actions/upload-artifact@v6
246+
uses: actions/upload-artifact@v7
310247
with:
311-
# Overwrite the previous copy
312248
name: artifacts-dist-manifest
313249
path: dist-manifest.json
314-
# Create a GitHub Release while uploading all files to it
315250
- name: "Download GitHub Artifacts"
316-
uses: actions/download-artifact@v7
251+
uses: actions/download-artifact@v8
317252
with:
318253
pattern: artifacts-*
319254
path: artifacts
@@ -351,15 +286,12 @@ jobs:
351286
persist-credentials: true
352287
repository: "stalwartlabs/homebrew-tap"
353288
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
354-
# So we have access to the formula
355289
- name: Fetch homebrew formulae
356-
uses: actions/download-artifact@v7
290+
uses: actions/download-artifact@v8
357291
with:
358292
pattern: artifacts-*
359293
path: Formula/
360294
merge-multiple: true
361-
# This is extra complex because you can make your Formula name not match your app name
362-
# so we need to find releases with a *.rb file, and publish with that filename.
363295
- name: Commit formula files
364296
run: |
365297
git config --global user.name "${GITHUB_USER}"
@@ -385,9 +317,6 @@ jobs:
385317
- plan
386318
- host
387319
- publish-homebrew-formula
388-
# use "always() && ..." to allow us to wait for all publish jobs while
389-
# still allowing individual publish jobs to skip themselves (for prereleases).
390-
# "host" however must run to completion, no skipping allowed!
391320
if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }}
392321
runs-on: "ubuntu-22.04"
393322
env:

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ The tool fetches the server's schema on first use and derives every command, val
4141
| `get` / `query` | Fetch a single object or list / filter many. |
4242
| `create` / `update` / `delete` | Single-object mutations. |
4343
| `apply` | Apply a JSON plan of bulk creates, updates, and destroys (intended for Ansible, Terraform, NixOS, Pulumi, and CI/CD pipelines). |
44+
| `snapshot` | Export live server state as an `apply`-ready JSON plan. Useful for backups, cross-environment promotion, and round-trip disaster-recovery rehearsals. |
4445

4546
Output is human-friendly by default (sectioned, with color when stdout is a TTY) and switches to compact JSON or NDJSON for machine consumption.
4647

@@ -81,6 +82,8 @@ stalwart-cli create domain --field name=example.com --field isEnabled=true
8182
stalwart-cli update domain <id> --field description='Primary'
8283
stalwart-cli delete domain --ids <id>
8384
stalwart-cli apply --file plan.json # bulk apply
85+
stalwart-cli snapshot Tenant Domain \ # export state as an apply plan
86+
--output backup.json
8487
```
8588

8689
## Documentation

src/cli/mod.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ pub enum Command {
7070
Describe(DescribeArgs),
7171
/// Apply a bulk plan of creates, updates, and destroys from a JSON file
7272
Apply(ApplyArgs),
73+
/// Snapshot one or more object types into a plan file consumable by `apply`
74+
Snapshot(SnapshotArgs),
7375
}
7476

7577
#[derive(Args, Debug)]
@@ -187,3 +189,34 @@ pub struct ApplyArgs {
187189
#[arg(long)]
188190
pub progress: bool,
189191
}
192+
193+
#[derive(Args, Debug)]
194+
pub struct SnapshotArgs {
195+
/// Object types to include (positional, at least one). Use bare object
196+
/// names (`Domain`, `Account`, ...). For multi-variant types, all variants
197+
/// are included. View / variant slash forms are rejected.
198+
#[arg(required = true, value_name = "OBJECT")]
199+
pub objects: Vec<String>,
200+
201+
/// Write the plan to this path instead of stdout
202+
#[arg(long, value_name = "PATH")]
203+
pub output: Option<PathBuf>,
204+
205+
/// Skip the destroy block at the top of the plan
206+
#[arg(long)]
207+
pub no_destroys: bool,
208+
209+
/// Include secret field values as returned by the server (default: strip)
210+
#[arg(long)]
211+
pub include_secrets: bool,
212+
213+
/// Comma-separated types whose references may be left unresolved. Any
214+
/// reference to one of these types found in the data is dropped from the
215+
/// exported plan.
216+
#[arg(long, value_name = "TYPES", value_delimiter = ',')]
217+
pub allow_unresolved: Vec<String>,
218+
219+
/// Suppress progress output on stderr
220+
#[arg(long)]
221+
pub quiet: bool,
222+
}

src/commands/apply.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,6 @@ impl Summary {
336336
}
337337
}
338338

339-
/// Collect all `#id` references in a value tree (string values and object keys).
340339
fn collect_refs(value: &Value, out: &mut HashSet<String>) {
341340
match value {
342341
Value::String(s) => {
@@ -365,9 +364,6 @@ fn collect_refs(value: &Value, out: &mut HashSet<String>) {
365364
}
366365
}
367366

368-
/// Build a per-request `createdIds` map: the subset of known created ids
369-
/// referenced by this request. Excludes ids being created in this very request
370-
/// (those resolve locally within the JMAP `create` context).
371367
fn request_created_ids(
372368
refs: &HashSet<String>,
373369
state_ids: &HashMap<String, String>,

src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod delete;
1010
pub mod describe;
1111
pub mod get;
1212
pub mod query;
13+
pub mod snapshot;
1314
pub mod update;
1415

1516
use crate::app::context::Context;
@@ -25,5 +26,6 @@ pub fn dispatch(ctx: Context, cmd: Command) -> CliResult<()> {
2526
Command::Update(args) => update::run(&ctx, &args),
2627
Command::Delete(args) => delete::run(&ctx, &args),
2728
Command::Apply(args) => apply::run(&ctx, &args),
29+
Command::Snapshot(args) => snapshot::run(&ctx, &args),
2830
}
2931
}

0 commit comments

Comments
 (0)