diff --git a/fastops/gcp.py b/fastops/gcp.py new file mode 100644 index 0000000..62bdfc9 --- /dev/null +++ b/fastops/gcp.py @@ -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 diff --git a/fastops/ship.py b/fastops/ship.py index 38c55cf..1df4b1d 100644 --- a/fastops/ship.py +++ b/fastops/ship.py @@ -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 = { @@ -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}' diff --git a/fastops/teardown.py b/fastops/teardown.py new file mode 100644 index 0000000..6bd406c --- /dev/null +++ b/fastops/teardown.py @@ -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}')