Skip to content
83 changes: 55 additions & 28 deletions linode_api4/groups/linode.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ def instance_create(
interface_generation: Optional[Union[InterfaceGeneration, str]] = None,
network_helper: Optional[bool] = None,
maintenance_policy: Optional[str] = None,
root_pass: Optional[str] = None,
kernel: Optional[str] = None,
boot_size: Optional[int] = None,
authorized_users: Optional[List[str]] = None,
**kwargs,
):
"""
Expand All @@ -172,27 +176,26 @@ def instance_create(
To create an Instance from an :any:`Image`, call `instance_create` with
a :any:`Type`, a :any:`Region`, and an :any:`Image`. All three of
these fields may be provided as either the ID or the appropriate object.
In this mode, a root password will be generated and returned with the
new Instance object.
When an Image is provided, at least one of ``root_pass``, ``authorized_users``, or
``authorized_keys`` must also be given.

For example::

new_linode, password = client.linode.instance_create(
new_linode = client.linode.instance_create(
"g6-standard-2",
"us-east",
image="linode/debian9")
image="linode/debian13",
root_pass="aComplex@Password123")

ltype = client.linode.types().first()
region = client.regions().first()
image = client.images().first()

another_linode, password = client.linode.instance_create(
another_linode = client.linode.instance_create(
ltype,
region,
image=image)

To output the password from the above example:
print(password)
image=image,
authorized_keys="ssh-rsa AAAA")

To output the first IPv4 address of the new Linode:
print(new_linode.ipv4[0])
Expand All @@ -210,10 +213,11 @@ def instance_create(

stackscript = StackScript(client, 10079)

new_linode, password = client.linode.instance_create(
new_linode = client.linode.instance_create(
"g6-standard-2",
"us-east",
image="linode/debian9",
image="linode/debian13",
root_pass="aComplex@Password123",
stackscript=stackscript,
stackscript_data={"gh_username": "example"})

Expand Down Expand Up @@ -244,10 +248,11 @@ def instance_create(
To create a new Instance with explicit interfaces, provide list of
LinodeInterfaceOptions objects or dicts to the "interfaces" field::

linode, password = client.linode.instance_create(
linode = client.linode.instance_create(
"g6-standard-1",
"us-mia",
image="linode/ubuntu24.04",
root_pass="aComplex@Password123",

# This can be configured as an account-wide default
interface_generation=InterfaceGeneration.LINODE,
Expand Down Expand Up @@ -280,10 +285,14 @@ def instance_create(
:type ltype: str or Type
:param region: The Region in which we are creating the Instance
:type region: str or Region
:param image: The Image to deploy to this Instance. If this is provided
and no root_pass is given, a password will be generated
and returned along with the new Instance.
:param image: The Image to deploy to this Instance. If this is provided,
at least one of root_pass, authorized_users, or authorized_keys must also be
provided.
:type image: str or Image
:param root_pass: The root password for the new Instance. Required when
an image is provided and neither authorized_users nor
authorized_keys are given.
:type root_pass: str
:param stackscript: The StackScript to deploy to the new Instance. If
provided, "image" is required and must be compatible
with the chosen StackScript.
Expand All @@ -300,6 +309,11 @@ def instance_create(
be a single key, or a path to a file containing
the key.
:type authorized_keys: list or str
:param authorized_users: A list of usernames whose keys should be installed
as trusted for the root user. These user's keys
should already be set up, see :any:`ProfileGroup.ssh_keys`
for details.
:type authorized_users: list[str]
:param label: The display label for the new Instance
:type label: str
:param group: The display group for the new Instance
Expand Down Expand Up @@ -336,26 +350,39 @@ def instance_create(
:param maintenance_policy: The slug of the maintenance policy to apply during maintenance.
If not provided, the default policy (linode/migrate) will be applied.
:type maintenance_policy: str

:returns: A new Instance object, or a tuple containing the new Instance and
the generated password.
:rtype: Instance or tuple(Instance, str)
:param kernel: The kernel to boot the Instance with. If provided, this will be used as the
kernel for the default configuration profile.
:type kernel: str
:param boot_size: The size of the boot disk in MB. If provided, this will be used to create
the boot disk for the Instance.
:type boot_size: int

:returns: A new Instance object
:rtype: Instance
:raises ApiError: If contacting the API fails
:raises UnexpectedResponseError: If the API response is somehow malformed.
This usually indicates that you are using
an outdated library.
"""

