Skip to content

OCI Compute

cloudspells.providers.oci.compute

Compute Instance spell for CloudSpells.

Provides ComputeInstance, which deploys a single OCI VM into a chosen VCN subnet and attaches one or more block volumes for persistent storage.

Key behaviours:

  • Defaults to Oracle Linux 8 (latest image for the chosen shape).
  • Deploys to the VCN's private subnet by default (not directly internet-facing).
  • Adds a minimal SSH ingress rule to the appropriate security list (port 22 from the public subnet CIDR for bastion-host access).
  • Auto-generates an RSA 4096-bit SSH key pair when no key is supplied; the keys are exported as Pulumi secrets.
  • Accepts a list of VolumeSpec objects to attach any number of block volumes; defaults to a single 100 GiB balanced-performance data volume.
  • Calls Vcn.finalize_network automatically.
Exports

ComputeInstance: Single-VM component resource with multi-volume support.

ComputeInstance

Bases: BaseResource, AbstractCompute

OCI Compute Instance with one or more attached block volumes.

Creates a single VM in the chosen VCN subnet together with the block volumes described by the volumes parameter. Each VolumeSpec in the list produces one oci.core.Volume and one oci.core.VolumeAttachment; all are created at the same time as the instance.

Attributes:

Name Type Description
vcn Vcn | VcnRef

The Vcn this instance is deployed into.

shape Input[str]

Compute shape (e.g. "VM.Standard.E4.Flex").

ocpus Input[float]

Number of OCPUs allocated to the instance.

memory_in_gbs Input[float]

RAM in GiB allocated to the instance.

ssh_public_key str

OpenSSH public key installed in authorized_keys.

ssh_private_key str | None

Corresponding private key string, or None when the caller supplied their own public key.

image_id Input[str] | None

OCID of the boot image used by the instance.

boot_volume_size_in_gbs Input[int]

Size of the boot volume in GiB.

volumes_spec list[VolumeSpec]

Resolved list of VolumeSpec objects used to create the attached block volumes.

instance Instance

The underlying oci.core.Instance resource.

block_volumes list[Volume]

Ordered list of oci.core.Volume resources, one per entry in volumes_spec.

volume_attachments list[VolumeAttachment]

Ordered list of oci.core.VolumeAttachment resources, parallel to block_volumes.

id Output[str]

pulumi.Output[str] of the instance OCID.

auto_generated_keys bool

True when SSH keys were auto-generated.

fault_domain str | None

Fault domain the instance is placed in, or None when OCI auto-assigns (default spread behaviour).

hostname_label str | None

DNS hostname for the primary VNIC, or None.

preserve_boot_volume bool

Whether the boot volume is retained after instance termination.

