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.
- outerbounds/command_groups/cli.py +2 -0
- outerbounds/command_groups/integrations_cli.py +2155 -0
- outerbounds/command_groups/secrets_cli.py +2 -2
- {outerbounds-0.10.14.dist-info → outerbounds-0.10.37.dist-info}/METADATA +7 -6
- {outerbounds-0.10.14.dist-info → outerbounds-0.10.37.dist-info}/RECORD +7 -6
- {outerbounds-0.10.14.dist-info → outerbounds-0.10.37.dist-info}/WHEEL +0 -0
- {outerbounds-0.10.14.dist-info → outerbounds-0.10.37.dist-info}/entry_points.txt +0 -0
|
@@ -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")
|