ret_pass = None
if image and not "root_pass" in kwargs:
ret_pass = Instance.generate_root_password()
kwargs["root_pass"] = ret_pass
if (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: I think the removal of the root password generation would be a breaking change. All good if that's how we want to move forward, just figured I'd call it out 🙂

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah true, but I think since the purpose of this project is to make passwords optional it would be odd to generate one even when the user intentionally does not provide one.

image
and not root_pass
and not authorized_keys
and not authorized_users
):
raise ValueError(
"When creating an Instance from an Image, at least one of "
"root_pass, authorized_users, or authorized_keys must be provided."
)

params = {
"type": ltype,
"region": region,
"image": image,
"root_pass": root_pass,
"authorized_keys": load_and_validate_keys(authorized_keys),
"authorized_users": authorized_users,
# These will automatically be flattened below
"firewall_id": firewall,
"backup_id": backup,
Expand All @@ -373,6 +400,8 @@ def instance_create(
"interfaces": interfaces,
"interface_generation": interface_generation,
"network_helper": network_helper,
"kernel": kernel,
"boot_size": boot_size,
}

params.update(kwargs)
Expand All @@ -387,10 +416,7 @@ def instance_create(
"Unexpected response when creating linode!", json=result
)

l = Instance(self.client, result["id"], result)
if not ret_pass:
return l
return l, ret_pass
return Instance(self.client, result["id"], result)

@staticmethod
def build_instance_metadata(user_data=None, encode_user_data=True):
Expand All @@ -399,10 +425,11 @@ def build_instance_metadata(user_data=None, encode_user_data=True):
the :any:`instance_create` method. This helper can also be used
when cloning and rebuilding Instances.
**Creating an Instance with User Data**::
new_linode, password = client.linode.instance_create(
new_linode = client.linode.instance_create(
"g6-standard-2",
"us-east",
image="linode/ubuntu22.04",
root_pass="aComplex@Password123",
metadata=client.linode.build_instance_metadata(user_data="myuserdata")
)
:param user_data: User-defined data to provide to the Linode Instance through
Expand Down
60 changes: 34 additions & 26 deletions linode_api4/objects/linode.py
Original file line number Diff line number Diff line change
Expand Up @@ -1395,11 +1395,10 @@ def disk_create(
for the image deployed the disk will be used. Required
if creating a disk without an image.
:param read_only: If True, creates a read-only disk
:param image: The Image to deploy to the disk.
:param image: The Image to deploy to the disk. If provided, at least one of
root_pass, authorized_users or authorized_keys must also be given.
:param root_pass: The password to configure for the root user when deploying an
image to this disk. Not used if image is not given. If an
image is given and root_pass is not, a password will be
generated and returned alongside the new disk.
image to this disk. Not used if image is not given.
:param authorized_keys: A list of SSH keys to install as trusted for the root user.
:param authorized_users: A list of usernames whose keys should be installed
as trusted for the root user. These user's keys
Expand All @@ -1412,12 +1411,21 @@ def disk_create(
disk. Requires deploying a compatible image.
:param **stackscript_args: Any arguments to pass to the StackScript, as defined
by its User Defined Fields.

:returns: A new Disk object.
:rtype: Disk
"""

gen_pass = None
if image and not root_pass:
gen_pass = Instance.generate_root_password()
root_pass = gen_pass
if (
image
and not root_pass
and not authorized_keys
and not authorized_users
):
raise ValueError(
"When creating a Disk from an Image, at least one of "
"root_pass, authorized_users, or authorized_keys must be provided."
)

authorized_keys = load_and_validate_keys(authorized_keys)

Expand Down Expand Up @@ -1464,11 +1472,7 @@ def disk_create(
"Unexpected response creating disk!", json=result
)

d = Disk(self._client, result["id"], self.id, result)

if gen_pass:
return d, gen_pass
return d
return Disk(self._client, result["id"], self.id, result)

def enable_backups(self):
"""
Expand Down Expand Up @@ -1580,6 +1584,7 @@ def rebuild(
disk_encryption: Optional[
Union[InstanceDiskEncryptionType, str]
] = None,
authorized_users: Optional[List[str]] = None,
**kwargs,
):
"""
Expand All @@ -1591,26 +1596,31 @@ def rebuild(

:param image: The Image to deploy to this Instance
:type image: str or Image
:param root_pass: The root password for the newly rebuilt Instance. If
omitted, a password will be generated and returned.
:param root_pass: The root password for the newly rebuilt Instance. At least
one of root_pass, authorized_users, or authorized_keys must be provided.
:type root_pass: str
:param authorized_keys: The ssh public keys to install in the linode's
/root/.ssh/authorized_keys file. Each entry may
be a single key, or a path to a file containing
the key.
:type authorized_keys: list or str
:param authorized_users: A list of usernames whose keys should be installed
as trusted for the root user. These user's keys
should already be set up, see :any:`ProfileGroup.ssh_keys`
for details.
:type authorized_users: list[str]
:param disk_encryption: The disk encryption policy for this Linode.
NOTE: Disk encryption may not currently be available to all users.
:type disk_encryption: InstanceDiskEncryptionType or str

:returns: The newly generated password, if one was not provided
(otherwise True)
:rtype: str or bool
:returns: True.
:rtype: bool
"""
ret_pass = None
if not root_pass:
ret_pass = Instance.generate_root_password()
root_pass = ret_pass
if not root_pass and not authorized_keys and not authorized_users:
raise ValueError(
"When rebuilding an Instance, at least one of "
"root_pass, authorized_users, or authorized_keys must be provided."
)

authorized_keys = load_and_validate_keys(authorized_keys)

Expand All @@ -1621,6 +1631,7 @@ def rebuild(
"disk_encryption": (
str(disk_encryption) if disk_encryption else None
),
"authorized_users": authorized_users,
}

params.update(kwargs)
Expand All @@ -1639,10 +1650,7 @@ def rebuild(
# update ourself with the newly-returned information
self._populate(result)

if not ret_pass:
return True
else:
return ret_pass
return True

def rescue(self, *disks):
"""
Expand Down
18 changes: 12 additions & 6 deletions test/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,13 @@ def create_linode(test_linode_client, e2e_test_firewall):
region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core")
label = get_test_label(length=8)

linode_instance, password = client.linode.instance_create(
linode_instance = client.linode.instance_create(
"g6-nanode-1",
region,
image="linode/debian12",
label=label,
firewall=e2e_test_firewall,
root_pass="aComplex@Password123",
)

yield linode_instance
Expand All @@ -242,13 +243,15 @@ def create_linode_for_pass_reset(test_linode_client, e2e_test_firewall):

region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core")
label = get_test_label(length=8)
password = "aComplex@Password123"

linode_instance, password = client.linode.instance_create(
linode_instance = client.linode.instance_create(
"g6-nanode-1",
region,
image="linode/debian12",
label=label,
firewall=e2e_test_firewall,
root_pass=password,
)

yield linode_instance, password
Expand Down Expand Up @@ -488,15 +491,16 @@ def create_vpc_with_subnet_and_linode(

label = get_test_label(length=8)

instance, password = test_linode_client.linode.instance_create(
instance = test_linode_client.linode.instance_create(
"g6-standard-1",
vpc.region,
image="linode/debian11",
label=label,
firewall=e2e_test_firewall,
root_pass="aComplex@Password123",
)

yield vpc, subnet, instance, password
yield vpc, subnet, instance

instance.delete()

Expand Down Expand Up @@ -579,12 +583,13 @@ def linode_for_vlan_tests(test_linode_client, e2e_test_firewall):
region = get_region(client, {"Linodes", "Vlans"}, site_type="core")
label = get_test_label(length=8)

linode_instance, password = client.linode.instance_create(
linode_instance = client.linode.instance_create(
"g6-nanode-1",
region,
image="linode/debian12",
label=label,
firewall=e2e_test_firewall,
root_pass="aComplex@Password123",
)

yield linode_instance
Expand Down Expand Up @@ -628,13 +633,14 @@ def linode_with_linode_interfaces(
region = vpc.region
label = get_test_label()

instance, _ = client.linode.instance_create(
instance = client.linode.instance_create(
"g6-nanode-1",
region,
image="linode/debian12",
label=label,
booted=False,
interface_generation=InterfaceGeneration.LINODE,
root_pass="aComplex@Password123",
interfaces=[
LinodeInterfaceOptions(
firewall_id=e2e_test_firewall.id,
Expand Down
Loading
Loading