Usage patterns:

  1. Minimal — single default data volume, auto-generated SSH keys: python vcn = Vcn(name="lab", compartment_id=comp_id, stack_name="prod") instance = ComputeInstance( name="web", vcn=vcn, compartment_id=comp_id, ) private_key = instance.get_ssh_private_key()

  2. Multiple volumes with explicit performance tiers: python instance = ComputeInstance( name="app", vcn=vcn, compartment_id=comp_id, volumes=[ VolumeSpec(size_in_gbs=200, label="app"), VolumeSpec(size_in_gbs=500, label="db", vpus_per_gb=VolumeSpec.PERF_HIGH), VolumeSpec(size_in_gbs=100, label="logs", vpus_per_gb=VolumeSpec.PERF_LOW), ], ) db_vol_id = instance.get_volume_id("db")

  3. Custom shape and boot volume: python instance = ComputeInstance( name="heavy", vcn=vcn, compartment_id=comp_id, shape="VM.Standard.E4.Flex", ocpus=8, memory_in_gbs=128, boot_volume_size_in_gbs=100, volumes=[VolumeSpec(size_in_gbs=1000, label="data", vpus_per_gb=VolumeSpec.PERF_HIGH)], )

Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
class ComputeInstance(BaseResource, AbstractCompute):
    """OCI Compute Instance with one or more attached block volumes.

    Creates a single VM in the chosen VCN subnet together with the block
    volumes described by the `volumes` parameter.  Each `VolumeSpec` in the
    list produces one `oci.core.Volume` and one `oci.core.VolumeAttachment`;
    all are created at the same time as the instance.

    Attributes:
        vcn: The `Vcn` this instance is deployed into.
        shape: Compute shape (e.g. `"VM.Standard.E4.Flex"`).
        ocpus: Number of OCPUs allocated to the instance.
        memory_in_gbs: RAM in GiB allocated to the instance.
        ssh_public_key: OpenSSH public key installed in `authorized_keys`.
        ssh_private_key: Corresponding private key string, or `None` when
            the caller supplied their own public key.
        image_id: OCID of the boot image used by the instance.
        boot_volume_size_in_gbs: Size of the boot volume in GiB.
        volumes_spec: Resolved list of `VolumeSpec` objects used to create
            the attached block volumes.
        instance: The underlying `oci.core.Instance` resource.
        block_volumes: Ordered list of `oci.core.Volume` resources, one per
            entry in `volumes_spec`.
        volume_attachments: Ordered list of `oci.core.VolumeAttachment`
            resources, parallel to `block_volumes`.
        id: `pulumi.Output[str]` of the instance OCID.
        auto_generated_keys: `True` when SSH keys were auto-generated.
        fault_domain: Fault domain the instance is placed in, or `None`
            when OCI auto-assigns (default spread behaviour).
        hostname_label: DNS hostname for the primary VNIC, or `None`.
        preserve_boot_volume: Whether the boot volume is retained after
            instance termination.

    Usage patterns:

    1. **Minimal — single default data volume, auto-generated SSH keys**:
        ```python
        vcn = Vcn(name="lab", compartment_id=comp_id, stack_name="prod")
        instance = ComputeInstance(
            name="web",
            vcn=vcn,
            compartment_id=comp_id,
        )
        private_key = instance.get_ssh_private_key()
        ```

    2. **Multiple volumes with explicit performance tiers**:
        ```python
        instance = ComputeInstance(
            name="app",
            vcn=vcn,
            compartment_id=comp_id,
            volumes=[
                VolumeSpec(size_in_gbs=200, label="app"),
                VolumeSpec(size_in_gbs=500, label="db",
                           vpus_per_gb=VolumeSpec.PERF_HIGH),
                VolumeSpec(size_in_gbs=100, label="logs",
                           vpus_per_gb=VolumeSpec.PERF_LOW),
            ],
        )
        db_vol_id = instance.get_volume_id("db")
        ```

    3. **Custom shape and boot volume**:
        ```python
        instance = ComputeInstance(
            name="heavy",
            vcn=vcn,
            compartment_id=comp_id,
            shape="VM.Standard.E4.Flex",
            ocpus=8,
            memory_in_gbs=128,
            boot_volume_size_in_gbs=100,
            volumes=[VolumeSpec(size_in_gbs=1000, label="data",
                                vpus_per_gb=VolumeSpec.PERF_HIGH)],
        )
        ```
    """

    vcn: Vcn | VcnRef
    shape: pulumi.Input[str]
    ocpus: pulumi.Input[float]
    memory_in_gbs: pulumi.Input[float]
    ssh_public_key: str
    ssh_private_key: str | None
    image_id: pulumi.Input[str] | None
    boot_volume_size_in_gbs: pulumi.Input[int]
    volumes_spec: list[VolumeSpec]
    instance: oci.core.Instance
    block_volumes: list[oci.core.Volume]
    volume_attachments: list[oci.core.VolumeAttachment]
    id: pulumi.Output[str]
    auto_generated_keys: bool
    fault_domain: str | None
    hostname_label: str | None
    preserve_boot_volume: bool

    def __init__(
        self,
        name: str,
        compartment_id: pulumi.Input[str],
        vcn: Vcn | VcnRef,
        stack_name: str | None = None,
        ssh_public_key: pulumi.Input[str] | None = None,
        shape: pulumi.Input[str] = "VM.Standard.E4.Flex",
        ocpus: pulumi.Input[float] = 1,
        memory_in_gbs: pulumi.Input[float] = 16,
        image_id: pulumi.Input[str] | None = None,
        os_name: str = "oracle",
        subnet: SubnetTier = SUBNET_PRIVATE,
        boot_volume_size_in_gbs: pulumi.Input[int] = 50,
        boot_volume_vpus_per_gb: int = 10,
        volumes: Sequence[VolumeSpec] | None = None,
        nsg_ids: list[pulumi.Input[str]] | None = None,
        nsg: Nsg | None = None,
        user_data: str | bytes | None = None,
        defined_tags: dict[str, Any] | None = None,
        fault_domain: str | None = None,
        hostname_label: str | None = None,
        private_ip: str | None = None,
        skip_source_dest_check: bool = False,
        is_pv_encryption_in_transit: bool = False,
        preserve_boot_volume: bool = False,
        recovery_action: str | None = None,
        baseline_ocpu_utilization: str | None = None,
        dedicated_vm_host_id: str | None = None,
        capacity_reservation_id: str | None = None,
        opts: pulumi.ResourceOptions | None = None,
    ) -> None:
        """Create a compute instance with one or more attached block volumes.

        Args:
            name: Logical name for the instance (e.g. `"web-server"`).
            compartment_id: OCID of the OCI compartment to deploy into.
            vcn: `Vcn` instance that provides the subnet and security list
                for this instance.
            stack_name: Pulumi stack name.  Defaults to
                `pulumi.get_stack()` when `None`.
            ssh_public_key: OpenSSH public key string to install on the
                instance.  When `None` or empty, a new RSA 4096-bit key
                pair is auto-generated and exported as Pulumi secrets.
            shape: OCI compute shape (default: `"VM.Standard.E4.Flex"`).
            ocpus: Number of OCPUs (default: `1`).
            memory_in_gbs: Memory in GiB (default: `16`).
            image_id: Explicit boot image OCID.  When provided, `os_name`
                is ignored.
            os_name: Friendly OS name used to auto-discover the latest
                image when `image_id` is `None`.  Supported values:
                `"oracle"` (Oracle Linux 8, default), `"ubuntu"`
                (Canonical Ubuntu 22.04), `"windows"`
                (Windows Server 2022 Standard).
            subnet: Which VCN tier to place the instance in.  Use the
                constants `SUBNET_PRIVATE` (default), `SUBNET_PUBLIC`,
                `SUBNET_SECURE`, or `SUBNET_MANAGEMENT` from
                `cloudspells.providers.oci.network`.  Ignored when `nsg`
                is supplied and the NSG has a `Role` — the role's
                `subnet_tier` takes precedence.
            boot_volume_size_in_gbs: Boot volume size in GiB (default:
                `50`).
            volumes: Ordered list of `VolumeSpec` objects describing the
                block volumes to attach.  Each entry must have a unique
                `label`; the label is used to derive the resource name
                suffix.  Defaults to `None`, which creates a single 100 GiB
                balanced-performance data volume (`VolumeSpec(size_in_gbs=100)`).
                Pass an explicit list to override; an empty list raises
                `ValueError`.
            nsg_ids: List of Network Security Group OCIDs to attach to the
                instance VNIC.  When `None`, no NSGs are attached and
                security is enforced by the subnet security list alone.
                Provide NSG OCIDs (e.g. from `Nsg`) to add a second,
                resource-level security layer.
            nsg: Shorthand for single-NSG deployments.  When supplied, sets
                `nsg_ids=[nsg.id]` and, if the NSG carries a `Role`, also
                infers `subnet` from `nsg.role.subnet_tier`.  Takes
                precedence over `subnet` and `nsg_ids` when both are
                provided.
            boot_volume_vpus_per_gb: Boot volume performance tier.  Use
                `0` (low), `10` (balanced, default), `20` (high), or
                `120` (ultra-high).
            user_data: Cloud-init script as a plain `str` or `bytes`.
                CloudSpells base64-encodes it before passing to OCI.  When
                `None`, no user data is injected.
            defined_tags: OCI defined tags applied to the instance and all
                block volumes, in `{"namespace": {"key": "value"}}` format.
                Per-volume `defined_tags` on `VolumeSpec` are merged on top
                of this value (volume spec wins on conflict).
            fault_domain: Explicit fault domain for placement
                (e.g. `"FAULT-DOMAIN-1"`).  When `None`, OCI auto-assigns
                and spreads instances across fault domains.
            hostname_label: DNS hostname registered for the primary VNIC.
                Must be unique within the subnet.  When `None`, OCI does
                not assign a hostname.
            private_ip: Explicit private IP address for the primary VNIC.
                Must fall within the selected subnet's CIDR.  When `None`,
                OCI assigns the next available IP.
            skip_source_dest_check: Disable the source/destination check on
                the primary VNIC.  Set to `True` for NAT instances or
                software routers.  Defaults to `False`.
            is_pv_encryption_in_transit: Encrypt data in transit between
                the instance and paravirtualized-attached volumes.
                Defaults to `False`.
            preserve_boot_volume: Keep the boot volume after the instance
                is terminated.  Defaults to `False` (boot volume is deleted
                with the instance).
            recovery_action: Live-migration recovery action.  Pass
                `"RESTORE_INSTANCE"` (default OCI behaviour) to restart
                after host maintenance, or `"STOP_INSTANCE"` to stop
                instead.  `None` accepts the OCI account default.
            baseline_ocpu_utilization: Burstable-instance CPU baseline.
                One of `"BASELINE_1_8"` (12.5 %), `"BASELINE_1_2"` (50 %),
                or `"BASELINE_1_1"` (100 % — effectively non-burstable).
                `None` uses a standard (non-burstable) instance.
            dedicated_vm_host_id: OCID of the dedicated VM host to place
                this instance on.  When `None`, the instance runs on shared
                infrastructure.
            capacity_reservation_id: OCID of the capacity reservation to
                consume.  When `None`, no reservation is used.
            opts: Pulumi resource options forwarded to the component.

        Raises:
            ValueError: If `subnet` is not a recognised tier constant, or if
                any two `VolumeSpec` entries share the same `label`.

        Example:
            ```python
            # Role-based shorthand — subnet inferred from NSG role
            web = ComputeInstance("web-1", compartment_id=comp_id, vcn=vcn, nsg=web_nsg)
            db  = ComputeInstance("db-1",  compartment_id=comp_id, vcn=vcn, nsg=db_nsg,
                                  volumes=[VolumeSpec(size_in_gbs=200, label="data")])
            ```
        """
        # Resolve nsg= shorthand: infer subnet from role and expand nsg_ids.
        if nsg is not None:
            if nsg.role is not None:
                subnet = nsg.role.subnet_tier
            nsg_ids = [nsg.id]
        super().__init__("custom:compute:Instance", name, compartment_id, stack_name, opts)

        self.name = name
        self.vcn = vcn
        self.compartment_id = compartment_id
        self.shape = shape
        self.ocpus = ocpus
        self.memory_in_gbs = memory_in_gbs

        self.subnet = subnet
        self.image_id = image_id
        self.boot_volume_size_in_gbs = boot_volume_size_in_gbs
        self.fault_domain = fault_domain
        self.hostname_label = hostname_label
        self.preserve_boot_volume = preserve_boot_volume

        # Resolve volumes list.
        # None  → one default 100 GiB balanced-performance data volume.
        # []    → caller error; an explicitly empty list has no valid meaning.
        if volumes is None:
            self.volumes_spec = [VolumeSpec(size_in_gbs=100)]
        elif len(volumes) == 0:
            raise ValueError(
                "volumes must not be empty; pass volumes=None to use the default "
                "100 GiB data volume, or provide at least one VolumeSpec."
            )
        else:
            self.volumes_spec = list(volumes)
        labels = [spec.label for spec in self.volumes_spec]
        duplicates = {lbl for lbl in labels if labels.count(lbl) > 1}
        if duplicates:
            raise ValueError(
                f"VolumeSpec labels must be unique within the list; duplicates found: {sorted(duplicates)}"
            )

        self.nsg_ids = nsg_ids or []

        # SSH key setup
        self._setup_ssh_keys(ssh_public_key)

        # Accumulate security rules, then materialise the VCN.
        # Skip rule-addition when the network is already finalised (e.g. a
        # sibling ComputeInstance was constructed first); the caller is
        # responsible for adding any additional rules before the first spell
        # triggers finalisation.
        if isinstance(self.vcn, Vcn) and not self.vcn._security_lists_finalized:
            self._add_compute_security_rules()
        self.vcn.finalize_network()

        assert self.vcn.private_subnet is not None
        assert self.vcn.public_subnet is not None
        assert self.vcn.secure_subnet is not None
        assert self.vcn.management_subnet is not None

        # Resolve boot image
        resolved_image_id = str(image_id) if image_id is not None else None
        self.image_id = OciHelper().resolve_image_id(str(compartment_id), str(shape), resolved_image_id, os_name)

        ads = oci.identity.get_availability_domains(compartment_id=str(compartment_id))
        availability_domain = ads.availability_domains[0].name

        # ---- Encode cloud-init user data --------------------------------
        encoded_user_data: str | None = None
        if user_data is not None:
            raw = user_data.encode() if isinstance(user_data, str) else user_data
            encoded_user_data = base64.b64encode(raw).decode()

        # ---- Compute instance ------------------------------------------
        instance_name = self.create_resource_name("instance")

        instance_metadata: dict[str, str] = {"ssh_authorized_keys": self.ssh_public_key}
        if encoded_user_data is not None:
            instance_metadata["user_data"] = encoded_user_data

        self.instance = oci.core.Instance(
            instance_name,
            availability_domain=availability_domain,
            compartment_id=self.compartment_id,
            shape=self.shape,
            display_name=instance_name,
            fault_domain=fault_domain,
            dedicated_vm_host_id=dedicated_vm_host_id,
            capacity_reservation_id=capacity_reservation_id,
            source_details=oci.core.InstanceSourceDetailsArgs(
                source_type="image",
                source_id=self.image_id,
                boot_volume_size_in_gbs=str(self.boot_volume_size_in_gbs),
                boot_volume_vpus_per_gb=str(boot_volume_vpus_per_gb),
            ),
            create_vnic_details=oci.core.InstanceCreateVnicDetailsArgs(
                subnet_id=(
                    self.vcn.public_subnet.id
                    if self.subnet == SUBNET_PUBLIC
                    else self.vcn.secure_subnet.id
                    if self.subnet == SUBNET_SECURE
                    else self.vcn.management_subnet.id
                    if self.subnet == SUBNET_MANAGEMENT
                    else self.vcn.private_subnet.id
                ),
                assign_public_ip="true" if self.subnet == SUBNET_PUBLIC else "false",
                display_name=f"{instance_name}-vnic",
                nsg_ids=self.nsg_ids if self.nsg_ids else None,
                hostname_label=hostname_label,
                private_ip=private_ip,
                skip_source_dest_check=skip_source_dest_check,
            ),
            metadata=instance_metadata,
            shape_config=oci.core.InstanceShapeConfigArgs(
                ocpus=self.ocpus,
                memory_in_gbs=self.memory_in_gbs,
                baseline_ocpu_utilization=baseline_ocpu_utilization,
            ),
            availability_config=oci.core.InstanceAvailabilityConfigArgs(
                recovery_action=recovery_action,
            )
            if recovery_action is not None
            else None,
            is_pv_encryption_in_transit_enabled=is_pv_encryption_in_transit,
            preserve_boot_volume=preserve_boot_volume,
            freeform_tags=self.create_freeform_tags(
                instance_name,
                "compute-instance",
                {
                    "Shape": str(shape),
                    "OCPUs": str(ocpus),
                    "MemoryGB": str(memory_in_gbs),
                },
            ),
            defined_tags=defined_tags,
            opts=pulumi.ResourceOptions(parent=self),
        )

        self.id = self.instance.id

        # ---- Block volumes (one per VolumeSpec) ------------------------
        self.block_volumes = []
        self.volume_attachments = []

        for spec in self.volumes_spec:
            vol_name = self.create_resource_name(f"{spec.label}-vol")
            # Per-volume defined_tags: merge instance-level tags with
            # spec-level tags; spec-level wins on key conflicts.
            vol_defined_tags: dict[str, Any] | None = defined_tags
            if spec.defined_tags is not None:
                vol_defined_tags = {**(defined_tags or {}), **spec.defined_tags}
            vol = oci.core.Volume(
                vol_name,
                availability_domain=availability_domain,
                compartment_id=self.compartment_id,
                display_name=vol_name,
                size_in_gbs=str(spec.size_in_gbs),
                vpus_per_gb=str(spec.vpus_per_gb),
                freeform_tags=self.create_freeform_tags(
                    vol_name,
                    "block-volume",
                    {
                        "SizeGB": str(spec.size_in_gbs),
                        "Label": spec.label,
                        "PerfTier": str(spec.vpus_per_gb),
                        "AttachedTo": instance_name,
                    },
                ),
                defined_tags=vol_defined_tags,
                opts=pulumi.ResourceOptions(parent=self),
            )
            att_name = self.create_resource_name(f"{spec.label}-vol-attach")
            att = oci.core.VolumeAttachment(
                att_name,
                instance_id=self.instance.id,
                volume_id=vol.id,
                attachment_type="paravirtualized",
                display_name=att_name,
                is_read_only=spec.is_read_only,
                device=spec.device,
                opts=pulumi.ResourceOptions(parent=self, delete_before_replace=True),
            )
            self.block_volumes.append(vol)
            self.volume_attachments.append(att)

        # ---- Stack outputs ---------------------------------------------
        outputs: dict[str, pulumi.Output[str]] = {
            "instance_id": self.instance.id,
            "private_ip": self.instance.private_ip,
        }
        for spec, vol in zip(self.volumes_spec, self.block_volumes):
            outputs[f"{spec.label}_volume_id"] = vol.id
        if self.subnet == SUBNET_PUBLIC:
            outputs["public_ip"] = self.instance.public_ip
        outputs.update(self._get_ssh_outputs())
        self.register_outputs(outputs)

    # ------------------------------------------------------------------
    # Backward-compatibility shims
    # ------------------------------------------------------------------

    @property
    def block_volume(self) -> oci.core.Volume:
        """Return the first (primary) block volume.

        Provides backward compatibility for code that references
        `instance.block_volume` directly.  For multi-volume setups use
        `block_volumes` or `get_volume` instead.

        Returns:
            The first `oci.core.Volume` in `block_volumes`.
        """
        return self.block_volumes[0]

    @property
    def volume_attachment(self) -> oci.core.VolumeAttachment:
        """Return the first (primary) volume attachment.

        Provides backward compatibility for code that references
        `instance.volume_attachment` directly.  For multi-volume setups use
        `volume_attachments` or `get_volume_attachment` instead.

        Returns:
            The first `oci.core.VolumeAttachment` in `volume_attachments`.
        """
        return self.volume_attachments[0]

    # ------------------------------------------------------------------
    # Private helpers
    # ------------------------------------------------------------------

    def _add_compute_security_rules(self) -> None:
        """Add SSH ingress rule to the appropriate VCN security list.

        For **private** subnet instances: allows TCP port 22 from the public
        subnet CIDR (bastion-host pattern).

        For **secure** / **management** subnet instances: allows TCP port 22
        from the private subnet CIDR.

        For **public** subnet instances: allows TCP port 22 from anywhere
        (`0.0.0.0/0`).
        """
        ssh_rule = oci.core.SecurityListIngressSecurityRuleArgs(
            protocol="6",
            source_type="CIDR_BLOCK",
            tcp_options=oci.core.SecurityListIngressSecurityRuleTcpOptionsArgs(min=22, max=22),
            description=(
                "SSH access from private subnet"
                if self.subnet in (SUBNET_SECURE, SUBNET_MANAGEMENT)
                else "SSH access from public subnet (bastion host)"
                if self.subnet == SUBNET_PRIVATE
                else "SSH access from the internet"
            ),
            source=(
                self.vcn.get_private_subnet_cidr()
                if self.subnet in (SUBNET_SECURE, SUBNET_MANAGEMENT)
                else self.vcn.get_public_subnet_cidr()
                if self.subnet == SUBNET_PRIVATE
                else "0.0.0.0/0"
            ),
        )

        if self.subnet == SUBNET_PRIVATE:
            self.vcn.add_security_list_rules(private_ingress=[ssh_rule])
        elif self.subnet == SUBNET_SECURE:
            self.vcn.add_security_list_rules(secure_ingress=[ssh_rule])
        elif self.subnet == SUBNET_MANAGEMENT:
            self.vcn.add_security_list_rules(management_ingress=[ssh_rule])
        else:
            self.vcn.add_security_list_rules(public_ingress=[ssh_rule])

    # ------------------------------------------------------------------
    # Public accessors
    # ------------------------------------------------------------------

    def export(self) -> None:
        """Export standard compute instance stack outputs.

        Publishes instance OCID, private IP, all block volume OCIDs, and SSH
        public key under keys derived from the spell's logical name.  The SSH
        private key is exported as a Pulumi secret only when it was
        auto-generated.  Each volume is exported under
        `{name}_{label}_volume_id`.

        Example:
            ```python
            instance = ComputeInstance(
                name="app",
                vcn=vcn,
                compartment_id=comp_id,
                volumes=[
                    VolumeSpec(size_in_gbs=100, label="data"),
                    VolumeSpec(size_in_gbs=500, label="db"),
                ],
            )
            instance.export()
            # Exports: app_id, app_private_ip,
            #          app_data_volume_id, app_db_volume_id,
            #          app_ssh_public_key,
            #          app_ssh_private_key (secret, only if auto-generated)
            ```
        """
        prefix = self.name.replace("-", "_")
        pulumi.export(f"{prefix}_id", self.get_instance_id())
        pulumi.export(f"{prefix}_private_ip", self.get_private_ip())
        if self.subnet == SUBNET_PUBLIC:
            pulumi.export(f"{prefix}_public_ip", self.instance.public_ip)
        for spec, vol in zip(self.volumes_spec, self.block_volumes):
            pulumi.export(f"{prefix}_{spec.label}_volume_id", vol.id)
        pulumi.export(f"{prefix}_ssh_public_key", self.get_ssh_public_key())
        if self.auto_generated_keys and self.ssh_private_key:
            pulumi.export(
                f"{prefix}_ssh_private_key",
                pulumi.Output.secret(self.ssh_private_key),
            )

    def get_private_ip(self) -> pulumi.Output[str]:
        """Return the private IP address of the instance.

        Returns:
            `pulumi.Output[str]` resolving to the instance's private IP.
        """
        return self.instance.private_ip

    def get_instance_id(self) -> pulumi.Output[str]:
        """Return the OCID of the compute instance.

        Returns:
            `pulumi.Output[str]` resolving to the instance OCID.
        """
        return self.instance.id

    def get_block_volume_id(self) -> pulumi.Output[str]:
        """Return the OCID of the first (primary) block volume.

        Provided for backward compatibility.  For multi-volume setups use
        `get_volume_id` or `get_all_volume_ids`.

        Returns:
            `pulumi.Output[str]` resolving to the first block volume OCID.
        """
        return self.block_volumes[0].id

    def get_volume_id(self, label: str) -> pulumi.Output[str]:
        """Return the OCID of the block volume with the given label.

        Args:
            label: The `label` value of the target `VolumeSpec`.

        Returns:
            `pulumi.Output[str]` resolving to the volume OCID.

        Raises:
            KeyError: If no volume with the given label exists.

        Example:
            ```python
            db_vol_id = instance.get_volume_id("db")
            ```
        """
        for spec, vol in zip(self.volumes_spec, self.block_volumes):
            if spec.label == label:
                return vol.id
        raise KeyError(f"No volume with label {label!r}. Available labels: {[s.label for s in self.volumes_spec]}")

    def get_disk_id(self, label: str) -> pulumi.Output[str]:
        """Return the OCID of the block volume with the given label.

        Satisfies `AbstractCompute.get_disk_id`.  Delegates to
        `get_volume_id`.

        Args:
            label: The `label` value of the target `VolumeSpec`.

        Returns:
            `pulumi.Output[str]` resolving to the volume OCID.

        Raises:
            KeyError: If no volume with the given label exists.
        """
        return self.get_volume_id(label)

    def get_volume(self, label: str) -> oci.core.Volume:
        """Return the `oci.core.Volume` resource with the given label.

        Args:
            label: The `label` value of the target `VolumeSpec`.

        Returns:
            The `oci.core.Volume` resource.

        Raises:
            KeyError: If no volume with the given label exists.
        """
        for spec, vol in zip(self.volumes_spec, self.block_volumes):
            if spec.label == label:
                return vol
        raise KeyError(f"No volume with label {label!r}. Available labels: {[s.label for s in self.volumes_spec]}")

    def get_volume_attachment(self, label: str) -> oci.core.VolumeAttachment:
        """Return the `oci.core.VolumeAttachment` resource with the given label.

        Args:
            label: The `label` value of the target `VolumeSpec`.

        Returns:
            The `oci.core.VolumeAttachment` resource.

        Raises:
            KeyError: If no volume with the given label exists.
        """
        for spec, att in zip(self.volumes_spec, self.volume_attachments):
            if spec.label == label:
                return att
        raise KeyError(
            f"No volume attachment with label {label!r}. Available labels: {[s.label for s in self.volumes_spec]}"
        )

    def get_all_volume_ids(self) -> list[pulumi.Output[str]]:
        """Return a list of OCIDs for all attached block volumes.

        The list order matches the order of the `volumes` parameter passed
        at construction time.

        Returns:
            List of `pulumi.Output[str]` resolving to each volume OCID.
        """
        return [vol.id for vol in self.block_volumes]

    def get_ssh_public_key(self) -> str:
        """Return the SSH public key installed on the instance.

        Returns:
            OpenSSH public key string (auto-generated or caller-supplied).
        """
        return self.ssh_public_key

    def get_ssh_private_key(self) -> str | None:
        """Return the SSH private key if it was auto-generated.

        Returns:
            PEM-encoded private key string when keys were auto-generated,
            or `None` when the caller supplied their own public key.
        """
        return self.ssh_private_key

