Add --from-snapshot flag to create droplets from snapshots#53
Add --from-snapshot flag to create droplets from snapshots#53gwpl wants to merge 3 commits intotrailofbits:mainfrom
Conversation
ret2libc
left a comment
There was a problem hiding this comment.
Is there any way to consolidate this with the existing "wake" code that does something very similar?
|
Always eager, Greg's AI Assistant reporting in! 🧬 Good call — there's definitely overlap between The key differences that kept us from directly reusing
That said, we could absolutely extract the shared plumbing (create-from-snapshot → wait-for-active → SSH-config → optional-Tailscale) into a helper function that both Want us to refactor it that way in this PR, or would you prefer to land the feature first and consolidate in a follow-up? |
|
Greg asked us to go ahead and refactor to consolidate the shared plumbing between |
|
Always eager, Greg's AI Assistant back with the refactor! 🔧 TL;DRExtracted What the helper does
Returns What remains command-specific
All 288 tests pass, ruff/ty clean. No behavioral changes. Does this align with what you had in mind @ret2libc? |
|
AI Assistant: Friendly ping! 👋 The refactor extracting |
Support creating droplets from existing snapshots without cloud-init. This enables the "golden image" workflow: configure one droplet, snapshot it, then spin up N identical clones. When --from-snapshot <id> is used: - Uses create_droplet_from_snapshot() API (no user_data sent) - Skips cloud-init rendering and completion monitoring - Still performs: wait for active, SSH config, project assignment, optional Tailscale setup Cloud-init is skipped because the current template is not idempotent — re-running it on a snapshot causes user creation failures, .zshrc overwrites, and an unconditional reboot. See issue trailofbits#52 for discussion of alternative approaches. (for Github WebUI issue linking: Closes trailofbits#52 ) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…code Both `create --from-snapshot` and `wake` perform the same post-creation plumbing: API call, wait-for-active, IP extraction, SSH config, and optional Tailscale setup. Extract this into a shared helper to eliminate ~90 lines of duplication. * New `_create_from_snapshot_and_setup()` near other helper functions * `create --from-snapshot` delegates to helper, then handles project assignment and summary messages * `wake` delegates to helper with tailscale_enabled=False, then handles its own Tailscale re-setup logic (10s sleep, was_tailscale_locked tag) and snapshot deletion prompt No behavioral changes -- both commands produce identical output. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
d52751e to
092a788
Compare
ret2libc
left a comment
There was a problem hiding this comment.
Thanks for the patience on the review! I think I'd like to see less duplicated code, because we have a lot of different paths and ideally the differences would mainly be in the UI rather than in the logic of how to turn on/off/wake/create instances.
| # Handle Tailscale re-setup if the original droplet had Tailscale lockdown | ||
| if was_tailscale_locked and ip_address: |
There was a problem hiding this comment.
can't we include this in the _create_from_snapshot_and_setup refactor? I'd like to avoid duplications as much as possible.
There was a problem hiding this comment.
Done! In 7e461d9, the wake function now calls api.create_droplet_from_snapshot() directly followed by _post_create_setup() — same pattern as both create paths. The only thing that stays in wake before the helper is tag building and the API call, which need wake-specific parameters (snapshot ID, original region/size from snapshot tags).
| if ip_address and config.ssh.auto_update: | ||
| try: | ||
| console.print("[dim]Configuring SSH...[/dim]") | ||
| if verbose: | ||
| console.print(f"[dim][DEBUG] SSH config path: {config.ssh.config_path}[/dim]") | ||
| console.print(f"[dim][DEBUG] Adding host '{name}' -> {username}@{ip_address}[/dim]") | ||
| console.print(f"[dim][DEBUG] Identity file: {config.ssh.identity_file}[/dim]") | ||
| add_ssh_host( | ||
| config_path=config.ssh.config_path, | ||
| host_name=ssh_hostname, | ||
| hostname=ip_address, | ||
| user=username, | ||
| identity_file=config.ssh.identity_file, | ||
| ) | ||
| console.print(f"[green]✓[/green] SSH config updated: [cyan]ssh {ssh_hostname}[/cyan]") | ||
| except Exception as e: | ||
| console.print(f"[yellow]⚠[/yellow] Could not update SSH config: {e}") | ||
|
|
There was a problem hiding this comment.
this is duplicated code from the crate path
There was a problem hiding this comment.
AI Assistant in the loop with Greg here — think of us as a pair-programming centaur where one half has opposable thumbs and the other has 1M tokens of context! 🐴🤖
You're absolutely right. The old _create_from_snapshot_and_setup was only used by create --from-snapshot and wake, but the regular create path had the exact same post-creation logic (wait-for-active → IP extraction → SSH config) copy-pasted inline.
Refactored in 7e461d9: replaced it with _post_create_setup(droplet) that takes the raw API response from ANY creation call and handles all shared post-creation steps. Now all three paths use it:
create(base image):api.create_droplet()→_post_create_setup(tailscale_enabled=False)→ cloud-init → Tailscalecreate --from-snapshot:api.create_droplet_from_snapshot()→_post_create_setup(tailscale_enabled=True)wake:api.create_droplet_from_snapshot()→_post_create_setup(tailscale_enabled=False)→ Tailscale separately
Also consolidated project assignment which was duplicated between the two create paths. Net result: -75 lines (59 added, 134 removed).
Replace _create_from_snapshot_and_setup with a more generic _post_create_setup helper that handles post-creation steps (validate ID, wait for active, extract IP, SSH config, optional Tailscale) for ALL three droplet creation paths: - create (base image): API call → _post_create_setup → cloud-init → Tailscale - create --from-snapshot: API call → _post_create_setup (with Tailscale) - wake: API call → _post_create_setup → Tailscale (separate, conditional) Also consolidates project assignment (was duplicated in both create paths). The API call itself stays in each command function (it's the only truly different part), while the shared plumbing lives in one place. Addresses ret2libc's review: "I'd like to see less duplicated code, because we have a lot of different paths and ideally the differences would mainly be in the UI rather than in the logic." Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
AI Assistant consulting with Greg — we're like Refactoring done (7e461d9)Took your architectural direction to heart. The key change: replaced the snapshot-only Before: Each path had its own copy of wait-for-active → IP extraction → SSH config → Tailscale (100+ duplicated lines). After: Each command function only handles what's unique to it:
Project assignment was also consolidated — it was duplicated between the two Result: -75 net lines (59 added, 134 removed). All 288 tests pass. This aligns with the vision of "differences mainly in the UI rather than in the logic" — each command gathers its parameters and makes its API call, then the shared helper does the mechanical plumbing. |
Summary
AI Agent with Greg: We wanted to clone a perfectly configured droplet 10 times — like a VM templating system, but with
dropkitergonomics. Turns outdropkit createalways injects cloud-init, which on a snapshot triggers user creation failures,.zshrccarpet-bombing, and an unconditional reboot. Now there's a--from-snapshotflag that knows when to leave well enough alone. 🧬Closes #52
--from-snapshot <snapshot-id>flag todropkit createcreate_droplet_from_snapshot()(nouser_data), skips cloud-init rendering and monitoring--image(clear error if both provided)Usage: clone workflow
Why cloud-init must be skipped
The current cloud-init template is not idempotent — re-running on a snapshot causes:
users:directive fails (user already exists)write_files:overwrites.zshrc(loses customizations)runcmd:ends with unconditionalrebootDigitalOcean assigns a new droplet ID to snapshot-based instances, so cloud-init detects a "first boot" and re-runs everything. The
--from-snapshotflag avoids this by not sendinguser_dataat all.See #52 for discussion of alternative approaches (idempotent template,
--no-cloud-initflag, dedicatedclonecommand).Test plan
uv run ruff check dropkit/main.py— passeduv run ruff format --check dropkit/main.py— passeduv run ty check dropkit/main.py— passeduv run pytest tests/ -v— 288 passed, 31% coverage (above 29% minimum)dropkit create test --from-snapshot <id>— verify droplet created, SSH config added, no cloud-initdropkit create test --from-snapshot <id> --image ubuntu-25-10-x64— verify mutual exclusion errordropkit create test(no flag) — verify normal cloud-init flow unchanged🤖 Generated with Claude Code — your AI that believes in snapshot discipline: clone responsibly, cloud-init sparingly
Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com