outerbounds 0.10.14__py3-none-any.whl → 0.10.37__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of outerbounds might be problematic. Click here for more details.

@@ -0,0 +1,2155 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import base64
5
+ from enum import Enum
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+ import requests
8
+ from outerbounds._vendor import click
9
+ from ..utils import metaflowconfig
10
+ from ..utils.schema import (
11
+ CommandStatus,
12
+ OuterboundsCommandResponse,
13
+ OuterboundsCommandStatus,
14
+ )
15
+
16
+
17
+ class IntegrationOperation(str, Enum):
18
+ """Enum for integration operations."""
19
+
20
+ CREATE = "create"
21
+ UPDATE = "update"
22
+
23
+
24
+ @click.group()
25
+ def cli(**kwargs):
26
+ pass
27
+
28
+
29
+ @click.group(help="Manage resource integrations")
30
+ def integrations(**kwargs):
31
+ pass
32
+
33
+
34
+ def get_integration_api_url(config_dir: str, profile: str, perimeter_id: str) -> str:
35
+ """Constructs the API URL for resource integrations."""
36
+ api_url = metaflowconfig.get_sanitized_url_from_config(
37
+ config_dir, profile, "OBP_API_SERVER"
38
+ )
39
+ return f"{api_url}/v1/perimeters/{perimeter_id}/resourceintegrations"
40
+
41
+
42
+ def get_auth_headers(config_dir: str, profile: str) -> Dict[str, str]:
43
+ """Returns headers with the metaflow token."""
44
+ token = metaflowconfig.get_metaflow_token_from_config(config_dir, profile)
45
+ return {
46
+ "x-api-key": token,
47
+ "Content-Type": "application/json",
48
+ }
49
+
50
+
51
+ def get_current_perimeter(config_dir: str, profile: str) -> str:
52
+ """Helper to get the current perimeter from config."""
53
+ path_to_config = metaflowconfig.get_ob_config_file_path(config_dir, profile)
54
+ if not os.path.exists(path_to_config):
55
+ return ""
56
+
57
+ with open(path_to_config, "r") as file:
58
+ ob_config_dict = json.load(file)
59
+ return ob_config_dict.get("OB_CURRENT_PERIMETER", "")
60
+
61
+
62
+ def get_cloud_provider_from_backend_type(backend_type: str) -> Optional[str]:
63
+ """Get the cloud provider from the secret backend type."""
64
+ if backend_type == "aws-secrets-manager":
65
+ return "aws"
66
+ elif backend_type == "gcp-secret-manager":
67
+ return "gcp"
68
+ elif backend_type == "az-key-vault":
69
+ return "azure"
70
+ return None
71
+
72
+
73
+ def get_cloud_credentials(
74
+ config_dir: str, profile: str, cloud_provider: str
75
+ ) -> Optional[Dict[str, Any]]:
76
+ """Get temporary cloud credentials from the auth server."""
77
+ token = metaflowconfig.get_metaflow_token_from_config(config_dir, profile)
78
+ auth_url = metaflowconfig.get_sanitized_url_from_config(
79
+ config_dir, profile, "OBP_AUTH_SERVER"
80
+ )
81
+
82
+ try:
83
+ response = requests.get(
84
+ f"{auth_url}/generate/{cloud_provider}",
85
+ headers={"x-api-key": token},
86
+ )
87
+ response.raise_for_status()
88
+ return response.json()
89
+ except Exception as e:
90
+ click.secho(
91
+ f"Failed to get {cloud_provider} credentials: {str(e)}", fg="red", err=True
92
+ )
93
+ return None
94
+
95
+
96
+ def fetch_secret_from_aws(
97
+ secret_arn: str, credentials: Dict[str, Any]
98
+ ) -> Optional[Dict[str, str]]:
99
+ """Fetch secret value from AWS Secrets Manager."""
100
+ try:
101
+ import boto3
102
+ from botocore.exceptions import ClientError
103
+
104
+ # The credentials from /generate/aws contain role_arn and token
105
+ # We need to assume the role using the web identity token
106
+ role_arn = credentials.get("role_arn")
107
+ web_identity_token = credentials.get("token")
108
+ region = credentials.get("region", "us-east-1")
109
+
110
+ if not role_arn or not web_identity_token:
111
+ click.secho("Invalid AWS credentials format", fg="red", err=True)
112
+ return None
113
+
114
+ # Use STS to assume role with web identity
115
+ sts_client = boto3.client("sts", region_name=region)
116
+ assumed_role = sts_client.assume_role_with_web_identity(
117
+ RoleArn=role_arn,
118
+ RoleSessionName="outerbounds-cli-secrets",
119
+ WebIdentityToken=web_identity_token,
120
+ )
121
+
122
+ # Create a new session with the assumed role credentials
123
+ session = boto3.Session(
124
+ aws_access_key_id=assumed_role["Credentials"]["AccessKeyId"],
125
+ aws_secret_access_key=assumed_role["Credentials"]["SecretAccessKey"],
126
+ aws_session_token=assumed_role["Credentials"]["SessionToken"],
127
+ region_name=region,
128
+ )
129
+
130
+ client = session.client("secretsmanager")
131
+ response = client.get_secret_value(SecretId=secret_arn)
132
+
133
+ if "SecretBinary" in response:
134
+ secret_data = json.loads(response["SecretBinary"])
135
+ else:
136
+ secret_data = json.loads(response["SecretString"])
137
+
138
+ return secret_data
139
+ except ImportError:
140
+ click.secho(
141
+ "boto3 is required to fetch AWS secrets. Install it with: pip install boto3",
142
+ fg="yellow",
143
+ err=True,
144
+ )
145
+ return None
146
+ except ClientError as e:
147
+ click.secho(f"Failed to fetch secret from AWS: {str(e)}", fg="red", err=True)
148
+ return None
149
+ except Exception as e:
150
+ click.secho(f"Error fetching AWS secret: {str(e)}", fg="red", err=True)
151
+ return None
152
+
153
+
154
+ def fetch_secret_from_gcp(
155
+ secret_name: str, credentials: Dict[str, Any]
156
+ ) -> Optional[Dict[str, str]]:
157
+ """Fetch secret value from GCP Secret Manager."""
158
+ try:
159
+ from google.cloud import secretmanager # type: ignore[import-untyped]
160
+ from google.auth import identity_pool # type: ignore[import-untyped]
161
+ import tempfile
162
+
163
+ # The credentials from /generate/gcp use workload identity federation
164
+ jwt_token = credentials.get("token")
165
+ project_number = credentials.get("gcpProjectNumber")
166
+ workload_pool = credentials.get("gcpWorkloadIdentityPool")
167
+ workload_provider = credentials.get("gcpWorkloadIdentityPoolProvider")
168
+ service_account_email = credentials.get("gcpServiceAccountEmail")
169
+
170
+ if not all(
171
+ [
172
+ jwt_token,
173
+ project_number,
174
+ workload_pool,
175
+ workload_provider,
176
+ service_account_email,
177
+ ]
178
+ ):
179
+ click.secho("Invalid GCP credentials format", fg="red", err=True)
180
+ return None
181
+
182
+ # Write the JWT token to a temporary file
183
+ with tempfile.NamedTemporaryFile(
184
+ mode="w", suffix=".txt", delete=False
185
+ ) as token_file:
186
+ token_file.write(jwt_token or "")
187
+ token_file_path = token_file.name
188
+
189
+ # Create external account credentials config
190
+ external_account_config = {
191
+ "type": "external_account",
192
+ "audience": f"//iam.googleapis.com/projects/{project_number}/locations/global/workloadIdentityPools/{workload_pool}/providers/{workload_provider}",
193
+ "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
194
+ "token_url": "https://sts.googleapis.com/v1/token",
195
+ "service_account_impersonation_url": f"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{service_account_email}:generateAccessToken",
196
+ "credential_source": {"file": token_file_path},
197
+ }
198
+
199
+ # Create a temporary file with the external account credentials
200
+ with tempfile.NamedTemporaryFile(
201
+ mode="w", suffix=".json", delete=False
202
+ ) as config_file:
203
+ json.dump(external_account_config, config_file)
204
+ config_file_path = config_file.name
205
+
206
+ try:
207
+ # Create credentials from the external account config
208
+ creds = identity_pool.Credentials.from_file(config_file_path)
209
+
210
+ # Create the Secret Manager client with these credentials
211
+ client = secretmanager.SecretManagerServiceClient(credentials=creds)
212
+ response = client.access_secret_version(
213
+ request={"name": f"{secret_name}/versions/latest"}
214
+ )
215
+ secret_data = json.loads(response.payload.data)
216
+
217
+ return secret_data
218
+ finally:
219
+ # Clean up temporary files
220
+ if os.path.exists(token_file_path):
221
+ os.unlink(token_file_path)
222
+ if os.path.exists(config_file_path):
223
+ os.unlink(config_file_path)
224
+ except ImportError:
225
+ click.secho(
226
+ "google-cloud-secret-manager is required to fetch GCP secrets. Install it with: pip install google-cloud-secret-manager",
227
+ fg="yellow",
228
+ err=True,
229
+ )
230
+ return None
231
+ except Exception as e:
232
+ click.secho(f"Error fetching GCP secret: {str(e)}", fg="red", err=True)
233
+ return None
234
+
235
+
236
+ def fetch_secret_from_azure(
237
+ secret_url: str, credentials: Dict[str, Any]
238
+ ) -> Optional[Dict[str, str]]:
239
+ """Fetch secret value from Azure Key Vault."""
240
+ try:
241
+ from azure.keyvault.secrets import SecretClient # type: ignore[import-not-found]
242
+ from azure.identity import ClientAssertionCredential # type: ignore[import-not-found]
243
+ from urllib.parse import urlparse
244
+ import tempfile
245
+
246
+ # Parse vault URL from the secret URL
247
+ parsed = urlparse(secret_url)
248
+ vault_url = f"{parsed.scheme}://{parsed.netloc}"
249
+ secret_name = parsed.path.split("/")[-1] if parsed.path else ""
250
+
251
+ # The credentials from /generate/azure contain a JWT token for workload identity
252
+ tenant_id = credentials.get("azureTenantId")
253
+ client_id = credentials.get("azureClientId")
254
+ jwt_token = credentials.get("token")
255
+ authority_host = credentials.get(
256
+ "azureAuthorityHost", "login.microsoftonline.com"
257
+ )
258
+
259
+ if not tenant_id or not client_id or not jwt_token:
260
+ click.secho("Invalid Azure credentials format", fg="red", err=True)
261
+ return None
262
+
263
+ # Create a credential using the JWT token
264
+ def token_callback():
265
+ return jwt_token
266
+
267
+ credential = ClientAssertionCredential(
268
+ tenant_id=tenant_id,
269
+ client_id=client_id,
270
+ func=token_callback,
271
+ authority=f"https://{authority_host}",
272
+ )
273
+
274
+ client = SecretClient(vault_url=vault_url, credential=credential)
275
+ secret = client.get_secret(secret_name)
276
+ secret_data = json.loads(secret.value)
277
+
278
+ return secret_data
279
+ except ImportError:
280
+ click.secho(
281
+ "azure-keyvault-secrets and azure-identity are required to fetch Azure secrets. Install them with: pip install azure-keyvault-secrets azure-identity",
282
+ fg="yellow",
283
+ err=True,
284
+ )
285
+ return None
286
+ except Exception as e:
287
+ click.secho(f"Error fetching Azure secret: {str(e)}", fg="red", err=True)
288
+ return None
289
+
290
+
291
+ def get_integrations_url(config_dir: str, profile: str) -> Optional[str]:
292
+ """Get the integrations URL from config."""
293
+ try:
294
+ # Try to get from environment variable first
295
+ integrations_url = os.environ.get("OBP_INTEGRATIONS_URL")
296
+ if integrations_url:
297
+ return integrations_url
298
+
299
+ # Try to construct it from the API server URL
300
+ api_url = metaflowconfig.get_sanitized_url_from_config(
301
+ config_dir, profile, "OBP_API_SERVER"
302
+ )
303
+ if api_url:
304
+ # The integrations URL is the same as the API server URL
305
+ return api_url
306
+
307
+ return None
308
+ except Exception as e:
309
+ return None
310
+
311
+
312
+ def fetch_integration_secret_metadata(
313
+ integration_name: str, perimeter: str, config_dir: str, profile: str
314
+ ) -> Optional[Tuple[str, str]]:
315
+ """Fetch secret metadata (resource ID and backend type) from the integrations API."""
316
+ try:
317
+ integrations_url = get_integrations_url(config_dir, profile)
318
+ if not integrations_url:
319
+ click.secho(
320
+ "Could not determine integrations URL from config",
321
+ fg="yellow",
322
+ err=True,
323
+ )
324
+ return None
325
+
326
+ # Call the secrets metadata endpoint
327
+ metadata_url = f"{integrations_url}/integrations/secrets/metadata"
328
+ headers = get_auth_headers(config_dir, profile)
329
+
330
+ payload = {
331
+ "perimeter_name": perimeter,
332
+ "integration_name": integration_name,
333
+ }
334
+
335
+ res = requests.get(metadata_url, json=payload, headers=headers)
336
+ res.raise_for_status()
337
+
338
+ data = res.json()
339
+ secret_resource_id = data.get("secret_resource_id")
340
+ secret_backend_type = data.get("secret_backend_type")
341
+
342
+ if not secret_resource_id or not secret_backend_type:
343
+ click.secho(
344
+ "Invalid response from secrets metadata API", fg="yellow", err=True
345
+ )
346
+ return None
347
+
348
+ return (secret_resource_id, secret_backend_type)
349
+
350
+ except requests.exceptions.HTTPError as e:
351
+ if e.response.status_code == 404:
352
+ click.secho(
353
+ f"Integration '{integration_name}' not found or has no secrets",
354
+ fg="yellow",
355
+ err=True,
356
+ )
357
+ else:
358
+ click.secho(
359
+ f"API error: {e.response.status_code} - {e.response.text}",
360
+ fg="red",
361
+ err=True,
362
+ )
363
+ return None
364
+ except Exception as e:
365
+ click.secho(f"Error fetching secret metadata: {str(e)}", fg="red", err=True)
366
+ return None
367
+
368
+
369
+ def fetch_integration_secret_values(
370
+ integration_name: str, perimeter: str, config_dir: str, profile: str
371
+ ) -> Optional[Dict[str, str]]:
372
+ """Fetch secret values for an integration."""
373
+ try:
374
+ # Get secret metadata from the integrations API
375
+ metadata = fetch_integration_secret_metadata(
376
+ integration_name, perimeter, config_dir, profile
377
+ )
378
+ if not metadata:
379
+ return None
380
+
381
+ secret_resource_id, secret_backend_type = metadata
382
+
383
+ # Determine cloud provider from the backend type
384
+ cloud_provider = get_cloud_provider_from_backend_type(secret_backend_type)
385
+ if not cloud_provider:
386
+ click.secho(
387
+ f"Unsupported secret backend type: {secret_backend_type}",
388
+ fg="yellow",
389
+ err=True,
390
+ )
391
+ return None
392
+
393
+ # Get credentials for the cloud provider
394
+ credentials = get_cloud_credentials(config_dir, profile, cloud_provider)
395
+ if not credentials:
396
+ return None
397
+
398
+ # Fetch the secret based on the backend type
399
+ if secret_backend_type == "aws-secrets-manager":
400
+ return fetch_secret_from_aws(secret_resource_id, credentials)
401
+ elif secret_backend_type == "gcp-secret-manager":
402
+ return fetch_secret_from_gcp(secret_resource_id, credentials)
403
+ elif secret_backend_type == "az-key-vault":
404
+ return fetch_secret_from_azure(secret_resource_id, credentials)
405
+ else:
406
+ click.secho(
407
+ f"Unsupported secret backend type: {secret_backend_type}",
408
+ fg="yellow",
409
+ err=True,
410
+ )
411
+ return None
412
+
413
+ except Exception as e:
414
+ click.secho(f"Error fetching integration secret: {str(e)}", fg="red", err=True)
415
+ return None
416
+
417
+
418
+ def handle_api_error(
419
+ e: Exception, step: CommandStatus, response: OuterboundsCommandResponse, output: str
420
+ ):
421
+ """Handles API errors and updates the response object."""
422
+ click.secho(f"API Request Failed: {e}", fg="red", err=True)
423
+ if isinstance(e, requests.exceptions.HTTPError):
424
+ try:
425
+ error_details = e.response.json()
426
+ click.secho(
427
+ f"Details: {json.dumps(error_details, indent=2)}", fg="red", err=True
428
+ )
429
+ except Exception:
430
+ click.secho(f"Response content: {e.response.text}", fg="red", err=True)
431
+
432
+ step.update(
433
+ status=OuterboundsCommandStatus.FAIL,
434
+ reason=f"API Request Failed: {str(e)}",
435
+ mitigation="Check your network connection, authentication token, and permissions.",
436
+ )
437
+ response.add_step(step)
438
+ if output == "json":
439
+ click.echo(json.dumps(response.as_dict(), indent=4))
440
+ sys.exit(1)
441
+
442
+
443
+ @integrations.command(help="List all resource integrations in a perimeter")
444
+ @click.option(
445
+ "--perimeter",
446
+ help="Perimeter ID. Defaults to the currently active perimeter.",
447
+ )
448
+ @click.option(
449
+ "-d",
450
+ "--config-dir",
451
+ default=os.path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
452
+ help="Path to Metaflow configuration directory",
453
+ show_default=True,
454
+ )
455
+ @click.option(
456
+ "-p",
457
+ "--profile",
458
+ default=os.environ.get("METAFLOW_PROFILE", ""),
459
+ help="The named metaflow profile",
460
+ )
461
+ @click.option(
462
+ "-o",
463
+ "--output",
464
+ default="",
465
+ help="Show output in the specified format.",
466
+ type=click.Choice(["json", ""]),
467
+ )
468
+ def list(perimeter=None, config_dir=None, profile=None, output=""):
469
+ response = OuterboundsCommandResponse()
470
+ step = CommandStatus(
471
+ "ListIntegrations",
472
+ OuterboundsCommandStatus.OK,
473
+ "Successfully listed integrations.",
474
+ )
475
+
476
+ if not perimeter:
477
+ perimeter = get_current_perimeter(config_dir, profile)
478
+
479
+ if not perimeter:
480
+ click.secho(
481
+ "No perimeter specified and no active perimeter found.", fg="red", err=True
482
+ )
483
+ sys.exit(1)
484
+
485
+ try:
486
+ url = get_integration_api_url(config_dir, profile, perimeter)
487
+ headers = get_auth_headers(config_dir, profile)
488
+
489
+ res = requests.get(url, headers=headers)
490
+ res.raise_for_status()
491
+
492
+ data = res.json()
493
+ integrations_list = data.get("resource_integrations", [])
494
+
495
+ if output == "json":
496
+ response.add_or_update_data("integrations", integrations_list)
497
+ response.add_step(step)
498
+ click.echo(json.dumps(response.as_dict(), indent=4))
499
+ else:
500
+ if not integrations_list:
501
+ click.secho("No integrations found.", fg="yellow", err=True)
502
+ else:
503
+ # Print table header
504
+ click.echo(
505
+ f"{'NAME':<40} {'PERIMETER':<15} {'TYPE':<30} {'HAS_SECRET':<12} {'DESCRIPTION':<50}",
506
+ err=True,
507
+ )
508
+
509
+ # Print each integration
510
+ for integ in integrations_list:
511
+ name = integ.get("integration_name", "")[:40]
512
+ integ_type = integ.get("integration_type", "")[:30]
513
+ description = integ.get("integration_description", "")[:50]
514
+ has_secrets = (
515
+ "Yes" if integ.get("integration_has_secrets", False) else "No"
516
+ )
517
+
518
+ click.echo(
519
+ f"{name:<40} {perimeter:<15} {integ_type:<30} {has_secrets:<12} {description:<50}",
520
+ err=True,
521
+ )
522
+
523
+ except Exception as e:
524
+ handle_api_error(e, step, response, output)
525
+
526
+
527
+ @integrations.command(help="Get a specific resource integration")
528
+ @click.argument("name")
529
+ @click.option(
530
+ "--perimeter",
531
+ help="Perimeter ID. Defaults to the currently active perimeter.",
532
+ )
533
+ @click.option(
534
+ "-d",
535
+ "--config-dir",
536
+ default=os.path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
537
+ help="Path to Metaflow configuration directory",
538
+ show_default=True,
539
+ )
540
+ @click.option(
541
+ "-p",
542
+ "--profile",
543
+ default=os.environ.get("METAFLOW_PROFILE", ""),
544
+ help="The named metaflow profile",
545
+ )
546
+ @click.option(
547
+ "-o",
548
+ "--output",
549
+ default="",
550
+ help="Show output in the specified format.",
551
+ type=click.Choice(["json", ""]),
552
+ )
553
+ @click.option(
554
+ "--show-secret-values",
555
+ is_flag=True,
556
+ default=False,
557
+ help="Fetch and display secret values from the cloud provider.",
558
+ )
559
+ def get(
560
+ name,
561
+ perimeter=None,
562
+ config_dir=None,
563
+ profile=None,
564
+ output="",
565
+ show_secret_values=False,
566
+ ):
567
+ response = OuterboundsCommandResponse()
568
+ step = CommandStatus(
569
+ "GetIntegration",
570
+ OuterboundsCommandStatus.OK,
571
+ f"Successfully fetched integration {name}.",
572
+ )
573
+
574
+ if not perimeter:
575
+ perimeter = get_current_perimeter(config_dir, profile)
576
+
577
+ if not perimeter:
578
+ click.secho(
579
+ "No perimeter specified and no active perimeter found.", fg="red", err=True
580
+ )
581
+ sys.exit(1)
582
+
583
+ try:
584
+ url = f"{get_integration_api_url(config_dir, profile, perimeter)}/{name}"
585
+ headers = get_auth_headers(config_dir, profile)
586
+
587
+ res = requests.get(url, headers=headers)
588
+ res.raise_for_status()
589
+
590
+ data = res.json()
591
+
592
+ # Fetch secret values if requested
593
+ if show_secret_values and data.get("integration_secret_keys"):
594
+ secret_values = fetch_integration_secret_values(
595
+ name, perimeter, config_dir, profile
596
+ )
597
+ if secret_values:
598
+ # Merge secret values into integration_secret_keys
599
+ # Transform from list to dict with actual values
600
+ data["integration_secret_keys"] = secret_values
601
+ # If fetching failed, keep integration_secret_keys as a list
602
+
603
+ if output == "json":
604
+ response.add_or_update_data("integration", data)
605
+ response.add_step(step)
606
+ click.echo(json.dumps(response.as_dict(), indent=4))
607
+ else:
608
+ click.echo(json.dumps(data, indent=2))
609
+
610
+ except Exception as e:
611
+ handle_api_error(e, step, response, output)
612
+
613
+
614
+ @integrations.command(help="Delete a resource integration")
615
+ @click.argument("name")
616
+ @click.option(
617
+ "--perimeter",
618
+ help="Perimeter ID. Defaults to the currently active perimeter.",
619
+ )
620
+ @click.option(
621
+ "-d",
622
+ "--config-dir",
623
+ default=os.path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
624
+ help="Path to Metaflow configuration directory",
625
+ show_default=True,
626
+ )
627
+ @click.option(
628
+ "-p",
629
+ "--profile",
630
+ default=os.environ.get("METAFLOW_PROFILE", ""),
631
+ help="The named metaflow profile",
632
+ )
633
+ @click.option(
634
+ "-o",
635
+ "--output",
636
+ default="",
637
+ help="Show output in the specified format.",
638
+ type=click.Choice(["json", ""]),
639
+ )
640
+ def delete(name, perimeter=None, config_dir=None, profile=None, output=""):
641
+ response = OuterboundsCommandResponse()
642
+ step = CommandStatus(
643
+ "DeleteIntegration",
644
+ OuterboundsCommandStatus.OK,
645
+ f"Successfully deleted integration {name}.",
646
+ )
647
+
648
+ if not perimeter:
649
+ perimeter = get_current_perimeter(config_dir, profile)
650
+
651
+ if not perimeter:
652
+ click.secho(
653
+ "No perimeter specified and no active perimeter found.", fg="red", err=True
654
+ )
655
+ sys.exit(1)
656
+
657
+ try:
658
+ url = f"{get_integration_api_url(config_dir, profile, perimeter)}/{name}"
659
+ headers = get_auth_headers(config_dir, profile)
660
+
661
+ res = requests.delete(url, headers=headers)
662
+ res.raise_for_status()
663
+
664
+ click.secho(f"Integration {name} deleted successfully.", fg="green", err=True)
665
+ response.add_step(step)
666
+
667
+ if output == "json":
668
+ click.echo(json.dumps(response.as_dict(), indent=4))
669
+
670
+ except Exception as e:
671
+ handle_api_error(e, step, response, output)
672
+
673
+
674
+ # Helper function to create standard options for all create/update commands
675
+ def common_integration_options(func):
676
+ func = click.option(
677
+ "--perimeter",
678
+ help="Perimeter ID. Defaults to the currently active perimeter.",
679
+ )(func)
680
+ func = click.option(
681
+ "-d",
682
+ "--config-dir",
683
+ default=os.path.expanduser(
684
+ os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")
685
+ ),
686
+ help="Path to Metaflow configuration directory",
687
+ show_default=True,
688
+ )(func)
689
+ func = click.option(
690
+ "-p",
691
+ "--profile",
692
+ default=os.environ.get("METAFLOW_PROFILE", ""),
693
+ help="The named metaflow profile",
694
+ )(func)
695
+ func = click.option(
696
+ "-o",
697
+ "--output",
698
+ default="",
699
+ help="Show output in the specified format.",
700
+ type=click.Choice(["json", ""]),
701
+ )(func)
702
+ return func
703
+
704
+
705
+ def create_or_update_integration(
706
+ operation: IntegrationOperation,
707
+ name: str,
708
+ integration_type: str,
709
+ description: str,
710
+ spec: Dict[str, Any],
711
+ secrets: Dict[str, str],
712
+ perimeter: Optional[str],
713
+ config_dir: str,
714
+ profile: str,
715
+ output: str,
716
+ ):
717
+ """
718
+ Generic handler for creating or updating an integration.
719
+ """
720
+ response = OuterboundsCommandResponse()
721
+ step_name = (
722
+ "CreateIntegration"
723
+ if operation == IntegrationOperation.CREATE
724
+ else "UpdateIntegration"
725
+ )
726
+ msg = f"Successfully {operation.value}d integration {name}."
727
+ step = CommandStatus(step_name, OuterboundsCommandStatus.OK, msg)
728
+
729
+ if not perimeter:
730
+ perimeter = get_current_perimeter(config_dir, profile)
731
+
732
+ if not perimeter:
733
+ click.secho(
734
+ "No perimeter specified and no active perimeter found.", fg="red", err=True
735
+ )
736
+ sys.exit(1)
737
+
738
+ try:
739
+ url = get_integration_api_url(config_dir, profile, perimeter)
740
+ if operation == IntegrationOperation.UPDATE:
741
+ url = f"{url}/{name}"
742
+
743
+ headers = get_auth_headers(config_dir, profile)
744
+
745
+ payload = {
746
+ "integration_name": name,
747
+ "integration_description": description,
748
+ "integration_type": integration_type,
749
+ "integration_status": "CREATED",
750
+ "integration_spec": spec,
751
+ }
752
+ if secrets:
753
+ import base64
754
+
755
+ encoded_secrets = {}
756
+ for k, v in secrets.items():
757
+ if v:
758
+ encoded_secrets[k] = base64.b64encode(v.encode("utf-8")).decode(
759
+ "utf-8"
760
+ )
761
+ payload["integration_secrets"] = encoded_secrets
762
+
763
+ if operation == IntegrationOperation.CREATE:
764
+ res = requests.post(url, headers=headers, json=payload)
765
+ else:
766
+ res = requests.put(url, headers=headers, json=payload)
767
+
768
+ res.raise_for_status()
769
+
770
+ data = res.json()
771
+ click.secho(msg, fg="green", err=True)
772
+
773
+ if output == "json":
774
+ response.add_or_update_data("integration", data)
775
+ response.add_step(step)
776
+ click.echo(json.dumps(response.as_dict(), indent=4))
777
+
778
+ except Exception as e:
779
+ handle_api_error(e, step, response, output)
780
+
781
+
782
+ # ------------------------------------------------------------------------------
783
+ # Storage Integrations
784
+ # ------------------------------------------------------------------------------
785
+
786
+ # --- S3 Proxy ---
787
+ @integrations.group(
788
+ name="s3-proxy",
789
+ help="""
790
+ This command is used to create an S3 Proxy integration, which will allow you to
791
+ use your local Nebius or CoreWeave object storage service as your local caching storage
792
+ when reading from or writing to AWS S3 buckets.
793
+
794
+ Example usage:
795
+ outerbounds integrations s3-proxy create --name my-s3-proxy --bucket-name my-bucket \\
796
+ --endpoint-url https://my-bucket.s3.nebius.com --region us-central1 \\
797
+ --access-key-id my-access-key --secret-access-key my-secret-access-key
798
+ """,
799
+ )
800
+ def s3_proxy():
801
+ pass
802
+
803
+
804
+ @s3_proxy.command(help="Create an S3 Proxy integration")
805
+ @click.argument("name")
806
+ @click.option("--description", help="Description of the integration", default="")
807
+ @click.option("--bucket-name", required=True, help="The S3 bucket name")
808
+ @click.option("--endpoint-url", required=True, help="The S3 endpoint URL")
809
+ @click.option("--region", required=True, help="The AWS region")
810
+ @click.option("--access-key-id", required=True, help="AWS Access Key ID")
811
+ @click.option("--secret-access-key", required=True, help="AWS Secret Access Key")
812
+ @common_integration_options
813
+ def create( # type: ignore[no-redef]
814
+ name,
815
+ description,
816
+ bucket_name,
817
+ endpoint_url,
818
+ region,
819
+ access_key_id,
820
+ secret_access_key,
821
+ perimeter,
822
+ config_dir,
823
+ profile,
824
+ output,
825
+ ):
826
+ spec = {
827
+ "bucket_name": bucket_name,
828
+ "endpoint_url": endpoint_url,
829
+ "region": region,
830
+ }
831
+ secrets = {
832
+ "access_key_id": access_key_id,
833
+ "secret_access_key": secret_access_key,
834
+ }
835
+ create_or_update_integration(
836
+ IntegrationOperation.CREATE,
837
+ name,
838
+ "S3_PROXY",
839
+ description,
840
+ spec,
841
+ secrets,
842
+ perimeter,
843
+ config_dir,
844
+ profile,
845
+ output,
846
+ )
847
+
848
+
849
+ @s3_proxy.command(help="Update an S3 Proxy integration")
850
+ @click.argument("name")
851
+ @click.option("--description", help="Description of the integration")
852
+ @click.option("--bucket-name", help="The S3 bucket name")
853
+ @click.option("--endpoint-url", help="The S3 endpoint URL")
854
+ @click.option("--region", help="The AWS region")
855
+ @click.option("--access-key-id", help="AWS Access Key ID")
856
+ @click.option("--secret-access-key", help="AWS Secret Access Key")
857
+ @common_integration_options
858
+ def update( # type: ignore[no-redef]
859
+ name,
860
+ description,
861
+ bucket_name,
862
+ endpoint_url,
863
+ region,
864
+ access_key_id,
865
+ secret_access_key,
866
+ perimeter,
867
+ config_dir,
868
+ profile,
869
+ output,
870
+ ):
871
+ if not perimeter:
872
+ perimeter = get_current_perimeter(config_dir, profile)
873
+
874
+ url = f"{get_integration_api_url(config_dir, profile, perimeter)}/{name}"
875
+ headers = get_auth_headers(config_dir, profile)
876
+ res = requests.get(url, headers=headers)
877
+ res.raise_for_status()
878
+ existing = res.json()
879
+
880
+ spec = existing.get("spec", {})
881
+ if bucket_name:
882
+ spec["bucket_name"] = bucket_name
883
+ if endpoint_url:
884
+ spec["endpoint_url"] = endpoint_url
885
+ if region:
886
+ spec["region"] = region
887
+
888
+ if description is None:
889
+ description = spec.get("description", "")
890
+
891
+ secrets = {}
892
+ if access_key_id:
893
+ secrets["access_key_id"] = access_key_id
894
+ if secret_access_key:
895
+ secrets["secret_access_key"] = secret_access_key
896
+
897
+ create_or_update_integration(
898
+ IntegrationOperation.UPDATE,
899
+ name,
900
+ "S3_PROXY",
901
+ description,
902
+ spec,
903
+ secrets,
904
+ perimeter,
905
+ config_dir,
906
+ profile,
907
+ output,
908
+ )
909
+
910
+
911
+ # ------------------------------------------------------------------------------
912
+ # Artifacts & Packages Integrations
913
+ # ------------------------------------------------------------------------------
914
+
915
+ # --- Code Artifacts ---
916
+ @integrations.group(
917
+ name="code-artifacts",
918
+ help="""
919
+ This command is used to create a CodeArtifacts integration, which will allow Fast Bakery to download package from
920
+ your AWS CodeArtifacts private repositories.
921
+ Once you have created this integrations, make sure you add all the pypi repositories hosed by this Code Artifacts
922
+ instance to the Private PyPI Repositories integration.
923
+
924
+ Example usage:
925
+ outerbounds integrations code-artifacts create my-code-artifacts --domain my-domain --domain-owner 123456789012 --aws-region us-west-2 --target-role arn:aws:iam::123456789012:role/my-role
926
+ """,
927
+ )
928
+ def code_artifacts():
929
+ pass
930
+
931
+
932
+ @code_artifacts.command(help="Create an AWS CodeArtifacts integration")
933
+ @click.argument("name")
934
+ @click.option("--description", help="Description of the integration", default="")
935
+ @click.option("--domain", required=True, help="CodeArtifacts Domain")
936
+ @click.option(
937
+ "--domain-owner", required=True, help="CodeArtifacts Domain Owner (AWS Account ID)"
938
+ )
939
+ @click.option("--aws-region", required=True, help="AWS Region")
940
+ @click.option("--target-role", help="Target IAM Role ARN")
941
+ @common_integration_options
942
+ def create( # type: ignore[no-redef]
943
+ name,
944
+ description,
945
+ domain,
946
+ domain_owner,
947
+ aws_region,
948
+ target_role,
949
+ perimeter,
950
+ config_dir,
951
+ profile,
952
+ output,
953
+ ):
954
+ # Set use_target_role based on whether target_role is provided
955
+ use_target_role = target_role is not None
956
+
957
+ spec = {
958
+ "domain_name": domain,
959
+ "domain_owner": domain_owner,
960
+ "aws_region": aws_region,
961
+ "use_target_role": use_target_role,
962
+ }
963
+ if target_role:
964
+ spec["target_role"] = target_role
965
+ create_or_update_integration(
966
+ IntegrationOperation.CREATE,
967
+ name,
968
+ "CODE_ARTIFACTS",
969
+ description,
970
+ spec,
971
+ {},
972
+ perimeter,
973
+ config_dir,
974
+ profile,
975
+ output,
976
+ )
977
+
978
+
979
+ @code_artifacts.command(help="Update an AWS CodeArtifacts integration")
980
+ @click.argument("name")
981
+ @click.option("--description", help="Description of the integration")
982
+ @click.option("--domain", help="CodeArtifacts Domain")
983
+ @click.option("--domain-owner", help="CodeArtifacts Domain Owner (AWS Account ID)")
984
+ @click.option("--aws-region", help="AWS Region")
985
+ @click.option("--target-role", help="Target IAM Role ARN")
986
+ @common_integration_options
987
+ def update( # type: ignore[no-redef]
988
+ name,
989
+ description,
990
+ domain,
991
+ domain_owner,
992
+ aws_region,
993
+ target_role,
994
+ perimeter,
995
+ config_dir,
996
+ profile,
997
+ output,
998
+ ):
999
+ if not perimeter:
1000
+ perimeter = get_current_perimeter(config_dir, profile)
1001
+
1002
+ url = f"{get_integration_api_url(config_dir, profile, perimeter)}/{name}"
1003
+ headers = get_auth_headers(config_dir, profile)
1004
+ res = requests.get(url, headers=headers)
1005
+ res.raise_for_status()
1006
+ existing = res.json()
1007
+
1008
+ spec = existing.get("spec", {})
1009
+ if domain:
1010
+ spec["domain_name"] = domain
1011
+ if domain_owner:
1012
+ spec["domain_owner"] = domain_owner
1013
+ if aws_region:
1014
+ spec["aws_region"] = aws_region
1015
+ if target_role is not None:
1016
+ spec["target_role"] = target_role
1017
+ # Set use_target_role based on whether target_role is provided
1018
+ spec["use_target_role"] = True
1019
+ else:
1020
+ # If updating without target_role, set use_target_role to False
1021
+ spec["use_target_role"] = False
1022
+
1023
+ if description is None:
1024
+ description = spec.get("description", "")
1025
+
1026
+ create_or_update_integration(
1027
+ IntegrationOperation.UPDATE,
1028
+ name,
1029
+ "CODE_ARTIFACTS",
1030
+ description,
1031
+ spec,
1032
+ {},
1033
+ perimeter,
1034
+ config_dir,
1035
+ profile,
1036
+ output,
1037
+ )
1038
+
1039
+
1040
+ # --- Artifactory ---
1041
+ @integrations.group(
1042
+ name="artifactory",
1043
+ help="""
1044
+ This command is used to create an Artifactory integration, which will allows Fast Bakery to download package from
1045
+ your Artifactory private repositories.
1046
+ Once you have created this integrations, make sure you add all the pypi and conda channels hosted by this Artifactory
1047
+ instance to the Private PyPI Repositories and Private Conda Channels integrations.
1048
+
1049
+ Example usage:
1050
+ outerbounds integrations artifactory create --name my-artifactory --domain my-artifactory.com --reachable-from-control-plane --username my-username --password my-password
1051
+ """,
1052
+ )
1053
+ def artifactory():
1054
+ pass
1055
+
1056
+
1057
+ @artifactory.command(help="Create an Artifactory integration")
1058
+ @click.argument("name")
1059
+ @click.option("--description", help="Description of the integration", default="")
1060
+ @click.option("--domain", required=True, help="Artifactory Domain")
1061
+ @click.option(
1062
+ "--reachable-from-control-plane/--not-reachable",
1063
+ default=True,
1064
+ help="Is reachable from control plane",
1065
+ )
1066
+ @click.option("--username", required=True, help="Username")
1067
+ @click.option("--password", required=True, help="Password")
1068
+ @common_integration_options
1069
+ def create( # type: ignore[no-redef]
1070
+ name,
1071
+ description,
1072
+ domain,
1073
+ reachable_from_control_plane,
1074
+ username,
1075
+ password,
1076
+ perimeter,
1077
+ config_dir,
1078
+ profile,
1079
+ output,
1080
+ ):
1081
+ spec = {
1082
+ "domain": domain,
1083
+ "is_reachable_from_control_plane": reachable_from_control_plane,
1084
+ }
1085
+ secrets = {
1086
+ "username": username,
1087
+ "password": password,
1088
+ }
1089
+ create_or_update_integration(
1090
+ IntegrationOperation.CREATE,
1091
+ name,
1092
+ "ARTIFACTORY",
1093
+ description,
1094
+ spec,
1095
+ secrets,
1096
+ perimeter,
1097
+ config_dir,
1098
+ profile,
1099
+ output,
1100
+ )
1101
+
1102
+
1103
+ @artifactory.command(help="Update an Artifactory integration")
1104
+ @click.argument("name")
1105
+ @click.option("--description", help="Description of the integration")
1106
+ @click.option("--domain", help="Artifactory Domain")
1107
+ @click.option(
1108
+ "--reachable-from-control-plane/--not-reachable",
1109
+ default=None,
1110
+ help="Is reachable from control plane",
1111
+ )
1112
+ @click.option("--username", help="Username")
1113
+ @click.option("--password", help="Password")
1114
+ @common_integration_options
1115
+ def update( # type: ignore[no-redef]
1116
+ name,
1117
+ description,
1118
+ domain,
1119
+ reachable_from_control_plane,
1120
+ username,
1121
+ password,
1122
+ perimeter,
1123
+ config_dir,
1124
+ profile,
1125
+ output,
1126
+ ):
1127
+ if not perimeter:
1128
+ perimeter = get_current_perimeter(config_dir, profile)
1129
+
1130
+ url = f"{get_integration_api_url(config_dir, profile, perimeter)}/{name}"
1131
+ headers = get_auth_headers(config_dir, profile)
1132
+ res = requests.get(url, headers=headers)
1133
+ res.raise_for_status()
1134
+ existing = res.json()
1135
+
1136
+ spec = existing.get("spec", {})
1137
+ if domain:
1138
+ spec["domain"] = domain
1139
+ if reachable_from_control_plane is not None:
1140
+ spec["is_reachable_from_control_plane"] = reachable_from_control_plane
1141
+ secrets = {}
1142
+ if username:
1143
+ secrets["username"] = username
1144
+ if password:
1145
+ secrets["password"] = password
1146
+ if description is None:
1147
+ description = spec.get("description", "")
1148
+
1149
+ create_or_update_integration(
1150
+ IntegrationOperation.UPDATE,
1151
+ name,
1152
+ "ARTIFACTORY",
1153
+ description,
1154
+ spec,
1155
+ {},
1156
+ perimeter,
1157
+ config_dir,
1158
+ profile,
1159
+ output,
1160
+ )
1161
+
1162
+
1163
+ # --- Azure Artifacts ---
1164
+ @integrations.group(
1165
+ name="azure-artifacts",
1166
+ help="""
1167
+ This command is used to create an Azure Artifacts integration, which will allows Fast Bakery to download package from
1168
+ your Azure DevOps private repositories.
1169
+ Once you have created this integrations, make sure you add all the pypi and conda channels hosted by this Azure Artifacts
1170
+ instance to the Private PyPI Repositories integration.
1171
+
1172
+ Example usage:
1173
+ outerbounds integrations azure-artifacts create --name my-azure-artifacts --organization my-organization --project-name my-project --username my-username --password my-password
1174
+ """,
1175
+ )
1176
+ def azure_artifacts():
1177
+ pass
1178
+
1179
+
1180
+ @azure_artifacts.command(help="Create an Azure Artifacts integration")
1181
+ @click.argument("name")
1182
+ @click.option("--description", help="Description of the integration", default="")
1183
+ @click.option("--organization", required=True, help="Azure DevOps Organization")
1184
+ @click.option("--project-name", help="Azure DevOps Project Name (optional)")
1185
+ @click.option("--username", required=True, help="Username/Email")
1186
+ @click.option("--password", required=True, help="PAT/Password")
1187
+ @common_integration_options
1188
+ def create( # type: ignore[no-redef]
1189
+ name,
1190
+ description,
1191
+ organization,
1192
+ project_name,
1193
+ username,
1194
+ password,
1195
+ perimeter,
1196
+ config_dir,
1197
+ profile,
1198
+ output,
1199
+ ):
1200
+ spec = {
1201
+ "organization": organization,
1202
+ }
1203
+ if project_name:
1204
+ spec["project_name"] = project_name
1205
+
1206
+ secrets = {
1207
+ "username": username,
1208
+ "password": password,
1209
+ }
1210
+ create_or_update_integration(
1211
+ IntegrationOperation.CREATE,
1212
+ name,
1213
+ "AZURE_ARTIFACTS",
1214
+ description,
1215
+ spec,
1216
+ secrets,
1217
+ perimeter,
1218
+ config_dir,
1219
+ profile,
1220
+ output,
1221
+ )
1222
+
1223
+
1224
+ @azure_artifacts.command(help="Update an Azure Artifacts integration")
1225
+ @click.argument("name")
1226
+ @click.option("--description", help="Description of the integration")
1227
+ @click.option("--organization", help="Azure DevOps Organization")
1228
+ @click.option("--project-name", help="Azure DevOps Project Name")
1229
+ @click.option("--username", help="Username/Email")
1230
+ @click.option("--password", help="PAT/Password")
1231
+ @common_integration_options
1232
+ def update( # type: ignore[no-redef]
1233
+ name,
1234
+ description,
1235
+ organization,
1236
+ project_name,
1237
+ username,
1238
+ password,
1239
+ perimeter,
1240
+ config_dir,
1241
+ profile,
1242
+ output,
1243
+ ):
1244
+ if not perimeter:
1245
+ perimeter = get_current_perimeter(config_dir, profile)
1246
+
1247
+ url = f"{get_integration_api_url(config_dir, profile, perimeter)}/{name}"
1248
+ headers = get_auth_headers(config_dir, profile)
1249
+ res = requests.get(url, headers=headers)
1250
+ res.raise_for_status()
1251
+ existing = res.json()
1252
+
1253
+ spec = existing.get("spec", {})
1254
+ if organization:
1255
+ spec["organization"] = organization
1256
+ if project_name is not None:
1257
+ spec["project_name"] = project_name
1258
+
1259
+ secrets = {}
1260
+ if username:
1261
+ secrets["username"] = username
1262
+ if password:
1263
+ secrets["password"] = password
1264
+
1265
+ if description is None:
1266
+ description = spec.get("description", "")
1267
+
1268
+ create_or_update_integration(
1269
+ IntegrationOperation.UPDATE,
1270
+ name,
1271
+ "AZURE_ARTIFACTS",
1272
+ description,
1273
+ spec,
1274
+ secrets,
1275
+ perimeter,
1276
+ config_dir,
1277
+ profile,
1278
+ output,
1279
+ )
1280
+
1281
+
1282
+ # --- GitLab Artifacts ---
1283
+ @integrations.group(
1284
+ name="gitlab-artifacts",
1285
+ help="""
1286
+ This command is used to create a GitLab Artifacts integration, which will allows Fast Bakery to download package from
1287
+ your GitLab private repositories.
1288
+ Once you have created this integrations, make sure you add all the pypi repositories hosted by this GitLab
1289
+ instance to the Private PyPI Repositories integration.
1290
+
1291
+ Example usage:
1292
+ outerbounds integrations gitlab-artifacts create --name my-gitlab-artifacts --gitlab-url https://gitlab.com --project-id 1234567890 --username my-username --password my-password
1293
+ """,
1294
+ )
1295
+ def gitlab_artifacts():
1296
+ pass
1297
+
1298
+
1299
+ @gitlab_artifacts.command(help="Create a GitLab Artifacts integration")
1300
+ @click.argument("name")
1301
+ @click.option("--description", help="Description of the integration", default="")
1302
+ @click.option("--gitlab-url", default="gitlab.com", help="GitLab Instance URL")
1303
+ @click.option("--project-id", required=True, help="GitLab Project ID")
1304
+ @click.option("--username", required=True, help="Username")
1305
+ @click.option("--password", required=True, help="Access Token/Password")
1306
+ @common_integration_options
1307
+ def create( # type: ignore[no-redef]
1308
+ name,
1309
+ description,
1310
+ gitlab_url,
1311
+ project_id,
1312
+ username,
1313
+ password,
1314
+ perimeter,
1315
+ config_dir,
1316
+ profile,
1317
+ output,
1318
+ ):
1319
+ spec = {
1320
+ "gitlab_url": gitlab_url,
1321
+ "project_id": project_id,
1322
+ }
1323
+ secrets = {
1324
+ "username": username,
1325
+ "password": password,
1326
+ }
1327
+ create_or_update_integration(
1328
+ IntegrationOperation.CREATE,
1329
+ name,
1330
+ "GITLAB_ARTIFACTS",
1331
+ description,
1332
+ spec,
1333
+ secrets,
1334
+ perimeter,
1335
+ config_dir,
1336
+ profile,
1337
+ output,
1338
+ )
1339
+
1340
+
1341
+ @gitlab_artifacts.command(help="Update a GitLab Artifacts integration")
1342
+ @click.argument("name")
1343
+ @click.option("--description", help="Description of the integration")
1344
+ @click.option("--gitlab-url", help="GitLab Instance URL")
1345
+ @click.option("--project-id", help="GitLab Project ID")
1346
+ @click.option("--username", help="Username")
1347
+ @click.option("--password", help="Access Token/Password")
1348
+ @common_integration_options
1349
+ def update( # type: ignore[no-redef]
1350
+ name,
1351
+ description,
1352
+ gitlab_url,
1353
+ project_id,
1354
+ username,
1355
+ password,
1356
+ perimeter,
1357
+ config_dir,
1358
+ profile,
1359
+ output,
1360
+ ):
1361
+ if not perimeter:
1362
+ perimeter = get_current_perimeter(config_dir, profile)
1363
+
1364
+ url = f"{get_integration_api_url(config_dir, profile, perimeter)}/{name}"
1365
+ headers = get_auth_headers(config_dir, profile)
1366
+ res = requests.get(url, headers=headers)
1367
+ res.raise_for_status()
1368
+ existing = res.json()
1369
+
1370
+ spec = existing.get("spec", {})
1371
+ if gitlab_url:
1372
+ spec["gitlab_url"] = gitlab_url
1373
+ if project_id:
1374
+ spec["project_id"] = project_id
1375
+
1376
+ secrets = {}
1377
+ if username:
1378
+ secrets["username"] = username
1379
+ if password:
1380
+ secrets["password"] = password
1381
+
1382
+ if description is None:
1383
+ description = spec.get("description", "")
1384
+
1385
+ create_or_update_integration(
1386
+ IntegrationOperation.UPDATE,
1387
+ name,
1388
+ "GITLAB_ARTIFACTS",
1389
+ description,
1390
+ spec,
1391
+ secrets,
1392
+ perimeter,
1393
+ config_dir,
1394
+ profile,
1395
+ output,
1396
+ )
1397
+
1398
+
1399
+ # --- Container Registry ---
1400
+ @integrations.group(
1401
+ name="container-registry",
1402
+ help="""
1403
+ This command is used to create a Container Registry integration, which allows Fast Bakery to pull container images from
1404
+ your private container registry. This means you can use private container images as the base image for your Fast Bakery images.
1405
+
1406
+ Three authentication methods are supported:
1407
+ 1. AWS ECR authentication using IAM roles which are assumable by the task role (--target-role-arn)
1408
+ 2. AWS ECR authentication using the task's IAM role (--use-task-role)
1409
+ 3. Username/password authentication for other registries (--username and --password)
1410
+
1411
+ Example usage for AWS ECR with IAM role:
1412
+ outerbounds integrations container-registry create my-ecr-registry --registry-domain 123456789012.dkr.ecr.us-west-2.amazonaws.com --target-role-arn arn:aws:iam::123456789012:role/my-role
1413
+
1414
+ Example usage for AWS ECR with task role:
1415
+ outerbounds integrations container-registry create my-ecr-registry --registry-domain 123456789012.dkr.ecr.us-west-2.amazonaws.com --use-task-role
1416
+
1417
+ Example usage for Artifactory or other registries:
1418
+ outerbounds integrations container-registry create my-artifactory --registry-domain my-registry.com --username myuser --password mypass
1419
+ """,
1420
+ )
1421
+ def container_registry():
1422
+ pass
1423
+
1424
+
1425
+ @container_registry.command(help="Create a Container Registry integration")
1426
+ @click.argument("name")
1427
+ @click.option("--description", help="Description of the integration", default="")
1428
+ @click.option("--registry-domain", required=True, help="Registry Domain")
1429
+ @click.option("--target-role-arn", help="Target Role ARN (for AWS ECR authentication)")
1430
+ @click.option(
1431
+ "--use-task-role",
1432
+ is_flag=True,
1433
+ help="Use the task's IAM role for authentication (for AWS ECR)",
1434
+ )
1435
+ @click.option(
1436
+ "--username", help="Username for registry authentication (for non-ECR registries)"
1437
+ )
1438
+ @click.option(
1439
+ "--password", help="Password for registry authentication (for non-ECR registries)"
1440
+ )
1441
+ @common_integration_options
1442
+ def create( # type: ignore[no-redef]
1443
+ name,
1444
+ description,
1445
+ registry_domain,
1446
+ target_role_arn,
1447
+ use_task_role,
1448
+ username,
1449
+ password,
1450
+ perimeter,
1451
+ config_dir,
1452
+ profile,
1453
+ output,
1454
+ ):
1455
+ if use_task_role and (target_role_arn or username or password):
1456
+ click.secho(
1457
+ "Error: --use-task-role cannot be used with --target-role-arn, --username, or --password",
1458
+ fg="red",
1459
+ err=True,
1460
+ )
1461
+ sys.exit(1)
1462
+
1463
+ spec = {
1464
+ "registry_domain": registry_domain,
1465
+ }
1466
+
1467
+ if use_task_role:
1468
+ spec["use_task_role"] = True
1469
+ else:
1470
+ spec["use_task_role"] = False
1471
+
1472
+ if target_role_arn:
1473
+ spec["target_role_arn"] = target_role_arn
1474
+
1475
+ secrets = {}
1476
+ if username or password:
1477
+ if not username or not password:
1478
+ click.secho(
1479
+ "Error: Both --username and --password must be provided together",
1480
+ fg="red",
1481
+ err=True,
1482
+ )
1483
+ sys.exit(1)
1484
+ secrets = {
1485
+ "username": username,
1486
+ "password": password,
1487
+ }
1488
+
1489
+ create_or_update_integration(
1490
+ IntegrationOperation.CREATE,
1491
+ name,
1492
+ "CONTAINER_REGISTRY",
1493
+ description,
1494
+ spec,
1495
+ secrets,
1496
+ perimeter,
1497
+ config_dir,
1498
+ profile,
1499
+ output,
1500
+ )
1501
+
1502
+
1503
+ @container_registry.command(help="Update a Container Registry integration")
1504
+ @click.argument("name")
1505
+ @click.option("--description", help="Description of the integration")
1506
+ @click.option("--registry-domain", help="Registry Domain")
1507
+ @click.option("--target-role-arn", help="Target Role ARN (for AWS ECR authentication)")
1508
+ @click.option(
1509
+ "--use-task-role",
1510
+ is_flag=True,
1511
+ help="Use the task's IAM role for authentication (for AWS ECR)",
1512
+ )
1513
+ @click.option(
1514
+ "--username", help="Username for registry authentication (for non-ECR registries)"
1515
+ )
1516
+ @click.option(
1517
+ "--password", help="Password for registry authentication (for non-ECR registries)"
1518
+ )
1519
+ @common_integration_options
1520
+ def update( # type: ignore[no-redef]
1521
+ name,
1522
+ description,
1523
+ registry_domain,
1524
+ target_role_arn,
1525
+ use_task_role,
1526
+ username,
1527
+ password,
1528
+ perimeter,
1529
+ config_dir,
1530
+ profile,
1531
+ output,
1532
+ ):
1533
+ if use_task_role and (target_role_arn or username or password):
1534
+ click.secho(
1535
+ "Error: --use-task-role cannot be used with --target-role-arn, --username, or --password",
1536
+ fg="red",
1537
+ err=True,
1538
+ )
1539
+ sys.exit(1)
1540
+
1541
+ if not perimeter:
1542
+ perimeter = get_current_perimeter(config_dir, profile)
1543
+
1544
+ url = f"{get_integration_api_url(config_dir, profile, perimeter)}/{name}"
1545
+ headers = get_auth_headers(config_dir, profile)
1546
+ res = requests.get(url, headers=headers)
1547
+ res.raise_for_status()
1548
+ existing = res.json()
1549
+
1550
+ spec = existing.get("integration_spec", {})
1551
+ if registry_domain:
1552
+ spec["registry_domain"] = registry_domain
1553
+
1554
+ if use_task_role:
1555
+ spec["use_task_role"] = True
1556
+ spec.pop("target_role_arn", None)
1557
+ elif target_role_arn is not None:
1558
+ spec["target_role_arn"] = target_role_arn
1559
+ spec["use_task_role"] = False
1560
+
1561
+ if description is None:
1562
+ description = existing.get("integration_description", "")
1563
+
1564
+ secrets = {}
1565
+ if username or password:
1566
+ spec["use_task_role"] = False
1567
+ if not username or not password:
1568
+ click.secho(
1569
+ "Error: Both --username and --password must be provided together",
1570
+ fg="red",
1571
+ err=True,
1572
+ )
1573
+ sys.exit(1)
1574
+ secrets = {
1575
+ "username": username,
1576
+ "password": password,
1577
+ }
1578
+
1579
+ create_or_update_integration(
1580
+ IntegrationOperation.UPDATE,
1581
+ name,
1582
+ "CONTAINER_REGISTRY",
1583
+ description,
1584
+ spec,
1585
+ secrets,
1586
+ perimeter,
1587
+ config_dir,
1588
+ profile,
1589
+ output,
1590
+ )
1591
+
1592
+
1593
+ # ------------------------------------------------------------------------------
1594
+ # Private Repositories Integrations
1595
+ # ------------------------------------------------------------------------------
1596
+
1597
+
1598
+ def find_integrations_by_type(
1599
+ config_dir: str, profile: str, perimeter: str, integration_type: str
1600
+ ) -> List[Dict[str, Any]]:
1601
+ """Finds integrations of a specific type."""
1602
+ url = get_integration_api_url(config_dir, profile, perimeter)
1603
+ headers = get_auth_headers(config_dir, profile)
1604
+ res = requests.get(url, headers=headers)
1605
+ res.raise_for_status()
1606
+ data = res.json()
1607
+ items = data.get("items", [])
1608
+
1609
+ matches = []
1610
+ for item in items:
1611
+ if item.get("spec", {}).get("type") == integration_type:
1612
+ matches.append(item)
1613
+ return matches
1614
+
1615
+
1616
+ def ensure_integration_exists(config_dir: str, profile: str, perimeter: str, name: str):
1617
+ """Ensures a specific integration exists."""
1618
+ url = f"{get_integration_api_url(config_dir, profile, perimeter)}/{name}"
1619
+ headers = get_auth_headers(config_dir, profile)
1620
+ try:
1621
+ res = requests.get(url, headers=headers)
1622
+ res.raise_for_status()
1623
+ except requests.exceptions.HTTPError as e:
1624
+ if e.response.status_code == 404:
1625
+ raise click.ClickException(
1626
+ f"Host integration '{name}' does not exist. Please create it first."
1627
+ )
1628
+ raise
1629
+
1630
+
1631
+ # --- Private PyPI Repositories ---
1632
+ @integrations.group(
1633
+ name="private-pypi-repositories",
1634
+ help="""
1635
+ This command is used to add a private pypi repository to the list of private repositories that are always checked when solving
1636
+ package dependencies. Each repository must be associated with an existing private service integration like Artifactory, GitLab,
1637
+ CodeArtifacts, Azure Artifacts, etc.
1638
+
1639
+ Example usage:
1640
+ outerbounds integrations private-pypi add --repository-name my-pypi-repository --repository-host-integration-name my-artifactory --repository-is-default
1641
+ outerbounds integrations private-pypi remove --repository-name my-pypi-repository
1642
+ """,
1643
+ )
1644
+ def private_pypi_repositories():
1645
+ pass
1646
+
1647
+
1648
+ @private_pypi_repositories.command(
1649
+ help="Add a repository to Private PyPI Repositories integration"
1650
+ )
1651
+ @click.option("--repository-name", required=True, help="Name of the repository")
1652
+ @click.option(
1653
+ "--repository-host-integration-name",
1654
+ required=True,
1655
+ help="Name of the host integration (e.g., artifactory, code-artifacts)",
1656
+ )
1657
+ @click.option(
1658
+ "--repository-is-default",
1659
+ is_flag=True,
1660
+ default=False,
1661
+ help="Set as default repository",
1662
+ )
1663
+ @common_integration_options
1664
+ def add( # type: ignore[no-redef]
1665
+ repository_name,
1666
+ repository_host_integration_name,
1667
+ repository_is_default,
1668
+ perimeter,
1669
+ config_dir,
1670
+ profile,
1671
+ output,
1672
+ ):
1673
+ if not perimeter:
1674
+ perimeter = get_current_perimeter(config_dir, profile)
1675
+
1676
+ integrations_found = find_integrations_by_type(
1677
+ config_dir, profile, perimeter, "PRIVATE_PYPI_REPOSITORIES"
1678
+ )
1679
+
1680
+ if len(integrations_found) > 1:
1681
+ raise click.ClickException(
1682
+ "More than one private PyPI repositories integration found. Please contact Outerbounds support."
1683
+ )
1684
+
1685
+ target_name = "default-private-pypi-repositories"
1686
+ current_repos = []
1687
+ description = ""
1688
+ operation = IntegrationOperation.CREATE
1689
+
1690
+ if len(integrations_found) == 1:
1691
+ existing = integrations_found[0]
1692
+ target_name = existing.get("metadata", {}).get("name")
1693
+ current_repos = existing.get("spec", {}).get("repositories", [])
1694
+ description = existing.get("spec", {}).get("description", "")
1695
+ operation = IntegrationOperation.UPDATE
1696
+
1697
+ # Validate that the host integration exists
1698
+ ensure_integration_exists(
1699
+ config_dir, profile, perimeter, repository_host_integration_name
1700
+ )
1701
+
1702
+ # Create new repository entry
1703
+ new_repo = {
1704
+ "repository_name": repository_name,
1705
+ "host_integration_name": repository_host_integration_name,
1706
+ "is_default": repository_is_default,
1707
+ }
1708
+
1709
+ # Add or update repository in the list
1710
+ repo_map = {r["repository_name"]: r for r in current_repos}
1711
+ repo_map[repository_name] = new_repo
1712
+
1713
+ final_repos = list(repo_map.values())
1714
+
1715
+ spec = {"repositories": final_repos}
1716
+
1717
+ create_or_update_integration(
1718
+ operation,
1719
+ target_name,
1720
+ "PRIVATE_PYPI_REPOSITORIES",
1721
+ description,
1722
+ spec,
1723
+ {},
1724
+ perimeter,
1725
+ config_dir,
1726
+ profile,
1727
+ output,
1728
+ )
1729
+
1730
+
1731
+ @private_pypi_repositories.command(
1732
+ help="Remove a repository from Private PyPI Repositories integration"
1733
+ )
1734
+ @click.option(
1735
+ "--repository-name", required=True, help="Name of the repository to remove"
1736
+ )
1737
+ @common_integration_options
1738
+ def remove(repository_name, perimeter, config_dir, profile, output): # type: ignore[no-redef]
1739
+ if not perimeter:
1740
+ perimeter = get_current_perimeter(config_dir, profile)
1741
+
1742
+ integrations_found = find_integrations_by_type(
1743
+ config_dir, profile, perimeter, "PRIVATE_PYPI_REPOSITORIES"
1744
+ )
1745
+
1746
+ if len(integrations_found) > 1:
1747
+ raise click.ClickException(
1748
+ "More than one private PyPI repository integration found. Please contact Outerbounds support."
1749
+ )
1750
+
1751
+ if not integrations_found:
1752
+ raise click.ClickException("No private PyPI repository integration found.")
1753
+
1754
+ existing = integrations_found[0]
1755
+ target_name = existing.get("metadata", {}).get("name")
1756
+ current_repos = existing.get("spec", {}).get("repositories", [])
1757
+ description = existing.get("spec", {}).get("description", "")
1758
+
1759
+ final_repos = [r for r in current_repos if r["repository_name"] != repository_name]
1760
+
1761
+ if len(final_repos) == len(current_repos):
1762
+ click.secho(f"Repository '{repository_name}' not found.", fg="yellow", err=True)
1763
+ return
1764
+
1765
+ spec = {"repositories": final_repos}
1766
+
1767
+ create_or_update_integration(
1768
+ IntegrationOperation.UPDATE,
1769
+ target_name,
1770
+ "PRIVATE_PYPI_REPOSITORIES",
1771
+ description,
1772
+ spec,
1773
+ {},
1774
+ perimeter,
1775
+ config_dir,
1776
+ profile,
1777
+ output,
1778
+ )
1779
+
1780
+
1781
+ # --- Private Conda Channels ---
1782
+ @integrations.group(
1783
+ name="private-conda-channels",
1784
+ help="""
1785
+ This command is used to add a private conda channel to the list of private channels that are always checked when solving
1786
+ package dependencies. Each channel must be associated with an existing private service integration like Artifactory.
1787
+
1788
+ Example usage:
1789
+ outerbounds integrations private-conda add --channel-name my-conda-channel --channel-host-integration-name my-artifactory --channel-is-default
1790
+ outerbounds integrations private-conda remove --channel-name my-conda-channel
1791
+ """,
1792
+ )
1793
+ def private_conda_channels():
1794
+ pass
1795
+
1796
+
1797
+ @private_conda_channels.command(
1798
+ help="Add a channel to Private Conda Channels integration"
1799
+ )
1800
+ @click.option("--channel-name", required=True, help="Name of the channel")
1801
+ @click.option(
1802
+ "--channel-host-integration-name",
1803
+ required=True,
1804
+ help="Name of the host integration (e.g., artifactory)",
1805
+ )
1806
+ @click.option("--channel-is-default", default=False, help="Set as default channel")
1807
+ @common_integration_options
1808
+ def add( # type: ignore[no-redef]
1809
+ channel_name,
1810
+ channel_host_integration_name,
1811
+ channel_is_default,
1812
+ perimeter,
1813
+ config_dir,
1814
+ profile,
1815
+ output,
1816
+ ):
1817
+ if not perimeter:
1818
+ perimeter = get_current_perimeter(config_dir, profile)
1819
+
1820
+ integrations_found = find_integrations_by_type(
1821
+ config_dir, profile, perimeter, "PRIVATE_CONDA_CHANNELS"
1822
+ )
1823
+
1824
+ if len(integrations_found) > 1:
1825
+ raise click.ClickException(
1826
+ "More than one private Conda channel integration found. Please contact Outerbounds support."
1827
+ )
1828
+
1829
+ target_name = "default-private-conda-channels"
1830
+ current_channels = []
1831
+ description = ""
1832
+ operation = IntegrationOperation.CREATE
1833
+
1834
+ if len(integrations_found) == 1:
1835
+ existing = integrations_found[0]
1836
+ target_name = existing.get("metadata", {}).get("name")
1837
+ current_channels = existing.get("spec", {}).get("channels", [])
1838
+ description = existing.get("spec", {}).get("description", "")
1839
+ operation = IntegrationOperation.UPDATE
1840
+
1841
+ # Validate that the host integration exists
1842
+ ensure_integration_exists(
1843
+ config_dir, profile, perimeter, channel_host_integration_name
1844
+ )
1845
+
1846
+ # Create new channel entry
1847
+ new_channel = {
1848
+ "repository_name": channel_name,
1849
+ "host_integration_name": channel_host_integration_name,
1850
+ "is_default": channel_is_default,
1851
+ }
1852
+
1853
+ # Add or update channel in the list
1854
+ channel_map = {c["repository_name"]: c for c in current_channels}
1855
+ channel_map[channel_name] = new_channel
1856
+
1857
+ final_channels = list(channel_map.values())
1858
+
1859
+ spec = {"channels": final_channels}
1860
+
1861
+ create_or_update_integration(
1862
+ operation,
1863
+ target_name,
1864
+ "PRIVATE_CONDA_CHANNELS",
1865
+ description,
1866
+ spec,
1867
+ {},
1868
+ perimeter,
1869
+ config_dir,
1870
+ profile,
1871
+ output,
1872
+ )
1873
+
1874
+
1875
+ @private_conda_channels.command(
1876
+ help="Remove a channel from Private Conda Channels integration"
1877
+ )
1878
+ @click.option("--channel-name", required=True, help="Name of the channel to remove")
1879
+ @common_integration_options
1880
+ def remove(channel_name, perimeter, config_dir, profile, output): # type: ignore[no-redef]
1881
+ if not perimeter:
1882
+ perimeter = get_current_perimeter(config_dir, profile)
1883
+
1884
+ integrations_found = find_integrations_by_type(
1885
+ config_dir, profile, perimeter, "PRIVATE_CONDA_CHANNELS"
1886
+ )
1887
+
1888
+ if len(integrations_found) > 1:
1889
+ raise click.ClickException(
1890
+ "More than one private Conda channel integration found. Please contact Outerbounds support."
1891
+ )
1892
+
1893
+ if not integrations_found:
1894
+ raise click.ClickException("No private Conda channel integration found.")
1895
+
1896
+ existing = integrations_found[0]
1897
+ target_name = existing.get("metadata", {}).get("name")
1898
+ current_channels = existing.get("spec", {}).get("channels", [])
1899
+ description = existing.get("spec", {}).get("description", "")
1900
+
1901
+ final_channels = [
1902
+ c for c in current_channels if c["repository_name"] != channel_name
1903
+ ]
1904
+
1905
+ if len(final_channels) == len(current_channels):
1906
+ click.secho(f"Channel '{channel_name}' not found.", fg="yellow", err=True)
1907
+ return
1908
+
1909
+ spec = {"channels": final_channels}
1910
+
1911
+ create_or_update_integration(
1912
+ IntegrationOperation.UPDATE,
1913
+ target_name,
1914
+ "PRIVATE_CONDA_CHANNELS",
1915
+ description,
1916
+ spec,
1917
+ {},
1918
+ perimeter,
1919
+ config_dir,
1920
+ profile,
1921
+ output,
1922
+ )
1923
+
1924
+
1925
+ # --- Git PyPI Repository ---
1926
+ @integrations.group(
1927
+ name="git-pypi-repository",
1928
+ help="""
1929
+ This command is used to create a Git PyPI Repository integration, which allows Fast Bakery to install Python packages
1930
+ directly from private Git repositories using pip's VCS support (e.g., pip install git+https://...).
1931
+
1932
+ You can specify multiple repository URLs, which can be at different levels:
1933
+ - Service level: https://gitlab.com
1934
+ - Organization level: https://gitlab.com/outerbounds
1935
+ - Project level: https://gitlab.com/outerbounds/project1
1936
+
1937
+ The credentials will be used to authenticate to these repositories during package installation.
1938
+
1939
+ Example usage:
1940
+ outerbounds integrations git-pypi-repository create my-git-repos --repository-url https://gitlab.com/myorg --username myuser --password mytoken
1941
+ """,
1942
+ )
1943
+ def git_pypi_repository():
1944
+ pass
1945
+
1946
+
1947
+ @git_pypi_repository.command(help="Create a Git PyPI Repository integration")
1948
+ @click.argument("name")
1949
+ @click.option("--description", help="Description of the integration", default="")
1950
+ @click.option(
1951
+ "--repository-url",
1952
+ "-r",
1953
+ multiple=True,
1954
+ required=True,
1955
+ help="Git repository URL (can be specified multiple times for multiple repositories)",
1956
+ )
1957
+ @click.option("--username", required=True, help="Username for Git authentication")
1958
+ @click.option("--password", required=True, help="Password/Token for Git authentication")
1959
+ @common_integration_options
1960
+ def create( # type: ignore[no-redef]
1961
+ name,
1962
+ description,
1963
+ repository_url,
1964
+ username,
1965
+ password,
1966
+ perimeter,
1967
+ config_dir,
1968
+ profile,
1969
+ output,
1970
+ ):
1971
+ if not repository_url:
1972
+ raise click.ClickException("At least one repository URL is required")
1973
+
1974
+ if not username or not password:
1975
+ raise click.ClickException("Username and password are required")
1976
+
1977
+ spec = {
1978
+ "repository_urls": list(repository_url),
1979
+ }
1980
+
1981
+ secrets = {
1982
+ "username": username,
1983
+ "password": password,
1984
+ }
1985
+ create_or_update_integration(
1986
+ IntegrationOperation.CREATE,
1987
+ name,
1988
+ "GIT_PYPI_REPOSITORY",
1989
+ description,
1990
+ spec,
1991
+ secrets,
1992
+ perimeter,
1993
+ config_dir,
1994
+ profile,
1995
+ output,
1996
+ )
1997
+
1998
+
1999
+ @git_pypi_repository.command(help="Update a Git PyPI Repository integration")
2000
+ @click.argument("name")
2001
+ @click.option("--description", help="Description of the integration")
2002
+ @click.option(
2003
+ "--repository-url",
2004
+ "-r",
2005
+ multiple=True,
2006
+ help="Git repository URL (can be specified multiple times for multiple repositories). Replaces existing URLs if provided.",
2007
+ )
2008
+ @click.option("--username", help="Username for Git authentication")
2009
+ @click.option("--password", help="Password/Token for Git authentication")
2010
+ @common_integration_options
2011
+ def update( # type: ignore[no-redef]
2012
+ name,
2013
+ description,
2014
+ repository_url,
2015
+ username,
2016
+ password,
2017
+ perimeter,
2018
+ config_dir,
2019
+ profile,
2020
+ output,
2021
+ ):
2022
+ if not perimeter:
2023
+ perimeter = get_current_perimeter(config_dir, profile)
2024
+
2025
+ url = f"{get_integration_api_url(config_dir, profile, perimeter)}/{name}"
2026
+ headers = get_auth_headers(config_dir, profile)
2027
+ res = requests.get(url, headers=headers)
2028
+ res.raise_for_status()
2029
+ existing = res.json()
2030
+
2031
+ spec = existing.get("integration_spec", {})
2032
+ if repository_url:
2033
+ spec["repository_urls"] = list(repository_url)
2034
+
2035
+ if description is None:
2036
+ description = existing.get("integration_description", "")
2037
+
2038
+ secrets = {}
2039
+ if username:
2040
+ secrets["username"] = username
2041
+ if password:
2042
+ secrets["password"] = password
2043
+
2044
+ create_or_update_integration(
2045
+ IntegrationOperation.UPDATE,
2046
+ name,
2047
+ "GIT_PYPI_REPOSITORY",
2048
+ description,
2049
+ spec,
2050
+ secrets,
2051
+ perimeter,
2052
+ config_dir,
2053
+ profile,
2054
+ output,
2055
+ )
2056
+
2057
+
2058
+ # --- Custom Secret ---
2059
+ @integrations.group(
2060
+ name="custom-secret",
2061
+ help="""
2062
+ This command is used to create a Custom Secret integration, which will create a custom secret with the keys and value
2063
+ pairs you provide. Once the secret is created, you can use the @secrets(sources=["outerbounds.<integration-name>"]) to
2064
+ set the secrets as environment variables within your flow.
2065
+
2066
+ Example usage:
2067
+ outerbounds integrations custom-secret create --name my-custom-secret --description "My custom secret" --secret key=value
2068
+ """,
2069
+ )
2070
+ def custom_secret():
2071
+ pass
2072
+
2073
+
2074
+ @custom_secret.command(help="Create a Custom Secret integration")
2075
+ @click.argument("name")
2076
+ @click.option("--description", help="Description of the integration", default="")
2077
+ @click.option(
2078
+ "--secret",
2079
+ "-s",
2080
+ multiple=True,
2081
+ required=True,
2082
+ help="Secret Key-Value pair: 'key=value'",
2083
+ )
2084
+ @common_integration_options
2085
+ def create(name, description, secret, perimeter, config_dir, profile, output): # type: ignore[no-redef]
2086
+ spec = {}
2087
+ secrets = {}
2088
+ for s in secret:
2089
+ if "=" not in s:
2090
+ raise click.BadParameter("Secret must be in format 'key=value'")
2091
+ k, v = s.split("=", 1)
2092
+ secrets[k] = v
2093
+
2094
+ create_or_update_integration(
2095
+ IntegrationOperation.CREATE,
2096
+ name,
2097
+ "CUSTOM_SECRET",
2098
+ description,
2099
+ spec,
2100
+ secrets,
2101
+ perimeter,
2102
+ config_dir,
2103
+ profile,
2104
+ output,
2105
+ )
2106
+
2107
+
2108
+ @custom_secret.command(help="Update a Custom Secret integration")
2109
+ @click.argument("name")
2110
+ @click.option("--description", help="Description of the integration")
2111
+ @click.option(
2112
+ "--secret",
2113
+ "-s",
2114
+ multiple=True,
2115
+ help="Secret Key-Value pair: 'key=value'. Merges with existing secrets if provided.",
2116
+ )
2117
+ @common_integration_options
2118
+ def update(name, description, secret, perimeter, config_dir, profile, output): # type: ignore[no-redef]
2119
+ if not perimeter:
2120
+ perimeter = get_current_perimeter(config_dir, profile)
2121
+
2122
+ url = f"{get_integration_api_url(config_dir, profile, perimeter)}/{name}"
2123
+ headers = get_auth_headers(config_dir, profile)
2124
+ res = requests.get(url, headers=headers)
2125
+ res.raise_for_status()
2126
+ existing = res.json()
2127
+
2128
+ spec = existing.get("spec", {})
2129
+
2130
+ secrets = {}
2131
+ if secret:
2132
+ for s in secret:
2133
+ if "=" not in s:
2134
+ raise click.BadParameter("Secret must be in format 'key=value'")
2135
+ k, v = s.split("=", 1)
2136
+ secrets[k] = v
2137
+
2138
+ if description is None:
2139
+ description = spec.get("description", "")
2140
+
2141
+ create_or_update_integration(
2142
+ IntegrationOperation.UPDATE,
2143
+ name,
2144
+ "CUSTOM_SECRET",
2145
+ description,
2146
+ spec,
2147
+ secrets,
2148
+ perimeter,
2149
+ config_dir,
2150
+ profile,
2151
+ output,
2152
+ )
2153
+
2154
+
2155
+ cli.add_command(integrations, name="integrations")