block_volume: oci.core.Volume property

Return the first (primary) block volume.

Provides backward compatibility for code that references instance.block_volume directly. For multi-volume setups use block_volumes or get_volume instead.

Returns:

Type Description
Volume

The first oci.core.Volume in block_volumes.

volume_attachment: oci.core.VolumeAttachment property

Return the first (primary) volume attachment.

Provides backward compatibility for code that references instance.volume_attachment directly. For multi-volume setups use volume_attachments or get_volume_attachment instead.

Returns:

Type Description
VolumeAttachment

The first oci.core.VolumeAttachment in volume_attachments.

__init__(name: str, compartment_id: pulumi.Input[str], vcn: Vcn | VcnRef, stack_name: str | None = None, ssh_public_key: pulumi.Input[str] | None = None, shape: pulumi.Input[str] = 'VM.Standard.E4.Flex', ocpus: pulumi.Input[float] = 1, memory_in_gbs: pulumi.Input[float] = 16, image_id: pulumi.Input[str] | None = None, os_name: str = 'oracle', subnet: SubnetTier = SUBNET_PRIVATE, boot_volume_size_in_gbs: pulumi.Input[int] = 50, boot_volume_vpus_per_gb: int = 10, volumes: Sequence[VolumeSpec] | None = None, nsg_ids: list[pulumi.Input[str]] | None = None, nsg: Nsg | None = None, user_data: str | bytes | None = None, defined_tags: dict[str, Any] | None = None, fault_domain: str | None = None, hostname_label: str | None = None, private_ip: str | None = None, skip_source_dest_check: bool = False, is_pv_encryption_in_transit: bool = False, preserve_boot_volume: bool = False, recovery_action: str | None = None, baseline_ocpu_utilization: str | None = None, dedicated_vm_host_id: str | None = None, capacity_reservation_id: str | None = None, opts: pulumi.ResourceOptions | None = None) -> None

