Skip to content

Commit a562dd6

Browse files
CopilotKarthik777
andcommitted
Add Hetzner deployment target with auto-provisioning support
Co-authored-by: Karthik777 <7102951+Karthik777@users.noreply.github.com>
1 parent a44938c commit a562dd6

1 file changed

Lines changed: 118 additions & 12 deletions

File tree

fastops/ship.py

Lines changed: 118 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
# %% ../nbs/13_ship.ipynb
2828
def ship(path='.', *, to='docker', domain=None, port=None, proxy='caddy',
2929
preset='production', tls=True, tunnel=False, security=False,
30-
compliance=None, host=None, user='deploy', key=None, cloud=None, resources=None):
30+
compliance=None, host=None, user='deploy', key=None, cloud=None, resources=None, **kw):
3131
'Main orchestrator: detect → build → proxy → deploy'
3232

3333
result = {
@@ -218,21 +218,15 @@ def wrapper(fn=res_fn, prov=resource_provider):
218218
result['target'] = 'docker'
219219
result['url'] = f'http://localhost:{app_port}'
220220

221-
elif to == 'vps':
221+
elif to == 'vps' or (to == 'hetzner' and host):
222222
if not host:
223-
raise ValueError('host parameter required for VPS deployment')
223+
raise ValueError('host parameter required for VPS deployment. Use to="hetzner" to auto-provision.')
224224

225225
print(f'Deploying to VPS {host}...')
226-
from .vps import deploy
226+
from .vps import deploy as vps_deploy
227227

228-
# Deploy using existing vps.py deploy function
229-
deploy_result = deploy(
230-
host=host,
231-
user=user,
232-
key=key,
233-
path=path,
234-
compliance=compliance
235-
)
228+
deploy_path = kw.get('deploy_path', f'/srv/{app_name}')
229+
vps_deploy(compose, host, user=user, key=key, path=deploy_path)
236230
result['status'] = 'deployed'
237231
result['target'] = 'vps'
238232
result['host'] = host
@@ -277,6 +271,118 @@ def wrapper(fn=res_fn, prov=resource_provider):
277271
result['target'] = 'aws'
278272
result['aws'] = aws_result
279273

274+
elif to == 'hetzner':
275+
print('Deploying to Hetzner...')
276+
from .vps import vps_init, create, deploy as vps_deploy, server_ip, servers, hcloud_auth
277+
278+
# Server name from app name
279+
server_name = kw.get('server_name', app_name)
280+
server_type = kw.get('server_type', 'cx22') # €4/mo default — cheapest usable
281+
location = kw.get('location', 'nbg1') # Nuremberg, Germany — good EU default
282+
image = kw.get('image', 'ubuntu-24.04') # Latest LTS
283+
ssh_keys = kw.get('ssh_keys', [])
284+
pub_keys = kw.get('pub_keys', '')
285+
286+
# Read SSH public key from default location if not provided
287+
if not pub_keys:
288+
import os
289+
for key_path in ['~/.ssh/id_ed25519.pub', '~/.ssh/id_rsa.pub']:
290+
expanded = os.path.expanduser(key_path)
291+
if os.path.exists(expanded):
292+
pub_keys = open(expanded).read().strip()
293+
break
294+
295+
# Check if server already exists
296+
existing = None
297+
try:
298+
existing_servers = servers()
299+
for s in existing_servers:
300+
if s['name'] == server_name:
301+
existing = s
302+
break
303+
except Exception:
304+
pass # hcloud CLI might not be configured yet
305+
306+
if existing:
307+
print(f'Server {server_name} already exists at {existing["ip"]}')
308+
ip = existing['ip']
309+
else:
310+
# Generate cloud-init
311+
print(f'Provisioning Hetzner {server_type} in {location}...')
312+
313+
# Build cloud-init packages list
314+
init_packages = ['git', 'htop', 'curl']
315+
316+
# Generate cloud-init YAML
317+
cloud_init_yaml = vps_init(
318+
server_name,
319+
pub_keys=pub_keys,
320+
username=user,
321+
docker=True,
322+
packages=init_packages,
323+
cf_token=kw.get('cf_token'),
324+
)
325+
326+
# Create the server
327+
ip = create(
328+
server_name,
329+
image=image,
330+
server_type=server_type,
331+
location=location,
332+
cloud_init=cloud_init_yaml,
333+
ssh_keys=ssh_keys,
334+
)
335+
336+
# Wait for server to be ready (cloud-init takes ~60-90s)
337+
print(f'Server created at {ip}. Waiting for cloud-init to complete...')
338+
import time
339+
max_wait = kw.get('wait_timeout', 180) # 3 minutes default
340+
waited = 0
341+
interval = 10
342+
ready = False
343+
while waited < max_wait:
344+
time.sleep(interval)
345+
waited += interval
346+
try:
347+
from .vps import run_ssh
348+
result_cmd = run_ssh(ip, 'cloud-init status --wait 2>/dev/null || echo done',
349+
user=user, key=key)
350+
if 'done' in result_cmd or 'status: done' in result_cmd:
351+
ready = True
352+
break
353+
except Exception:
354+
pass # SSH not ready yet
355+
print(f' Waiting... ({waited}s)')
356+
357+
if not ready:
358+
print(f' Warning: cloud-init may not have completed after {max_wait}s. Proceeding anyway.')
359+
360+
# Configure DNS if domain provided
361+
if domain:
362+
try:
363+
from .cloudflare import dns_record
364+
print(f'Configuring DNS: {domain}{ip}')
365+
dns_record(domain.split('.')[-2] + '.' + domain.split('.')[-1],
366+
domain.split('.')[0] if '.' in domain and len(domain.split('.')) > 2 else '@',
367+
ip, proxied=kw.get('proxied', False))
368+
except Exception as e:
369+
print(f' DNS configuration skipped: {e}')
370+
371+
# Deploy the compose stack
372+
print(f'Deploying to {server_name} ({ip})...')
373+
from .vps import deploy as vps_deploy
374+
deploy_path = kw.get('deploy_path', f'/srv/{app_name}')
375+
vps_deploy(compose, ip, user=user, key=key, path=deploy_path)
376+
377+
result['status'] = 'deployed'
378+
result['target'] = 'hetzner'
379+
result['host'] = ip
380+
result['server_name'] = server_name
381+
result['server_type'] = server_type
382+
result['location'] = location
383+
result['deploy_path'] = deploy_path
384+
result['url'] = f'https://{domain}' if domain else f'http://{ip}:{app_port}'
385+
280386
else:
281387
result['status'] = 'error'
282388
result['error'] = f'Unknown deployment target: {to}'

0 commit comments

Comments
 (0)