Skip to content

Add --from-snapshot flag to create droplets from snapshots#53

Open
gwpl wants to merge 2 commits intotrailofbits:mainfrom
VariousForks:i52-create-from-snapshot
Open

Add --from-snapshot flag to create droplets from snapshots#53
gwpl wants to merge 2 commits intotrailofbits:mainfrom
VariousForks:i52-create-from-snapshot

Conversation

@gwpl
Copy link
Contributor

@gwpl gwpl commented Mar 23, 2026

Summary

AI Agent with Greg: We wanted to clone a perfectly configured droplet 10 times — like a VM templating system, but with dropkit ergonomics. Turns out dropkit create always injects cloud-init, which on a snapshot triggers user creation failures, .zshrc carpet-bombing, and an unconditional reboot. Now there's a --from-snapshot flag that knows when to leave well enough alone. 🧬

Closes #52

  • Add --from-snapshot <snapshot-id> flag to dropkit create
  • When used: calls create_droplet_from_snapshot() (no user_data), skips cloud-init rendering and monitoring
  • Mutually exclusive with --image (clear error if both provided)
  • All post-creation steps still work: wait for active, SSH config, project assignment, Tailscale setup

Usage: clone workflow

# Snapshot your golden image (via DO console or doctl)
# Then clone it with dropkit:
dropkit create worker-1 --from-snapshot 12345678 --size s-4vcpu-8gb
dropkit create worker-2 --from-snapshot 12345678 --size s-4vcpu-8gb

# Or in a loop:
for i in $(seq 1 10); do
  dropkit create "worker-$i" --from-snapshot 12345678 --region ams3
done

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 unconditional reboot

DigitalOcean assigns a new droplet ID to snapshot-based instances, so cloud-init detects a "first boot" and re-runs everything. The --from-snapshot flag avoids this by not sending user_data at all.

See #52 for discussion of alternative approaches (idempotent template, --no-cloud-init flag, dedicated clone command).

Test plan

  • uv run ruff check dropkit/main.py — passed
  • uv run ruff format --check dropkit/main.py — passed
  • uv run ty check dropkit/main.py — passed
  • uv run pytest tests/ -v — 288 passed, 31% coverage (above 29% minimum)
  • Manual test: dropkit create test --from-snapshot <id> — verify droplet created, SSH config added, no cloud-init
  • Manual test: dropkit create test --from-snapshot <id> --image ubuntu-25-10-x64 — verify mutual exclusion error
  • Manual test: dropkit 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

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>
@gwpl gwpl requested a review from ret2libc as a code owner March 23, 2026 22:42
Copy link
Collaborator

@ret2libc ret2libc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way to consolidate this with the existing "wake" code that does something very similar?

@gwpl
Copy link
Contributor Author

gwpl commented Mar 24, 2026

Always eager, Greg's AI Assistant reporting in! 🧬

Good call — there's definitely overlap between --from-snapshot in create and the wake command. Both use api.create_droplet_from_snapshot() and share post-creation steps (wait for active, SSH config, Tailscale setup).

The key differences that kept us from directly reusing wake:

Aspect wake create --from-snapshot
Snapshot source Looks up by dropkit-<name> naming convention + owner tag User provides arbitrary snapshot ID directly
Size/region Extracted from snapshot metadata tags (size:, regions) User specifies (or uses interactive prompts/defaults)
Post-restore Prompts to delete the snapshot No deletion (it's a template, not a hibernation artifact)
Tailscale Only if tailscale-lockdown tag present on snapshot Standard config-based decision

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 wake and create --from-snapshot call. That would reduce the duplication to just the "how to find/configure the snapshot" preamble.

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?

@gwpl
Copy link
Contributor Author

gwpl commented Mar 24, 2026

Greg asked us to go ahead and refactor to consolidate the shared plumbing between create --from-snapshot and wake. Pushing an update shortly!

…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>
@gwpl
Copy link
Contributor Author

gwpl commented Mar 24, 2026

Always eager, Greg's AI Assistant back with the refactor! 🔧

TL;DR

Extracted _create_from_snapshot_and_setup() helper that encapsulates the shared plumbing between create --from-snapshot and wake, eliminating ~90 lines of duplication.

What the helper does

_create_from_snapshot_and_setup(api, config, name, region, size, snapshot_id, tags_list, username, tailscale_enabled, verbose) handles:

  1. api.create_droplet_from_snapshot() call
  2. Wait for droplet to become active
  3. Extract public IP address
  4. Add SSH config entry (if config.ssh.auto_update)
  5. Optional Tailscale setup (controlled by tailscale_enabled param)

Returns (active_droplet, ip_address, tailscale_ip).

What remains command-specific

create --from-snapshot:

  • Project assignment (--project flag)
  • Cloud-init status tracking and summary messages
  • Passes tailscale_enabled based on --no-tailscale flag and config

wake:

  • Snapshot discovery preamble (find hibernated snapshot, extract region/size/tags)
  • Passes tailscale_enabled=False to helper, then handles Tailscale separately (only if was_tailscale_locked tag was on snapshot, with 10s SSH-readiness sleep)
  • Snapshot deletion prompt
  • Wake-specific success message

All 288 tests pass, ruff/ty clean. No behavioral changes.

Does this align with what you had in mind @ret2libc?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support creating droplets from snapshots (clone workflow)

2 participants