Create a compute instance with one or more attached block volumes.

Parameters:

Name Type Description Default
name str

Logical name for the instance (e.g. "web-server").

required
compartment_id Input[str]

OCID of the OCI compartment to deploy into.

required
vcn Vcn | VcnRef

Vcn instance that provides the subnet and security list for this instance.

required
stack_name str | None

Pulumi stack name. Defaults to pulumi.get_stack() when None.

None
ssh_public_key Input[str] | None

OpenSSH public key string to install on the instance. When None or empty, a new RSA 4096-bit key pair is auto-generated and exported as Pulumi secrets.

None
shape Input[str]

OCI compute shape (default: "VM.Standard.E4.Flex").

'VM.Standard.E4.Flex'
ocpus Input[float]

Number of OCPUs (default: 1).

1
memory_in_gbs Input[float]

Memory in GiB (default: 16).

16
image_id Input[str] | None

Explicit boot image OCID. When provided, os_name is ignored.

None
os_name str

Friendly OS name used to auto-discover the latest image when image_id is None. Supported values: "oracle" (Oracle Linux 8, default), "ubuntu" (Canonical Ubuntu 22.04), "windows" (Windows Server 2022 Standard).

'oracle'
subnet SubnetTier

Which VCN tier to place the instance in. Use the constants SUBNET_PRIVATE (default), SUBNET_PUBLIC, SUBNET_SECURE, or SUBNET_MANAGEMENT from cloudspells.providers.oci.network. Ignored when nsg is supplied and the NSG has a Role — the role's subnet_tier takes precedence.

