Skip to content

Commit a78dab6

Browse files
authored
Support iam_instance_profile for AWS (#2365)
* Support iam_instance_profile for AWS * Fix tests
1 parent 98a15ae commit a78dab6

File tree

7 files changed

+84
-7
lines changed

7 files changed

+84
-7
lines changed

docs/docs/concepts/backends.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,44 @@ There are two ways to configure AWS: using an access key or using the default cr
122122
"acm:ListCertificates"
123123
],
124124
"Resource": "*"
125+
},
126+
{
127+
"Effect": "Allow",
128+
"Action": [
129+
"iam:GetInstanceProfile",
130+
"iam:GetRole",
131+
"iam:PassRole"
132+
],
133+
"Resource": "*"
125134
}
126135
]
127136
}
128137
```
129138

130139
The `elasticloadbalancing:*` and `acm:*` permissions are only needed for provisioning gateways with ACM (AWS Certificate Manager) certificates.
131140

141+
The `iam:*` permissions are only needed if you specify `iam_instance_profile` to assign to EC2 instances.
142+
143+
You can also limit permissions to specific resources in your account:
144+
145+
```
146+
{
147+
"Version": "2012-10-17",
148+
"Statement": [
149+
...
150+
{
151+
"Effect": "Allow",
152+
"Action": [
153+
"iam:GetInstanceProfile",
154+
"iam:GetRole",
155+
"iam:PassRole"
156+
],
157+
"Resource": "arn:aws:iam::account-id:role/EC2-roles-for-XYZ-*"
158+
}
159+
]
160+
}
161+
```
162+
132163
??? info "VPC"
133164
By default, `dstack` uses the default VPC. It's possible to customize it:
134165

src/dstack/_internal/core/backends/aws/compute.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ def create_instance(
219219
disk_size=disk_size,
220220
image_id=image_id,
221221
instance_type=instance_offer.instance.name,
222-
iam_instance_profile_arn=None,
222+
iam_instance_profile=self.config.iam_instance_profile,
223223
user_data=get_user_data(authorized_keys=instance_config.get_public_keys()),
224224
tags=aws_resources.make_tags(tags),
225225
security_group_id=aws_resources.create_security_group(
@@ -264,6 +264,9 @@ def create_instance(
264264
)
265265
except botocore.exceptions.ClientError as e:
266266
logger.warning("Got botocore.exceptions.ClientError: %s", e)
267+
if e.response["Error"]["Code"] == "InvalidParameterValue":
268+
msg = e.response["Error"].get("Message", "")
269+
raise ComputeError(f"Invalid AWS request: {msg}")
267270
continue
268271
raise NoCapacityError()
269272

@@ -380,7 +383,7 @@ def create_gateway(
380383
disk_size=10,
381384
image_id=aws_resources.get_gateway_image_id(ec2_client),
382385
instance_type="t2.micro",
383-
iam_instance_profile_arn=None,
386+
iam_instance_profile=None,
384387
user_data=get_gateway_user_data(configuration.ssh_key_pub),
385388
tags=tags,
386389
security_group_id=security_group_id,

src/dstack/_internal/core/backends/aws/resources.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def create_instances_struct(
131131
disk_size: int,
132132
image_id: str,
133133
instance_type: str,
134-
iam_instance_profile_arn: Optional[str],
134+
iam_instance_profile: Optional[str],
135135
user_data: str,
136136
tags: List[Dict[str, str]],
137137
security_group_id: str,
@@ -166,8 +166,8 @@ def create_instances_struct(
166166
},
167167
],
168168
)
169-
if iam_instance_profile_arn:
170-
struct["IamInstanceProfile"] = {"Arn": iam_instance_profile_arn}
169+
if iam_instance_profile:
170+
struct["IamInstanceProfile"] = {"Name": iam_instance_profile}
171171
if spot:
172172
struct["InstanceMarketOptions"] = {
173173
"MarketType": "spot",

src/dstack/_internal/core/models/backends/aws.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class AWSConfigInfo(CoreModel):
3232
vpc_ids: Optional[Dict[str, str]] = None
3333
default_vpcs: Optional[bool] = None
3434
public_ips: Optional[bool] = None
35+
iam_instance_profile: Optional[str] = None
3536
tags: Optional[Dict[str, str]] = None
3637
os_images: Optional[AWSOSImageConfig] = None
3738

@@ -70,6 +71,7 @@ class AWSConfigInfoWithCredsPartial(CoreModel):
7071
vpc_ids: Optional[Dict[str, str]]
7172
default_vpcs: Optional[bool]
7273
public_ips: Optional[bool]
74+
iam_instance_profile: Optional[str]
7375
tags: Optional[Dict[str, str]]
7476
os_images: Optional["AWSOSImageConfig"]
7577

src/dstack/_internal/server/services/backends/configurators/aws.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
from typing import List
44

5+
import botocore.exceptions
56
from boto3.session import Session
67

78
from dstack._internal.core.backends.aws import AWSBackend, auth, compute, resources
@@ -35,6 +36,9 @@
3536
Configurator,
3637
raise_invalid_credentials_error,
3738
)
39+
from dstack._internal.utils.logging import get_logger
40+
41+
logger = get_logger(__name__)
3842

3943
REGIONS = [
4044
("US East, N. Virginia", "us-east-1"),
@@ -137,7 +141,8 @@ def _get_regions_element(self, selected: List[str]) -> ConfigMultiElement:
137141

138142
def _check_config(self, session: Session, config: AWSConfigInfoWithCredsPartial):
139143
self._check_tags_config(config)
140-
self._check_vpc_config(session=session, config=config)
144+
self._check_iam_instance_profile_config(session, config)
145+
self._check_vpc_config(session, config)
141146

142147
def _check_tags_config(self, config: AWSConfigInfoWithCredsPartial):
143148
if not config.tags:
@@ -151,6 +156,31 @@ def _check_tags_config(self, config: AWSConfigInfoWithCredsPartial):
151156
except BackendError as e:
152157
raise ServerClientError(e.args[0])
153158

159+
def _check_iam_instance_profile_config(
160+
self, session: Session, config: AWSConfigInfoWithCredsPartial
161+
):
162+
if config.iam_instance_profile is None:
163+
return
164+
try:
165+
iam_client = session.client("iam")
166+
iam_client.get_instance_profile(InstanceProfileName=config.iam_instance_profile)
167+
except botocore.exceptions.ClientError as e:
168+
if e.response["Error"]["Code"] == "NoSuchEntity":
169+
raise ServerClientError(
170+
f"IAM instance profile {config.iam_instance_profile} not found"
171+
)
172+
logger.exception(
173+
"Got botocore.exceptions.ClientError when checking iam_instance_profile"
174+
)
175+
raise ServerClientError(
176+
f"Failed to check IAM instance profile {config.iam_instance_profile}"
177+
)
178+
except Exception:
179+
logger.exception("Got exception when checking iam_instance_profile")
180+
raise ServerClientError(
181+
f"Failed to check IAM instance profile {config.iam_instance_profile}"
182+
)
183+
154184
def _check_vpc_config(self, session: Session, config: AWSConfigInfoWithCredsPartial):
155185
allocate_public_ip = config.public_ips if config.public_ips is not None else True
156186
use_default_vpcs = config.default_vpcs if config.default_vpcs is not None else True

src/dstack/_internal/server/services/config.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,16 @@ class AWSConfig(CoreModel):
107107
)
108108
),
109109
] = None
110+
iam_instance_profile: Annotated[
111+
Optional[str],
112+
Field(
113+
description=(
114+
"The name of the IAM instance profile to associate with EC2 instances."
115+
" You can also specify the IAM role name for roles created via the AWS console."
116+
" AWS automatically creates an instance profile and gives it the same name as the role"
117+
)
118+
),
119+
] = None
110120
tags: Annotated[
111121
Optional[Dict[str, str]],
112122
Field(description="The tags that will be assigned to resources created by `dstack`"),
@@ -251,7 +261,7 @@ class GCPConfig(CoreModel):
251261
),
252262
] = None
253263
vm_service_account: Annotated[
254-
Optional[str], Field(description="The service account associated with provisioned VMs")
264+
Optional[str], Field(description="The service account to associate with provisioned VMs")
255265
] = None
256266
tags: Annotated[
257267
Optional[Dict[str, str]],

src/tests/_internal/server/routers/test_backends.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,7 @@ async def test_returns_config_info(self, test_db, session: AsyncSession, client:
13351335
"vpc_ids": None,
13361336
"default_vpcs": None,
13371337
"public_ips": None,
1338+
"iam_instance_profile": None,
13381339
"tags": None,
13391340
"os_images": None,
13401341
"creds": json.loads(backend.auth.plaintext),

0 commit comments

Comments
 (0)