Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions fastops/gcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Google Cloud Platform CLI wrapper and opinionated resource builders"""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/14_gcp.ipynb.

# %% auto #0
__all__ = ['Gcp', 'gcloud', 'gcp_stack']

# %% ../nbs/14_gcp.ipynb
import os, json, subprocess
from .core import Cli

# %% ../nbs/14_gcp.ipynb
def callgcloud(*args):
'Run a gcloud command and return parsed JSON output'
cmd = ['gcloud'] + list(args) + ['--format=json']
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f'gcloud error: {result.stderr}')
return json.loads(result.stdout) if result.stdout.strip() else {}

class Gcp(Cli):
'Google Cloud CLI wrapper'
def _run(self, cmd, *args): return callgcloud(cmd, *args)

gcloud = Gcp()

# %% ../nbs/14_gcp.ipynb
def gcp_stack(name, *, image=None, port=8080, region='us-central1', project=None,
postgres=False, redis=False, domain=None, min_instances=0, max_instances=10,
memory='512Mi', cpu='1', env=None, service_account=None):
'Deploy a containerized app to GCP Cloud Run with optional managed services'
result = {'name': name, 'region': region, 'services': []}

proj = project or os.environ.get('GCLOUD_PROJECT') or os.environ.get('GOOGLE_CLOUD_PROJECT', '')
proj_args = ['--project', proj] if proj else []

app_env = dict(env or {})

# Optional: Cloud SQL (PostgreSQL)
if postgres:
db_instance = f'{name}-db'
db_password = os.environ.get('DB_PASSWORD', 'changeme')
try:
callgcloud('sql', 'instances', 'create', db_instance,
'--database-version=POSTGRES_16',
'--tier=db-f1-micro',
f'--region={region}',
'--root-password=' + db_password,
*proj_args)
except RuntimeError as e:
if 'already exists' not in str(e).lower(): raise

callgcloud('sql', 'databases', 'create', name,
f'--instance={db_instance}', *proj_args)

app_env['DATABASE_URL'] = f'postgresql://postgres:{db_password}@/{name}?host=/cloudsql/{proj}:{region}:{db_instance}'
result['services'].append({'type': 'cloud-sql', 'instance': db_instance})

# Optional: Memorystore (Redis)
if redis:
redis_instance = f'{name}-redis'
try:
callgcloud('redis', 'instances', 'create', redis_instance,
f'--region={region}',
'--size=1',
'--tier=basic',
*proj_args)
except RuntimeError as e:
if 'already exists' not in str(e).lower(): raise

redis_info = callgcloud('redis', 'instances', 'describe', redis_instance,
f'--region={region}', *proj_args)
redis_host = redis_info.get('host', 'localhost')
redis_port = redis_info.get('port', 6379)
app_env['REDIS_URL'] = f'redis://{redis_host}:{redis_port}'
result['services'].append({'type': 'memorystore', 'instance': redis_instance})

# Deploy to Cloud Run
if image:
deploy_args = [
'run', 'deploy', name,
f'--image={image}',
f'--port={port}',
f'--region={region}',
f'--memory={memory}',
f'--cpu={cpu}',
f'--min-instances={min_instances}',
f'--max-instances={max_instances}',
'--allow-unauthenticated',
*proj_args,
]

if service_account:
deploy_args.append(f'--service-account={service_account}')

if app_env:
env_str = ','.join(f'{k}={v}' for k, v in app_env.items())
deploy_args.append(f'--set-env-vars={env_str}')

if postgres:
deploy_args.append(f'--add-cloudsql-instances={proj}:{region}:{name}-db')

deploy_result = callgcloud(*deploy_args)

url = deploy_result.get('status', {}).get('url', f'https://{name}-{region}.run.app')
result['url'] = url
result['services'].append({'type': 'cloud-run', 'name': name, 'url': url})

# Custom domain mapping
if domain:
try:
callgcloud('run', 'domain-mappings', 'create',
f'--service={name}',
f'--domain={domain}',
f'--region={region}',
*proj_args)
except RuntimeError as e:
if 'already exists' not in str(e).lower(): raise
result['domain'] = domain

result['status'] = 'deployed'
return result
28 changes: 27 additions & 1 deletion fastops/ship.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
# %% ../nbs/13_ship.ipynb
def ship(path='.', *, to='docker', domain=None, port=None, proxy='caddy',
preset='production', tls=True, tunnel=False, security=False,
compliance=None, host=None, user='deploy', key=None, cloud=None, resources=None):
compliance=None, host=None, user='deploy', key=None, cloud=None, resources=None, **kw):
'Main orchestrator: detect → build → proxy → deploy'

result = {
Expand Down Expand Up @@ -277,6 +277,32 @@ def wrapper(fn=res_fn, prov=resource_provider):
result['target'] = 'aws'
result['aws'] = aws_result

elif to == 'gcp':
print('Deploying to GCP Cloud Run...')
from .gcp import gcp_stack

gcp_result = gcp_stack(
app_name,
image=kw.get('image'),
port=app_port,
region=kw.get('region', 'us-central1'),
project=kw.get('project'),
postgres=kw.get('postgres', False),
redis=kw.get('redis', False),
domain=domain,
min_instances=kw.get('min_instances', 0),
max_instances=kw.get('max_instances', 10),
memory=kw.get('memory', '512Mi'),
cpu=kw.get('cpu', '1'),
env=kw.get('env'),
service_account=kw.get('service_account'),
)

result['status'] = 'deployed'
result['target'] = 'gcp'
result['url'] = gcp_result.get('url', f'https://{domain}' if domain else '')
result['gcp'] = gcp_result

else:
result['status'] = 'error'
result['error'] = f'Unknown deployment target: {to}'
Expand Down
60 changes: 60 additions & 0 deletions fastops/teardown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Resource cleanup and teardown utilities"""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/15_teardown.ipynb.