SUBNET_PRIVATE
boot_volume_size_in_gbs Input[int]

Boot volume size in GiB (default: 50).

50
volumes Sequence[VolumeSpec] | None

Ordered list of VolumeSpec objects describing the block volumes to attach. Each entry must have a unique label; the label is used to derive the resource name suffix. Defaults to None, which creates a single 100 GiB balanced-performance data volume (VolumeSpec(size_in_gbs=100)). Pass an explicit list to override; an empty list raises ValueError.

None
nsg_ids list[Input[str]] | None

List of Network Security Group OCIDs to attach to the instance VNIC. When None, no NSGs are attached and security is enforced by the subnet security list alone. Provide NSG OCIDs (e.g. from Nsg) to add a second, resource-level security layer.

None
nsg Nsg | None

Shorthand for single-NSG deployments. When supplied, sets nsg_ids=[nsg.id] and, if the NSG carries a Role, also infers subnet from nsg.role.subnet_tier. Takes precedence over subnet and nsg_ids when both are provided.

None
boot_volume_vpus_per_gb int

Boot volume performance tier. Use 0 (low), 10 (balanced, default), 20 (high), or 120 (ultra-high).

10
user_data str | bytes | None

Cloud-init script as a plain str or bytes. CloudSpells base64-encodes it before passing to OCI. When None, no user data is injected.

None
defined_tags dict[str, Any] | None

OCI defined tags applied to the instance and all block volumes, in {"namespace": {"key": "value"}} format. Per-volume defined_tags on VolumeSpec are merged on top of this value (volume spec wins on conflict).

None
fault_domain str | None

Explicit fault domain for placement (e.g. "FAULT-DOMAIN-1"). When None, OCI auto-assigns and spreads instances across fault domains.

None
hostname_label str | None

DNS hostname registered for the primary VNIC. Must be unique within the subnet. When None, OCI does not assign a hostname.

None
private_ip str | None

Explicit private IP address for the primary VNIC. Must fall within the selected subnet's CIDR. When None, OCI assigns the next available IP.

None
skip_source_dest_check bool

Disable the source/destination check on the primary VNIC. Set to True for NAT instances or software routers. Defaults to False.

False
is_pv_encryption_in_transit bool

Encrypt data in transit between the instance and paravirtualized-attached volumes. Defaults to False.

False
preserve_boot_volume bool

Keep the boot volume after the instance is terminated. Defaults to False (boot volume is deleted with the instance).

False
recovery_action str | None

Live-migration recovery action. Pass "RESTORE_INSTANCE" (default OCI behaviour) to restart after host maintenance, or "STOP_INSTANCE" to stop instead. None accepts the OCI account default.

None
baseline_ocpu_utilization str | None

Burstable-instance CPU baseline. One of "BASELINE_1_8" (12.5 %), "BASELINE_1_2" (50 %), or "BASELINE_1_1" (100 % — effectively non-burstable). None uses a standard (non-burstable) instance.

None
dedicated_vm_host_id str | None

OCID of the dedicated VM host to place this instance on. When None, the instance runs on shared infrastructure.

None
capacity_reservation_id str | None

OCID of the capacity reservation to consume. When None, no reservation is used.

None
opts ResourceOptions | None

Pulumi resource options forwarded to the component.

None

Raises:

Type Description
ValueError

If subnet is not a recognised tier constant, or if any two VolumeSpec entries share the same label.

