|
27 | 27 | # %% ../nbs/13_ship.ipynb |
28 | 28 | def ship(path='.', *, to='docker', domain=None, port=None, proxy='caddy', |
29 | 29 | 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): |
31 | 31 | 'Main orchestrator: detect → build → proxy → deploy' |
32 | 32 |
|
33 | 33 | result = { |
@@ -218,21 +218,15 @@ def wrapper(fn=res_fn, prov=resource_provider): |
218 | 218 | result['target'] = 'docker' |
219 | 219 | result['url'] = f'http://localhost:{app_port}' |
220 | 220 |
|
221 | | - elif to == 'vps': |
| 221 | + elif to == 'vps' or (to == 'hetzner' and host): |
222 | 222 | 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.') |
224 | 224 |
|
225 | 225 | print(f'Deploying to VPS {host}...') |
226 | | - from .vps import deploy |
| 226 | + from .vps import deploy as vps_deploy |
227 | 227 |
|
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) |
236 | 230 | result['status'] = 'deployed' |
237 | 231 | result['target'] = 'vps' |
238 | 232 | result['host'] = host |
@@ -277,6 +271,118 @@ def wrapper(fn=res_fn, prov=resource_provider): |
277 | 271 | result['target'] = 'aws' |
278 | 272 | result['aws'] = aws_result |
279 | 273 |
|
| 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 | + |
280 | 386 | else: |
281 | 387 | result['status'] = 'error' |
282 | 388 | result['error'] = f'Unknown deployment target: {to}' |
|
0 commit comments