# %% auto #0
__all__ = ['teardown_gcp', 'destroy_cloud_run', 'destroy_cloud_sql', 'destroy_memorystore']

# %% ../nbs/15_teardown.ipynb
from .gcp import callgcloud

# %% ../nbs/15_teardown.ipynb
def destroy_cloud_run(name, region, project=None):
'Delete a Cloud Run service'
proj_args = ['--project', project] if project else []
try:
callgcloud('run', 'services', 'delete', name,
'--region', region, '--quiet', *proj_args)
print(f'Deleted Cloud Run service: {name}')
except RuntimeError as e:
print(f'Error deleting Cloud Run service: {e}')

# %% ../nbs/15_teardown.ipynb
def destroy_cloud_sql(name, project=None):
'Delete a Cloud SQL instance'
proj_args = ['--project', project] if project else []
try:
callgcloud('sql', 'instances', 'delete', name,
'--quiet', *proj_args)
print(f'Deleted Cloud SQL instance: {name}')
except RuntimeError as e:
print(f'Error deleting Cloud SQL instance: {e}')

# %% ../nbs/15_teardown.ipynb
def destroy_memorystore(name, region, project=None):
'Delete a Memorystore (Redis) instance'
proj_args = ['--project', project] if project else []
try:
callgcloud('redis', 'instances', 'delete', name,
'--region', region, '--quiet', *proj_args)
print(f'Deleted Memorystore instance: {name}')
except RuntimeError as e:
print(f'Error deleting Memorystore instance: {e}')

# %% ../nbs/15_teardown.ipynb
def teardown_gcp(name, region='us-central1', project=None, postgres=False, redis=False):
'Tear down all GCP resources for an application'
print(f'Tearing down GCP resources for {name}...')

# Delete Cloud Run service
destroy_cloud_run(name, region, project)

# Delete Cloud SQL if it was created
if postgres:
destroy_cloud_sql(f'{name}-db', project)

# Delete Memorystore if it was created
if redis:
destroy_memorystore(f'{name}-redis', region, project)

print(f'Teardown complete for {name}')