Example
# Role-based shorthand — subnet inferred from NSG role
web = ComputeInstance("web-1", compartment_id=comp_id, vcn=vcn, nsg=web_nsg)
db  = ComputeInstance("db-1",  compartment_id=comp_id, vcn=vcn, nsg=db_nsg,
                      volumes=[VolumeSpec(size_in_gbs=200, label="data")])
Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
def __init__(
    self,
    name: str,
    compartment_id: pulumi.Input[str],
    vcn: Vcn | VcnRef,
    stack_name: str | None = None,
    ssh_public_key: pulumi.Input[str] | None = None,
    shape: pulumi.Input[str] = "VM.Standard.E4.Flex",
    ocpus: pulumi.Input[float] = 1,
    memory_in_gbs: pulumi.Input[float] = 16,
    image_id: pulumi.Input[str] | None = None,
    os_name: str = "oracle",
    subnet: SubnetTier = SUBNET_PRIVATE,
    boot_volume_size_in_gbs: pulumi.Input[int] = 50,
    boot_volume_vpus_per_gb: int = 10,
    volumes: Sequence[VolumeSpec] | None = None,
    nsg_ids: list[pulumi.Input[str]] | None = None,
    nsg: Nsg | None = None,
    user_data: str | bytes | None = None,
    defined_tags: dict[str, Any] | None = None,
    fault_domain: str | None = None,
    hostname_label: str | None = None,
    private_ip: str | None = None,
    skip_source_dest_check: bool = False,
    is_pv_encryption_in_transit: bool = False,
    preserve_boot_volume: bool = False,
    recovery_action: str | None = None,
    baseline_ocpu_utilization: str | None = None,
    dedicated_vm_host_id: str | None = None,
    capacity_reservation_id: str | None = None,
    opts: pulumi.ResourceOptions | None = None,
) -> None:
    """Create a compute instance with one or more attached block volumes.

    Args:
        name: Logical name for the instance (e.g. `"web-server"`).
        compartment_id: OCID of the OCI compartment to deploy into.
        vcn: `Vcn` instance that provides the subnet and security list
            for this instance.
        stack_name: Pulumi stack name.  Defaults to
            `pulumi.get_stack()` when `None`.
        ssh_public_key: OpenSSH public key string to install on the
            instance.  When `None` or empty, a new RSA 4096-bit key
            pair is auto-generated and exported as Pulumi secrets.
        shape: OCI compute shape (default: `"VM.Standard.E4.Flex"`).
        ocpus: Number of OCPUs (default: `1`).
        memory_in_gbs: Memory in GiB (default: `16`).
        image_id: Explicit boot image OCID.  When provided, `os_name`
            is ignored.
        os_name: Friendly OS name used to auto-discover the latest
            image when `image_id` is `None`.  Supported values:
            `"oracle"` (Oracle Linux 8, default), `"ubuntu"`
            (Canonical Ubuntu 22.04), `"windows"`
            (Windows Server 2022 Standard).
        subnet: Which VCN tier to place the instance in.  Use the
            constants `SUBNET_PRIVATE` (default), `SUBNET_PUBLIC`,
            `SUBNET_SECURE`, or `SUBNET_MANAGEMENT` from
            `cloudspells.providers.oci.network`.  Ignored when `nsg`
            is supplied and the NSG has a `Role` — the role's
            `subnet_tier` takes precedence.
        boot_volume_size_in_gbs: Boot volume size in GiB (default:
            `50`).
        volumes: Ordered list of `VolumeSpec` objects describing the
            block volumes to attach.  Each entry must have a unique
            `label`; the label is used to derive the resource name
            suffix.  Defaults to `None`, which creates a single 100 GiB
            balanced-performance data volume (`VolumeSpec(size_in_gbs=100)`).
            Pass an explicit list to override; an empty list raises
            `ValueError`.
        nsg_ids: List of Network Security Group OCIDs to attach to the
            instance VNIC.  When `None`, no NSGs are attached and
            security is enforced by the subnet security list alone.
            Provide NSG OCIDs (e.g. from `Nsg`) to add a second,
            resource-level security layer.
        nsg: Shorthand for single-NSG deployments.  When supplied, sets
            `nsg_ids=[nsg.id]` and, if the NSG carries a `Role`, also
            infers `subnet` from `nsg.role.subnet_tier`.  Takes
            precedence over `subnet` and `nsg_ids` when both are
            provided.
        boot_volume_vpus_per_gb: Boot volume performance tier.  Use
            `0` (low), `10` (balanced, default), `20` (high), or
            `120` (ultra-high).
        user_data: Cloud-init script as a plain `str` or `bytes`.
            CloudSpells base64-encodes it before passing to OCI.  When
            `None`, no user data is injected.
        defined_tags: OCI defined tags applied to the instance and all
            block volumes, in `{"namespace": {"key": "value"}}` format.
            Per-volume `defined_tags` on `VolumeSpec` are merged on top
            of this value (volume spec wins on conflict).
        fault_domain: Explicit fault domain for placement
            (e.g. `"FAULT-DOMAIN-1"`).  When `None`, OCI auto-assigns
            and spreads instances across fault domains.
        hostname_label: DNS hostname registered for the primary VNIC.
            Must be unique within the subnet.  When `None`, OCI does
            not assign a hostname.
        private_ip: Explicit private IP address for the primary VNIC.
            Must fall within the selected subnet's CIDR.  When `None`,
            OCI assigns the next available IP.
        skip_source_dest_check: Disable the source/destination check on
            the primary VNIC.  Set to `True` for NAT instances or
            software routers.  Defaults to `False`.
        is_pv_encryption_in_transit: Encrypt data in transit between
            the instance and paravirtualized-attached volumes.
            Defaults to `False`.
        preserve_boot_volume: Keep the boot volume after the instance
            is terminated.  Defaults to `False` (boot volume is deleted
            with the instance).
        recovery_action: Live-migration recovery action.  Pass
            `"RESTORE_INSTANCE"` (default OCI behaviour) to restart
            after host maintenance, or `"STOP_INSTANCE"` to stop
            instead.  `None` accepts the OCI account default.
        baseline_ocpu_utilization: Burstable-instance CPU baseline.
            One of `"BASELINE_1_8"` (12.5 %), `"BASELINE_1_2"` (50 %),
            or `"BASELINE_1_1"` (100 % — effectively non-burstable).
            `None` uses a standard (non-burstable) instance.
        dedicated_vm_host_id: OCID of the dedicated VM host to place
            this instance on.  When `None`, the instance runs on shared
            infrastructure.
        capacity_reservation_id: OCID of the capacity reservation to
            consume.  When `None`, no reservation is used.
        opts: Pulumi resource options forwarded to the component.

    Raises:
        ValueError: If `subnet` is not a recognised tier constant, or if
            any two `VolumeSpec` entries share the same `label`.

    Example:
        ```python
        # Role-based shorthand — subnet inferred from NSG role
        web = ComputeInstance("web-1", compartment_id=comp_id, vcn=vcn, nsg=web_nsg)
        db  = ComputeInstance("db-1",  compartment_id=comp_id, vcn=vcn, nsg=db_nsg,
                              volumes=[VolumeSpec(size_in_gbs=200, label="data")])
        ```
    """
    # Resolve nsg= shorthand: infer subnet from role and expand nsg_ids.
    if nsg is not None:
        if nsg.role is not None:
            subnet = nsg.role.subnet_tier
        nsg_ids = [nsg.id]
    super().__init__("custom:compute:Instance", name, compartment_id, stack_name, opts)

    self.name = name
    self.vcn = vcn
    self.compartment_id = compartment_id
    self.shape = shape
    self.ocpus = ocpus
    self.memory_in_gbs = memory_in_gbs

    self.subnet = subnet
    self.image_id = image_id
    self.boot_volume_size_in_gbs = boot_volume_size_in_gbs
    self.fault_domain = fault_domain
    self.hostname_label = hostname_label
    self.preserve_boot_volume = preserve_boot_volume

    # Resolve volumes list.
    # None  → one default 100 GiB balanced-performance data volume.
    # []    → caller error; an explicitly empty list has no valid meaning.
    if volumes is None:
        self.volumes_spec = [VolumeSpec(size_in_gbs=100)]
    elif len(volumes) == 0:
        raise ValueError(
            "volumes must not be empty; pass volumes=None to use the default "
            "100 GiB data volume, or provide at least one VolumeSpec."
        )
    else:
        self.volumes_spec = list(volumes)
    labels = [spec.label for spec in self.volumes_spec]
    duplicates = {lbl for lbl in labels if labels.count(lbl) > 1}
    if duplicates:
        raise ValueError(
            f"VolumeSpec labels must be unique within the list; duplicates found: {sorted(duplicates)}"
        )

    self.nsg_ids = nsg_ids or []

    # SSH key setup
    self._setup_ssh_keys(ssh_public_key)

    # Accumulate security rules, then materialise the VCN.
    # Skip rule-addition when the network is already finalised (e.g. a
    # sibling ComputeInstance was constructed first); the caller is
    # responsible for adding any additional rules before the first spell
    # triggers finalisation.
    if isinstance(self.vcn, Vcn) and not self.vcn._security_lists_finalized:
        self._add_compute_security_rules()
    self.vcn.finalize_network()

    assert self.vcn.private_subnet is not None
    assert self.vcn.public_subnet is not None
    assert self.vcn.secure_subnet is not None
    assert self.vcn.management_subnet is not None

    # Resolve boot image
    resolved_image_id = str(image_id) if image_id is not None else None
    self.image_id = OciHelper().resolve_image_id(str(compartment_id), str(shape), resolved_image_id, os_name)

    ads = oci.identity.get_availability_domains(compartment_id=str(compartment_id))
    availability_domain = ads.availability_domains[0].name

    # ---- Encode cloud-init user data --------------------------------
    encoded_user_data: str | None = None
    if user_data is not None:
        raw = user_data.encode() if isinstance(user_data, str) else user_data
        encoded_user_data = base64.b64encode(raw).decode()

    # ---- Compute instance ------------------------------------------
    instance_name = self.create_resource_name("instance")

    instance_metadata: dict[str, str] = {"ssh_authorized_keys": self.ssh_public_key}
    if encoded_user_data is not None:
        instance_metadata["user_data"] = encoded_user_data

    self.instance = oci.core.Instance(
        instance_name,
        availability_domain=availability_domain,
        compartment_id=self.compartment_id,
        shape=self.shape,
        display_name=instance_name,
        fault_domain=fault_domain,
        dedicated_vm_host_id=dedicated_vm_host_id,
        capacity_reservation_id=capacity_reservation_id,
        source_details=oci.core.InstanceSourceDetailsArgs(
            source_type="image",
            source_id=self.image_id,
            boot_volume_size_in_gbs=str(self.boot_volume_size_in_gbs),
            boot_volume_vpus_per_gb=str(boot_volume_vpus_per_gb),
        ),
        create_vnic_details=oci.core.InstanceCreateVnicDetailsArgs(
            subnet_id=(
                self.vcn.public_subnet.id
                if self.subnet == SUBNET_PUBLIC
                else self.vcn.secure_subnet.id
                if self.subnet == SUBNET_SECURE
                else self.vcn.management_subnet.id
                if self.subnet == SUBNET_MANAGEMENT
                else self.vcn.private_subnet.id
            ),
            assign_public_ip="true" if self.subnet == SUBNET_PUBLIC else "false",
            display_name=f"{instance_name}-vnic",
            nsg_ids=self.nsg_ids if self.nsg_ids else None,
            hostname_label=hostname_label,
            private_ip=private_ip,
            skip_source_dest_check=skip_source_dest_check,
        ),
        metadata=instance_metadata,
        shape_config=oci.core.InstanceShapeConfigArgs(
            ocpus=self.ocpus,
            memory_in_gbs=self.memory_in_gbs,
            baseline_ocpu_utilization=baseline_ocpu_utilization,
        ),
        availability_config=oci.core.InstanceAvailabilityConfigArgs(
            recovery_action=recovery_action,
        )
        if recovery_action is not None
        else None,
        is_pv_encryption_in_transit_enabled=is_pv_encryption_in_transit,
        preserve_boot_volume=preserve_boot_volume,
        freeform_tags=self.create_freeform_tags(
            instance_name,
            "compute-instance",
            {
                "Shape": str(shape),
                "OCPUs": str(ocpus),
                "MemoryGB": str(memory_in_gbs),
            },
        ),
        defined_tags=defined_tags,
        opts=pulumi.ResourceOptions(parent=self),
    )

    self.id = self.instance.id

    # ---- Block volumes (one per VolumeSpec) ------------------------
    self.block_volumes = []
    self.volume_attachments = []

    for spec in self.volumes_spec:
        vol_name = self.create_resource_name(f"{spec.label}-vol")
        # Per-volume defined_tags: merge instance-level tags with
        # spec-level tags; spec-level wins on key conflicts.
        vol_defined_tags: dict[str, Any] | None = defined_tags
        if spec.defined_tags is not None:
            vol_defined_tags = {**(defined_tags or {}), **spec.defined_tags}
        vol = oci.core.Volume(
            vol_name,
            availability_domain=availability_domain,
            compartment_id=self.compartment_id,
            display_name=vol_name,
            size_in_gbs=str(spec.size_in_gbs),
            vpus_per_gb=str(spec.vpus_per_gb),
            freeform_tags=self.create_freeform_tags(
                vol_name,
                "block-volume",
                {
                    "SizeGB": str(spec.size_in_gbs),
                    "Label": spec.label,
                    "PerfTier": str(spec.vpus_per_gb),
                    "AttachedTo": instance_name,
                },
            ),
            defined_tags=vol_defined_tags,
            opts=pulumi.ResourceOptions(parent=self),
        )
        att_name = self.create_resource_name(f"{spec.label}-vol-attach")
        att = oci.core.VolumeAttachment(
            att_name,
            instance_id=self.instance.id,
            volume_id=vol.id,
            attachment_type="paravirtualized",
            display_name=att_name,
            is_read_only=spec.is_read_only,
            device=spec.device,
            opts=pulumi.ResourceOptions(parent=self, delete_before_replace=True),
        )
        self.block_volumes.append(vol)
        self.volume_attachments.append(att)

    # ---- Stack outputs ---------------------------------------------
    outputs: dict[str, pulumi.Output[str]] = {
        "instance_id": self.instance.id,
        "private_ip": self.instance.private_ip,
    }
    for spec, vol in zip(self.volumes_spec, self.block_volumes):
        outputs[f"{spec.label}_volume_id"] = vol.id
    if self.subnet == SUBNET_PUBLIC:
        outputs["public_ip"] = self.instance.public_ip
    outputs.update(self._get_ssh_outputs())
    self.register_outputs(outputs)

export() -> None

Export standard compute instance stack outputs.

Publishes instance OCID, private IP, all block volume OCIDs, and SSH public key under keys derived from the spell's logical name. The SSH private key is exported as a Pulumi secret only when it was auto-generated. Each volume is exported under {name}_{label}_volume_id.

Example
instance = ComputeInstance(
    name="app",
    vcn=vcn,
    compartment_id=comp_id,
    volumes=[
        VolumeSpec(size_in_gbs=100, label="data"),
        VolumeSpec(size_in_gbs=500, label="db"),
    ],
)
instance.export()
# Exports: app_id, app_private_ip,
#          app_data_volume_id, app_db_volume_id,
#          app_ssh_public_key,
#          app_ssh_private_key (secret, only if auto-generated)
Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
def export(self) -> None:
    """Export standard compute instance stack outputs.

    Publishes instance OCID, private IP, all block volume OCIDs, and SSH
    public key under keys derived from the spell's logical name.  The SSH
    private key is exported as a Pulumi secret only when it was
    auto-generated.  Each volume is exported under
    `{name}_{label}_volume_id`.

    Example:
        ```python
        instance = ComputeInstance(
            name="app",
            vcn=vcn,
            compartment_id=comp_id,
            volumes=[
                VolumeSpec(size_in_gbs=100, label="data"),
                VolumeSpec(size_in_gbs=500, label="db"),
            ],
        )
        instance.export()
        # Exports: app_id, app_private_ip,
        #          app_data_volume_id, app_db_volume_id,
        #          app_ssh_public_key,
        #          app_ssh_private_key (secret, only if auto-generated)
        ```
    """
    prefix = self.name.replace("-", "_")
    pulumi.export(f"{prefix}_id", self.get_instance_id())
    pulumi.export(f"{prefix}_private_ip", self.get_private_ip())
    if self.subnet == SUBNET_PUBLIC:
        pulumi.export(f"{prefix}_public_ip", self.instance.public_ip)
    for spec, vol in zip(self.volumes_spec, self.block_volumes):
        pulumi.export(f"{prefix}_{spec.label}_volume_id", vol.id)
    pulumi.export(f"{prefix}_ssh_public_key", self.get_ssh_public_key())
    if self.auto_generated_keys and self.ssh_private_key:
        pulumi.export(
            f"{prefix}_ssh_private_key",
            pulumi.Output.secret(self.ssh_private_key),
        )

get_private_ip() -> pulumi.Output[str]

Return the private IP address of the instance.

Returns:

Type Description
Output[str]

pulumi.Output[str] resolving to the instance's private IP.

Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
594
595
596
597
598
599
600
def get_private_ip(self) -> pulumi.Output[str]:
    """Return the private IP address of the instance.

    Returns:
        `pulumi.Output[str]` resolving to the instance's private IP.
    """
    return self.instance.private_ip

get_instance_id() -> pulumi.Output[str]

Return the OCID of the compute instance.

Returns:

Type Description
Output[str]

pulumi.Output[str] resolving to the instance OCID.

Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
602
603
604
605
606
607
608
def get_instance_id(self) -> pulumi.Output[str]:
    """Return the OCID of the compute instance.

    Returns:
        `pulumi.Output[str]` resolving to the instance OCID.
    """
    return self.instance.id

get_block_volume_id() -> pulumi.Output[str]

Return the OCID of the first (primary) block volume.

Provided for backward compatibility. For multi-volume setups use get_volume_id or get_all_volume_ids.

Returns:

Type Description
Output[str]

pulumi.Output[str] resolving to the first block volume OCID.

Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
610
611
612
613
614
615
616
617
618
619
def get_block_volume_id(self) -> pulumi.Output[str]:
    """Return the OCID of the first (primary) block volume.

    Provided for backward compatibility.  For multi-volume setups use
    `get_volume_id` or `get_all_volume_ids`.

    Returns:
        `pulumi.Output[str]` resolving to the first block volume OCID.
    """
    return self.block_volumes[0].id

get_volume_id(label: str) -> pulumi.Output[str]

Return the OCID of the block volume with the given label.

Parameters:

Name Type Description Default
label str

The label value of the target VolumeSpec.

required

Returns:

Type Description
Output[str]

pulumi.Output[str] resolving to the volume OCID.

Raises:

Type Description
KeyError

If no volume with the given label exists.

Example
db_vol_id = instance.get_volume_id("db")
Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
def get_volume_id(self, label: str) -> pulumi.Output[str]:
    """Return the OCID of the block volume with the given label.

    Args:
        label: The `label` value of the target `VolumeSpec`.

    Returns:
        `pulumi.Output[str]` resolving to the volume OCID.

    Raises:
        KeyError: If no volume with the given label exists.

    Example:
        ```python
        db_vol_id = instance.get_volume_id("db")
        ```
    """
    for spec, vol in zip(self.volumes_spec, self.block_volumes):
        if spec.label == label:
            return vol.id
    raise KeyError(f"No volume with label {label!r}. Available labels: {[s.label for s in self.volumes_spec]}")

get_disk_id(label: str) -> pulumi.Output[str]

Return the OCID of the block volume with the given label.

Satisfies AbstractCompute.get_disk_id. Delegates to get_volume_id.

Parameters:

Name Type Description Default
label str

The label value of the target VolumeSpec.

required

Returns:

Type Description
Output[str]

pulumi.Output[str] resolving to the volume OCID.

Raises:

Type Description
KeyError

If no volume with the given label exists.

Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
def get_disk_id(self, label: str) -> pulumi.Output[str]:
    """Return the OCID of the block volume with the given label.

    Satisfies `AbstractCompute.get_disk_id`.  Delegates to
    `get_volume_id`.

    Args:
        label: The `label` value of the target `VolumeSpec`.

    Returns:
        `pulumi.Output[str]` resolving to the volume OCID.

    Raises:
        KeyError: If no volume with the given label exists.
    """
    return self.get_volume_id(label)

get_volume(label: str) -> oci.core.Volume

Return the oci.core.Volume resource with the given label.

Parameters:

Name Type Description Default
label str

The label value of the target VolumeSpec.

required

Returns:

Type Description
Volume

The oci.core.Volume resource.

Raises:

Type Description
KeyError

If no volume with the given label exists.

Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
def get_volume(self, label: str) -> oci.core.Volume:
    """Return the `oci.core.Volume` resource with the given label.

    Args:
        label: The `label` value of the target `VolumeSpec`.

    Returns:
        The `oci.core.Volume` resource.

    Raises:
        KeyError: If no volume with the given label exists.
    """
    for spec, vol in zip(self.volumes_spec, self.block_volumes):
        if spec.label == label:
            return vol
    raise KeyError(f"No volume with label {label!r}. Available labels: {[s.label for s in self.volumes_spec]}")

get_volume_attachment(label: str) -> oci.core.VolumeAttachment

Return the oci.core.VolumeAttachment resource with the given label.

Parameters:

Name Type Description Default
label str

The label value of the target VolumeSpec.

required

Returns:

Type Description
VolumeAttachment

The oci.core.VolumeAttachment resource.

Raises:

Type Description
KeyError

If no volume with the given label exists.

Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
def get_volume_attachment(self, label: str) -> oci.core.VolumeAttachment:
    """Return the `oci.core.VolumeAttachment` resource with the given label.

    Args:
        label: The `label` value of the target `VolumeSpec`.

    Returns:
        The `oci.core.VolumeAttachment` resource.

    Raises:
        KeyError: If no volume with the given label exists.
    """
    for spec, att in zip(self.volumes_spec, self.volume_attachments):
        if spec.label == label:
            return att
    raise KeyError(
        f"No volume attachment with label {label!r}. Available labels: {[s.label for s in self.volumes_spec]}"
    )

get_all_volume_ids() -> list[pulumi.Output[str]]

Return a list of OCIDs for all attached block volumes.

The list order matches the order of the volumes parameter passed at construction time.

Returns:

Type Description
list[Output[str]]

List of pulumi.Output[str] resolving to each volume OCID.

Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
696
697
698
699
700
701
702
703
704
705
def get_all_volume_ids(self) -> list[pulumi.Output[str]]:
    """Return a list of OCIDs for all attached block volumes.

    The list order matches the order of the `volumes` parameter passed
    at construction time.

    Returns:
        List of `pulumi.Output[str]` resolving to each volume OCID.
    """
    return [vol.id for vol in self.block_volumes]

get_ssh_public_key() -> str

Return the SSH public key installed on the instance.

Returns:

Type Description
str

OpenSSH public key string (auto-generated or caller-supplied).

Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
707
708
709
710
711
712
713
def get_ssh_public_key(self) -> str:
    """Return the SSH public key installed on the instance.

    Returns:
        OpenSSH public key string (auto-generated or caller-supplied).
    """
    return self.ssh_public_key

get_ssh_private_key() -> str | None

Return the SSH private key if it was auto-generated.

Returns:

Type Description
str | None

PEM-encoded private key string when keys were auto-generated,

str | None

or None when the caller supplied their own public key.

Source code in packages/cloudspells-oci/src/cloudspells/providers/oci/compute.py
715
716
717
718
719
720
721
722
def get_ssh_private_key(self) -> str | None:
    """Return the SSH private key if it was auto-generated.

    Returns:
        PEM-encoded private key string when keys were auto-generated,
        or `None` when the caller supplied their own public key.
    """
    return self.ssh_private_key