misc.python.materialize.scratch

Utilities for launching and interacting with scratch EC2 instances.

  1# Copyright Materialize, Inc. and contributors. All rights reserved.
  2#
  3# Use of this software is governed by the Business Source License
  4# included in the LICENSE file at the root of this repository.
  5#
  6# As of the Change Date specified in that file, in accordance with
  7# the Business Source License, use of this software will be governed
  8# by the Apache License, Version 2.0.
  9
 10"""Utilities for launching and interacting with scratch EC2 instances."""
 11
 12import asyncio
 13import csv
 14import datetime
 15import os
 16import re
 17import shlex
 18import subprocess
 19import sys
 20from subprocess import CalledProcessError
 21from typing import NamedTuple, cast
 22
 23import boto3
 24from botocore.exceptions import ClientError
 25from mypy_boto3_ec2.literals import InstanceTypeType
 26from mypy_boto3_ec2.service_resource import Instance
 27from mypy_boto3_ec2.type_defs import (
 28    FilterTypeDef,
 29    InstanceNetworkInterfaceSpecificationTypeDef,
 30    InstanceTypeDef,
 31    RunInstancesRequestServiceResourceCreateInstancesTypeDef,
 32)
 33from pydantic import BaseModel
 34
 35from materialize import MZ_ROOT, git, spawn, ui, util
 36
 37# Sane defaults for internal Materialize use in the scratch account
 38DEFAULT_SECURITY_GROUP_NAME = "scratch-security-group"
 39DEFAULT_INSTANCE_PROFILE_NAME = "admin-instance"
 40
 41# Ubuntu 26.04 LTS (Resolute Raccoon) base images, keyed by the CPU
 42# architecture that EC2 reports for an instance type.
 43#
 44# To update: open https://cloud-images.ubuntu.com/locator/ec2/, filter to the
 45# desired release, zone `us-east-1`, and instance type `hvm:ebs-ssd-gp3`. Copy
 46# the AMI id for each architecture (amd64 -> x86_64, arm64) below. Bumping the
 47# release also means updating the comment above and the `ami_user` default if
 48# Canonical changes it.
 49AMIS_BY_ARCH: dict[str, str] = {
 50    "x86_64": "ami-0b6d9d3d33ba97d99",
 51    "arm64": "ami-0bc7f2dbdcc6b5303",
 52}
 53
 54SSH_COMMAND = ["mssh", "-o", "StrictHostKeyChecking=off"]
 55SFTP_COMMAND = ["msftp", "-o", "StrictHostKeyChecking=off"]
 56
 57say = ui.speaker("scratch> ")
 58
 59
 60def instance_type_arch(instance_type: str) -> str:
 61    """Return the CPU architecture for an EC2 instance type as a key into
 62    `AMIS_BY_ARCH`. Queries EC2, which also validates that the type exists."""
 63    infos = boto3.client("ec2").describe_instance_types(
 64        InstanceTypes=[cast(InstanceTypeType, instance_type)]
 65    )["InstanceTypes"]
 66    if not infos:
 67        raise RuntimeError(f"unknown EC2 instance type: {instance_type}")
 68    archs = infos[0]["ProcessorInfo"]["SupportedArchitectures"]
 69    for arch in archs:
 70        if arch in AMIS_BY_ARCH:
 71            return arch
 72    raise RuntimeError(
 73        f"no scratch AMI for instance type {instance_type} "
 74        f"(architectures: {', '.join(archs)})"
 75    )
 76
 77
 78def resolve_ami(desc: "MachineDesc") -> str:
 79    """Resolve the AMI for a machine, deriving it from the instance type's
 80    architecture unless the config pins one explicitly."""
 81    if desc.ami:
 82        return desc.ami
 83    return AMIS_BY_ARCH[instance_type_arch(desc.instance_type)]
 84
 85
 86def tags(i: Instance) -> dict[str, str]:
 87    if not i.tags:
 88        return {}
 89    return {t["Key"]: t["Value"] for t in i.tags}
 90
 91
 92def instance_typedef_tags(i: InstanceTypeDef) -> dict[str, str]:
 93    return {t["Key"]: t["Value"] for t in i.get("Tags", [])}
 94
 95
 96def name(tags: dict[str, str]) -> str | None:
 97    return tags.get("Name")
 98
 99
100def launched_by(tags: dict[str, str]) -> str | None:
101    return tags.get("LaunchedBy")
102
103
104def ami_user(tags: dict[str, str]) -> str | None:
105    return tags.get("ami-user", "ubuntu")
106
107
108def delete_after(tags: dict[str, str]) -> datetime.datetime | None:
109    unix = tags.get("scratch-delete-after")
110    if not unix:
111        return None
112    unix = int(float(unix))
113    return datetime.datetime.fromtimestamp(unix)
114
115
116def instance_host(instance: Instance, user: str | None = None) -> str:
117    if user is None:
118        user = ami_user(tags(instance))
119    return f"{user}@{instance.id}"
120
121
122# Launch tags the instance Name as "{nonce}-{config name}"; the nonce is
123# redundant with the instance id in a flat listing, so strip it for display.
124_NONCE_PREFIX = re.compile(r"^[0-9a-f]{8}-")
125
126
127def _short_name(n: str | None) -> str:
128    if not n:
129        return "-"
130    return _NONCE_PREFIX.sub("", n, count=1)
131
132
133def render_table(headers: list[str], rows: list[list[str]]) -> None:
134    """Print a borderless, left-aligned table, like `docker ps` / `kubectl get`:
135    columns separated by two spaces, an uppercase header, no rules."""
136    widths = [max(len(r[c]) for r in [headers, *rows]) for c in range(len(headers))]
137    for r in [headers, *rows]:
138        print("  ".join(cell.ljust(w) for cell, w in zip(r, widths)).rstrip())
139
140
141def _format_expires(dt: datetime.datetime | None) -> str:
142    """Render a delete-after time as a compact relative duration."""
143    if dt is None:
144        return "-"
145    secs = (dt - datetime.datetime.now()).total_seconds()
146    if secs <= 0:
147        return "expired"
148    if secs >= 86400:
149        return f"{secs / 86400:.1f}d"
150    if secs >= 3600:
151        return f"{secs / 3600:.0f}h"
152    return f"{secs / 60:.0f}m"
153
154
155def print_instances(
156    ists: list[Instance],
157    format: str = "table",
158    numbered: bool = False,
159    show_launched_by: bool = False,
160) -> None:
161    if format == "csv":
162        # CSV is machine-readable: emit the full set of columns, unabridged.
163        field_names = [
164            "Name",
165            "Instance ID",
166            "Public IP Address",
167            "Private IP Address",
168            "Launched By",
169            "Delete After",
170            "State",
171        ]
172        if numbered:
173            field_names = ["#"] + field_names
174        w = csv.writer(sys.stdout)
175        w.writerow(field_names)
176        for idx, i in enumerate(ists, 1):
177            t = tags(i)
178            row = [
179                name(t),
180                i.instance_id,
181                i.public_ip_address,
182                i.private_ip_address,
183                launched_by(t),
184                delete_after(t),
185                i.state["Name"],
186            ]
187            if numbered:
188                row = [idx] + row
189            w.writerow(row)
190        return
191
192    if format != "table":
193        raise RuntimeError("Unknown format passed to print_instances")
194
195    # Table is for humans: a compact subset that fits a default terminal.
196    # Private IP is omitted (mssh connects by instance id, not IP) and the
197    # owner only shown when listing instances beyond your own.
198    headers = ["INSTANCE ID", "NAME", "STATE", "EXPIRES", "PUBLIC IP"]
199    if show_launched_by:
200        headers.append("LAUNCHED BY")
201    if numbered:
202        headers = ["#"] + headers
203    rows = []
204    for idx, i in enumerate(ists, 1):
205        t = tags(i)
206        state = i.state["Name"]
207        # A dead instance has no meaningful expiry; don't show a future time.
208        expires = (
209            "-"
210            if state in ("terminated", "shutting-down")
211            else _format_expires(delete_after(t))
212        )
213        row = [
214            i.instance_id,
215            _short_name(name(t)),
216            state,
217            expires,
218            i.public_ip_address or "-",
219        ]
220        if show_launched_by:
221            row.append(launched_by(t) or "-")
222        if numbered:
223            row = [str(idx)] + row
224        rows.append(row)
225
226    render_table(headers, rows)
227
228
229def mssh(
230    instance: Instance,
231    command: str,
232    *,
233    extra_ssh_args: list[str] | None = None,
234    input: bytes | None = None,
235    quiet: bool = False,
236) -> None:
237    """Runs a command over SSH via EC2 Instance Connect."""
238    extra_ssh_args = extra_ssh_args or []
239    host = instance_host(instance)
240    if command:
241        if not quiet:
242            print(f"{host}$ {command}", file=sys.stderr)
243        # Quote to work around:
244        # https://github.com/aws/aws-ec2-instance-connect-cli/pull/26
245        command = shlex.quote(command)
246    else:
247        print(f"$ mssh {host}")
248
249    result = subprocess.run(
250        [
251            *SSH_COMMAND,
252            *extra_ssh_args,
253            host,
254            command,
255        ],
256        input=input,
257        stdout=subprocess.DEVNULL if quiet else None,
258        stderr=subprocess.DEVNULL if quiet else None,
259    )
260    # Exit code 130 = SIGINT (Ctrl-C) — exit cleanly
261    if result.returncode == 130:
262        sys.exit(130)
263    if result.returncode != 0:
264        raise subprocess.CalledProcessError(result.returncode, result.args)
265
266
267def msftp(
268    instance: Instance,
269) -> None:
270    """Connects over SFTP via EC2 Instance Connect."""
271    host = instance_host(instance)
272    spawn.runv([*SFTP_COMMAND, host])
273
274
275def setup_ai_tools(instance: Instance) -> None:
276    """Transfer local Claude Code and Codex configs to a scratch instance
277    and install the materialize-docs skill."""
278    import io
279    import pathlib
280    import tarfile
281
282    home = pathlib.Path.home()
283
284    # Collect only essential config files — skip session logs, caches, etc.
285    items: list[tuple[str, pathlib.Path]] = []
286
287    # Claude Code: settings, credentials, and project memory files
288    claude_dir = home / ".claude"
289    if claude_dir.is_dir():
290        for cfg_name in ("settings.json", ".credentials.json"):
291            p = claude_dir / cfg_name
292            if p.is_file():
293                items.append((f".claude/{cfg_name}", p))
294        # Transfer memory directories from all projects
295        projects_dir = claude_dir / "projects"
296        if projects_dir.is_dir():
297            for proj in projects_dir.iterdir():
298                if not proj.is_dir():
299                    continue
300                memory_dir = proj / "memory"
301                if memory_dir.is_dir():
302                    items.append((f".claude/projects/{proj.name}/memory", memory_dir))
303
304    if (home / ".claude.json").is_file():
305        items.append((".claude.json", home / ".claude.json"))
306
307    # Codex: only config, not session logs
308    codex_dir = home / ".codex"
309    if codex_dir.is_dir():
310        for cfg_name in ("config.json", "instructions.md"):
311            p = codex_dir / cfg_name
312            if p.is_file():
313                items.append((f".codex/{cfg_name}", p))
314
315    if items:
316        buf = io.BytesIO()
317        with tarfile.open(fileobj=buf, mode="w:gz") as tar:
318            for arcname, path in items:
319                tar.add(str(path), arcname=arcname)
320        mssh(instance, "tar xzf - -C ~", input=buf.getvalue(), quiet=True)
321
322    mssh(
323        instance,
324        "cd materialize && "
325        "NPM_CONFIG_UPDATE_NOTIFIER=false "
326        "npx -q -y skills add MaterializeInc/agent-skills -g -a claude-code --copy -y --skill materialize-docs 2>/dev/null || true",
327        quiet=True,
328    )
329
330    mssh(
331        instance,
332        "cd materialize && "
333        "NPM_CONFIG_UPDATE_NOTIFIER=false "
334        "npx -q -y skills add MaterializeInc/agent-skills -g -a codex --copy -y --skill materialize-docs 2>/dev/null || true",
335        quiet=True,
336    )
337
338
339def mkrepo(
340    instance: Instance, rev: str, init: bool = True, force: bool = False
341) -> None:
342    if init:
343        mssh(instance, "git clone https://github.com/MaterializeInc/materialize.git")
344
345    rev = git.rev_parse(rev)
346
347    cmd: list[str] = [
348        "git",
349        "push",
350        "--no-verify",
351        f"{instance_host(instance)}:materialize/.git",
352        # Explicit refspec is required if the host repository is in detached
353        # HEAD mode.
354        f"{rev}:refs/heads/scratch",
355    ]
356    if force:
357        cmd.append("--force")
358
359    spawn.runv(
360        cmd,
361        cwd=MZ_ROOT,
362        env=dict(os.environ, GIT_SSH_COMMAND=" ".join(SSH_COMMAND)),
363    )
364    git_config_cmds = f"git checkout -f {rev}"
365
366    # Propagate local git user.name and user.email to the scratch instance.
367    if git_name := git.get_user_name():
368        git_config_cmds += f" && git config user.name {shlex.quote(git_name)}"
369    if git_email := git.get_user_email():
370        git_config_cmds += f" && git config user.email {shlex.quote(git_email)}"
371
372    mssh(
373        instance,
374        f"cd materialize && {git_config_cmds}",
375    )
376
377
378class MachineDesc(BaseModel):
379    name: str
380    launch_script: str | None = None
381    instance_type: str
382    # Optional: when unset, the AMI is derived from the instance type's
383    # architecture via `resolve_ami`.
384    ami: str | None = None
385    tags: dict[str, str] = {}
386    size_gb: int = 50
387    checkout: bool = True
388    ami_user: str = "ubuntu"
389
390
391def launch(
392    *,
393    key_name: str | None,
394    instance_type: str,
395    ami: str,
396    ami_user: str,
397    tags: dict[str, str],
398    display_name: str | None = None,
399    size_gb: int,
400    security_group_name: str,
401    instance_profile: str | None,
402    nonce: str,
403    delete_after: datetime.datetime,
404) -> Instance:
405    """Launch and configure an ec2 instance with the given properties."""
406
407    if display_name:
408        tags["Name"] = display_name
409    tags["scratch-delete-after"] = str(delete_after.timestamp())
410    tags["nonce"] = nonce
411    tags["git_ref"] = git.describe()
412    tags["ami-user"] = ami_user
413
414    ec2 = boto3.client("ec2")
415    groups = ec2.describe_security_groups()
416    security_group_id = None
417    for group in groups["SecurityGroups"]:
418        if group["GroupName"] == security_group_name:
419            security_group_id = group["GroupId"]
420            break
421
422    if security_group_id is None:
423        vpcs = ec2.describe_vpcs()
424        vpc_id = None
425        for vpc in vpcs["Vpcs"]:
426            if vpc["IsDefault"]:
427                vpc_id = vpc["VpcId"]
428                break
429        if vpc_id is None:
430            default_vpc = ec2.create_default_vpc()
431            vpc_id = default_vpc["Vpc"]["VpcId"]
432        securitygroup = ec2.create_security_group(
433            GroupName=security_group_name,
434            Description="Allows all.",
435            VpcId=vpc_id,
436        )
437        security_group_id = securitygroup["GroupId"]
438        ec2.authorize_security_group_ingress(
439            GroupId=security_group_id,
440            CidrIp="0.0.0.0/0",
441            IpProtocol="tcp",
442            FromPort=22,
443            ToPort=22,
444        )
445
446    network_interface: InstanceNetworkInterfaceSpecificationTypeDef = {
447        "AssociatePublicIpAddress": True,
448        "DeviceIndex": 0,
449        "Groups": [security_group_id],
450    }
451
452    say(f"launching instance {display_name or '(unnamed)'}")
453    with open(MZ_ROOT / "misc" / "scratch" / "provision.bash") as f:
454        provisioning_script = f.read()
455    kwargs: RunInstancesRequestServiceResourceCreateInstancesTypeDef = {
456        "MinCount": 1,
457        "MaxCount": 1,
458        "ImageId": ami,
459        "InstanceType": cast(InstanceTypeType, instance_type),
460        "UserData": provisioning_script,
461        "TagSpecifications": [
462            {
463                "ResourceType": "instance",
464                "Tags": [{"Key": k, "Value": v} for (k, v) in tags.items()],
465            }
466        ],
467        "NetworkInterfaces": [network_interface],
468        "BlockDeviceMappings": [
469            {
470                "DeviceName": "/dev/sda1",
471                "Ebs": {
472                    "VolumeSize": size_gb,
473                    "VolumeType": "gp3",
474                },
475            }
476        ],
477        "MetadataOptions": {
478            # Allow Docker containers to access IMDSv2.
479            "HttpPutResponseHopLimit": 2,
480        },
481    }
482    if key_name:
483        kwargs["KeyName"] = key_name
484    if instance_profile:
485        kwargs["IamInstanceProfile"] = {"Name": instance_profile}
486    i = boto3.resource("ec2").create_instances(**kwargs)[0]
487
488    return i
489
490
491class CommandResult(NamedTuple):
492    status: str
493    stdout: str
494    stderr: str
495
496
497async def setup(
498    i: Instance,
499    git_rev: str,
500) -> None:
501    def is_ready(i: Instance) -> bool:
502        return bool(
503            i.public_ip_address and i.state and i.state.get("Name") == "running"
504        )
505
506    done = False
507    async for remaining in ui.async_timeout_loop(60, 2):
508        print(
509            f"\rscratch> Waiting for instance to become ready: {remaining:0.0f}s remaining\033[K",
510            end="",
511            flush=True,
512            file=sys.stderr,
513        )
514        try:
515            i.reload()
516            if is_ready(i):
517                done = True
518                break
519        except ClientError:
520            pass
521    print(file=sys.stderr)
522    if not done:
523        raise RuntimeError(
524            f"Instance {i} did not become ready in a reasonable amount of time"
525        )
526
527    # Wait for SSH to be available
528    done = False
529    async for remaining in ui.async_timeout_loop(120, 2):
530        print(
531            f"\rscratch> Waiting for SSH access: {remaining:0.0f}s remaining\033[K",
532            end="",
533            flush=True,
534            file=sys.stderr,
535        )
536        try:
537            mssh(i, "true", quiet=True)
538            done = True
539            break
540        except CalledProcessError:
541            continue
542    print(file=sys.stderr)
543    if not done:
544        raise RuntimeError(
545            "SSH did not become available in a reasonable amount of time"
546        )
547
548    # Start git clone while provisioning continues
549    say("Cloning repo (provisioning continues in background)...")
550    mkrepo(i, git_rev)
551
552    # Wait for provisioning to finish
553    done = False
554    async for remaining in ui.async_timeout_loop(300, 2):
555        print(
556            f"\rscratch> Waiting for provisioning to complete: {remaining:0.0f}s remaining\033[K",
557            end="",
558            flush=True,
559            file=sys.stderr,
560        )
561        try:
562            mssh(i, "[[ -f /opt/provision/done ]]", quiet=True)
563            done = True
564            break
565        except CalledProcessError:
566            continue
567    print(file=sys.stderr)
568    if not done:
569        raise RuntimeError(
570            "Instance did not finish setup in a reasonable amount of time"
571        )
572
573
574def launch_cluster(
575    descs: list[MachineDesc],
576    *,
577    nonce: str | None = None,
578    key_name: str | None = None,
579    security_group_name: str = DEFAULT_SECURITY_GROUP_NAME,
580    instance_profile: str | None = DEFAULT_INSTANCE_PROFILE_NAME,
581    extra_tags: dict[str, str] | None = None,
582    delete_after: datetime.datetime,
583    git_rev: str = "HEAD",
584    extra_env: dict[str, str] | None = None,
585) -> list[Instance]:
586    """Launch a cluster of instances with a given nonce"""
587
588    extra_tags = extra_tags or {}
589    extra_env = extra_env or {}
590
591    if not nonce:
592        nonce = util.nonce(8)
593
594    instances = [
595        launch(
596            key_name=key_name,
597            instance_type=d.instance_type,
598            ami=resolve_ami(d),
599            ami_user=d.ami_user,
600            tags={**d.tags, **extra_tags},
601            display_name=f"{nonce}-{d.name}",
602            size_gb=d.size_gb,
603            security_group_name=security_group_name,
604            instance_profile=instance_profile,
605            nonce=nonce,
606            delete_after=delete_after,
607        )
608        for d in descs
609    ]
610
611    async def setup_all() -> None:
612        await asyncio.gather(
613            *(
614                setup(i, git_rev if d.checkout else "HEAD")
615                for (i, d) in zip(instances, descs)
616            )
617        )
618
619    asyncio.run(setup_all())
620
621    for i in instances:
622        i.reload()
623
624    hosts_str = "".join(
625        f"{i.private_ip_address}\t{d.name}\n" for (i, d) in zip(instances, descs)
626    )
627    for i in instances:
628        mssh(i, "sudo tee -a /etc/hosts", input=hosts_str.encode())
629
630    for i in instances:
631        setup_ai_tools(i)
632
633    env = " ".join(f"{k}={shlex.quote(v)}" for k, v in extra_env.items())
634    for i, d in zip(instances, descs):
635        if d.launch_script:
636            mssh(
637                i,
638                f"(cd materialize && {env} nohup bash -c {shlex.quote(d.launch_script)}) &> mzscratch.log &",
639            )
640
641    return instances
642
643
644def whoami() -> str:
645    return boto3.client("sts").get_caller_identity()["UserId"].split(":")[1]
646
647
648def get_instance(name: str) -> Instance:
649    """
650    Get an instance by instance id. The special name 'mine' resolves to a
651    unique running owned instance, if there is one; otherwise the name is
652    assumed to be an instance id.
653    """
654    if name == "mine":
655        filters: list[FilterTypeDef] = [
656            {"Name": "tag:LaunchedBy", "Values": [whoami()]},
657            {"Name": "instance-state-name", "Values": ["pending", "running"]},
658        ]
659        instances = [i for i in boto3.resource("ec2").instances.filter(Filters=filters)]
660        if not instances:
661            raise RuntimeError("can't understand 'mine': no owned instance?")
662        if len(instances) > 1:
663            raise RuntimeError(
664                f"can't understand 'mine': too many owned instances ({', '.join(i.id for i in instances)})"
665            )
666        instance = instances[0]
667        say(f"understanding 'mine' as unique owned instance {instance.id}")
668        return instance
669    return boto3.resource("ec2").Instance(name)
670
671
672def list_instances(
673    owners: list[str] | None = None, all: bool = False
674) -> list[Instance]:
675    """List AWS instances, optionally filtered by owner."""
676    filters: list[FilterTypeDef] = []
677    if not all:
678        if not owners:
679            owners = [whoami()]
680        filters.append({"Name": "tag:LaunchedBy", "Values": owners})
681    return list(boto3.resource("ec2").instances.filter(Filters=filters))
682
683
684def get_old_instances() -> list[InstanceTypeDef]:
685    def exists(i: InstanceTypeDef) -> bool:
686        return i["State"]["Name"] != "terminated"
687
688    def is_old(i: InstanceTypeDef) -> bool:
689        delete_after = instance_typedef_tags(i).get("scratch-delete-after")
690        if delete_after is None:
691            return False
692        delete_after = float(delete_after)
693        return datetime.datetime.now(datetime.timezone.utc).timestamp() > delete_after
694
695    return [
696        i
697        for r in boto3.client("ec2").describe_instances()["Reservations"]
698        for i in r["Instances"]
699        if exists(i) and is_old(i)
700    ]
DEFAULT_SECURITY_GROUP_NAME = 'scratch-security-group'
DEFAULT_INSTANCE_PROFILE_NAME = 'admin-instance'
AMIS_BY_ARCH: dict[str, str] = {'x86_64': 'ami-0b6d9d3d33ba97d99', 'arm64': 'ami-0bc7f2dbdcc6b5303'}
SSH_COMMAND = ['mssh', '-o', 'StrictHostKeyChecking=off']
SFTP_COMMAND = ['msftp', '-o', 'StrictHostKeyChecking=off']
def say(msg: str) -> None:
55    def say(msg: str) -> None:
56        if not Verbosity.quiet:
57            print(f"{prefix}{msg}", file=sys.stderr)
def instance_type_arch(instance_type: str) -> str:
61def instance_type_arch(instance_type: str) -> str:
62    """Return the CPU architecture for an EC2 instance type as a key into
63    `AMIS_BY_ARCH`. Queries EC2, which also validates that the type exists."""
64    infos = boto3.client("ec2").describe_instance_types(
65        InstanceTypes=[cast(InstanceTypeType, instance_type)]
66    )["InstanceTypes"]
67    if not infos:
68        raise RuntimeError(f"unknown EC2 instance type: {instance_type}")
69    archs = infos[0]["ProcessorInfo"]["SupportedArchitectures"]
70    for arch in archs:
71        if arch in AMIS_BY_ARCH:
72            return arch
73    raise RuntimeError(
74        f"no scratch AMI for instance type {instance_type} "
75        f"(architectures: {', '.join(archs)})"
76    )

Return the CPU architecture for an EC2 instance type as a key into AMIS_BY_ARCH. Queries EC2, which also validates that the type exists.

def resolve_ami(desc: MachineDesc) -> str:
79def resolve_ami(desc: "MachineDesc") -> str:
80    """Resolve the AMI for a machine, deriving it from the instance type's
81    architecture unless the config pins one explicitly."""
82    if desc.ami:
83        return desc.ami
84    return AMIS_BY_ARCH[instance_type_arch(desc.instance_type)]

Resolve the AMI for a machine, deriving it from the instance type's architecture unless the config pins one explicitly.

def tags(i: mypy_boto3_ec2.service_resource.Instance) -> dict[str, str]:
87def tags(i: Instance) -> dict[str, str]:
88    if not i.tags:
89        return {}
90    return {t["Key"]: t["Value"] for t in i.tags}
def instance_typedef_tags(i: mypy_boto3_ec2.type_defs.InstanceTypeDef) -> dict[str, str]:
93def instance_typedef_tags(i: InstanceTypeDef) -> dict[str, str]:
94    return {t["Key"]: t["Value"] for t in i.get("Tags", [])}
def name(tags: dict[str, str]) -> str | None:
97def name(tags: dict[str, str]) -> str | None:
98    return tags.get("Name")
def launched_by(tags: dict[str, str]) -> str | None:
101def launched_by(tags: dict[str, str]) -> str | None:
102    return tags.get("LaunchedBy")
def ami_user(tags: dict[str, str]) -> str | None:
105def ami_user(tags: dict[str, str]) -> str | None:
106    return tags.get("ami-user", "ubuntu")
def delete_after(tags: dict[str, str]) -> datetime.datetime | None:
109def delete_after(tags: dict[str, str]) -> datetime.datetime | None:
110    unix = tags.get("scratch-delete-after")
111    if not unix:
112        return None
113    unix = int(float(unix))
114    return datetime.datetime.fromtimestamp(unix)
def instance_host( instance: mypy_boto3_ec2.service_resource.Instance, user: str | None = None) -> str:
117def instance_host(instance: Instance, user: str | None = None) -> str:
118    if user is None:
119        user = ami_user(tags(instance))
120    return f"{user}@{instance.id}"
def render_table(headers: list[str], rows: list[list[str]]) -> None:
134def render_table(headers: list[str], rows: list[list[str]]) -> None:
135    """Print a borderless, left-aligned table, like `docker ps` / `kubectl get`:
136    columns separated by two spaces, an uppercase header, no rules."""
137    widths = [max(len(r[c]) for r in [headers, *rows]) for c in range(len(headers))]
138    for r in [headers, *rows]:
139        print("  ".join(cell.ljust(w) for cell, w in zip(r, widths)).rstrip())

Print a borderless, left-aligned table, like docker ps / kubectl get: columns separated by two spaces, an uppercase header, no rules.

def mssh( instance: mypy_boto3_ec2.service_resource.Instance, command: str, *, extra_ssh_args: list[str] | None = None, input: bytes | None = None, quiet: bool = False) -> None:
230def mssh(
231    instance: Instance,
232    command: str,
233    *,
234    extra_ssh_args: list[str] | None = None,
235    input: bytes | None = None,
236    quiet: bool = False,
237) -> None:
238    """Runs a command over SSH via EC2 Instance Connect."""
239    extra_ssh_args = extra_ssh_args or []
240    host = instance_host(instance)
241    if command:
242        if not quiet:
243            print(f"{host}$ {command}", file=sys.stderr)
244        # Quote to work around:
245        # https://github.com/aws/aws-ec2-instance-connect-cli/pull/26
246        command = shlex.quote(command)
247    else:
248        print(f"$ mssh {host}")
249
250    result = subprocess.run(
251        [
252            *SSH_COMMAND,
253            *extra_ssh_args,
254            host,
255            command,
256        ],
257        input=input,
258        stdout=subprocess.DEVNULL if quiet else None,
259        stderr=subprocess.DEVNULL if quiet else None,
260    )
261    # Exit code 130 = SIGINT (Ctrl-C) — exit cleanly
262    if result.returncode == 130:
263        sys.exit(130)
264    if result.returncode != 0:
265        raise subprocess.CalledProcessError(result.returncode, result.args)

Runs a command over SSH via EC2 Instance Connect.

def msftp(instance: mypy_boto3_ec2.service_resource.Instance) -> None:
268def msftp(
269    instance: Instance,
270) -> None:
271    """Connects over SFTP via EC2 Instance Connect."""
272    host = instance_host(instance)
273    spawn.runv([*SFTP_COMMAND, host])

Connects over SFTP via EC2 Instance Connect.

def setup_ai_tools(instance: mypy_boto3_ec2.service_resource.Instance) -> None:
276def setup_ai_tools(instance: Instance) -> None:
277    """Transfer local Claude Code and Codex configs to a scratch instance
278    and install the materialize-docs skill."""
279    import io
280    import pathlib
281    import tarfile
282
283    home = pathlib.Path.home()
284
285    # Collect only essential config files — skip session logs, caches, etc.
286    items: list[tuple[str, pathlib.Path]] = []
287
288    # Claude Code: settings, credentials, and project memory files
289    claude_dir = home / ".claude"
290    if claude_dir.is_dir():
291        for cfg_name in ("settings.json", ".credentials.json"):
292            p = claude_dir / cfg_name
293            if p.is_file():
294                items.append((f".claude/{cfg_name}", p))
295        # Transfer memory directories from all projects
296        projects_dir = claude_dir / "projects"
297        if projects_dir.is_dir():
298            for proj in projects_dir.iterdir():
299                if not proj.is_dir():
300                    continue
301                memory_dir = proj / "memory"
302                if memory_dir.is_dir():
303                    items.append((f".claude/projects/{proj.name}/memory", memory_dir))
304
305    if (home / ".claude.json").is_file():
306        items.append((".claude.json", home / ".claude.json"))
307
308    # Codex: only config, not session logs
309    codex_dir = home / ".codex"
310    if codex_dir.is_dir():
311        for cfg_name in ("config.json", "instructions.md"):
312            p = codex_dir / cfg_name
313            if p.is_file():
314                items.append((f".codex/{cfg_name}", p))
315
316    if items:
317        buf = io.BytesIO()
318        with tarfile.open(fileobj=buf, mode="w:gz") as tar:
319            for arcname, path in items:
320                tar.add(str(path), arcname=arcname)
321        mssh(instance, "tar xzf - -C ~", input=buf.getvalue(), quiet=True)
322
323    mssh(
324        instance,
325        "cd materialize && "
326        "NPM_CONFIG_UPDATE_NOTIFIER=false "
327        "npx -q -y skills add MaterializeInc/agent-skills -g -a claude-code --copy -y --skill materialize-docs 2>/dev/null || true",
328        quiet=True,
329    )
330
331    mssh(
332        instance,
333        "cd materialize && "
334        "NPM_CONFIG_UPDATE_NOTIFIER=false "
335        "npx -q -y skills add MaterializeInc/agent-skills -g -a codex --copy -y --skill materialize-docs 2>/dev/null || true",
336        quiet=True,
337    )

Transfer local Claude Code and Codex configs to a scratch instance and install the materialize-docs skill.

def mkrepo( instance: mypy_boto3_ec2.service_resource.Instance, rev: str, init: bool = True, force: bool = False) -> None:
340def mkrepo(
341    instance: Instance, rev: str, init: bool = True, force: bool = False
342) -> None:
343    if init:
344        mssh(instance, "git clone https://github.com/MaterializeInc/materialize.git")
345
346    rev = git.rev_parse(rev)
347
348    cmd: list[str] = [
349        "git",
350        "push",
351        "--no-verify",
352        f"{instance_host(instance)}:materialize/.git",
353        # Explicit refspec is required if the host repository is in detached
354        # HEAD mode.
355        f"{rev}:refs/heads/scratch",
356    ]
357    if force:
358        cmd.append("--force")
359
360    spawn.runv(
361        cmd,
362        cwd=MZ_ROOT,
363        env=dict(os.environ, GIT_SSH_COMMAND=" ".join(SSH_COMMAND)),
364    )
365    git_config_cmds = f"git checkout -f {rev}"
366
367    # Propagate local git user.name and user.email to the scratch instance.
368    if git_name := git.get_user_name():
369        git_config_cmds += f" && git config user.name {shlex.quote(git_name)}"
370    if git_email := git.get_user_email():
371        git_config_cmds += f" && git config user.email {shlex.quote(git_email)}"
372
373    mssh(
374        instance,
375        f"cd materialize && {git_config_cmds}",
376    )
class MachineDesc(pydantic.main.BaseModel):
379class MachineDesc(BaseModel):
380    name: str
381    launch_script: str | None = None
382    instance_type: str
383    # Optional: when unset, the AMI is derived from the instance type's
384    # architecture via `resolve_ami`.
385    ami: str | None = None
386    tags: dict[str, str] = {}
387    size_gb: int = 50
388    checkout: bool = True
389    ami_user: str = "ubuntu"

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to
    __args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
name: str
launch_script: str | None
instance_type: str
ami: str | None
tags: dict[str, str]
size_gb: int
checkout: bool
ami_user: str
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

def launch( *, key_name: str | None, instance_type: str, ami: str, ami_user: str, tags: dict[str, str], display_name: str | None = None, size_gb: int, security_group_name: str, instance_profile: str | None, nonce: str, delete_after: datetime.datetime) -> mypy_boto3_ec2.service_resource.Instance:
392def launch(
393    *,
394    key_name: str | None,
395    instance_type: str,
396    ami: str,
397    ami_user: str,
398    tags: dict[str, str],
399    display_name: str | None = None,
400    size_gb: int,
401    security_group_name: str,
402    instance_profile: str | None,
403    nonce: str,
404    delete_after: datetime.datetime,
405) -> Instance:
406    """Launch and configure an ec2 instance with the given properties."""
407
408    if display_name:
409        tags["Name"] = display_name
410    tags["scratch-delete-after"] = str(delete_after.timestamp())
411    tags["nonce"] = nonce
412    tags["git_ref"] = git.describe()
413    tags["ami-user"] = ami_user
414
415    ec2 = boto3.client("ec2")
416    groups = ec2.describe_security_groups()
417    security_group_id = None
418    for group in groups["SecurityGroups"]:
419        if group["GroupName"] == security_group_name:
420            security_group_id = group["GroupId"]
421            break
422
423    if security_group_id is None:
424        vpcs = ec2.describe_vpcs()
425        vpc_id = None
426        for vpc in vpcs["Vpcs"]:
427            if vpc["IsDefault"]:
428                vpc_id = vpc["VpcId"]
429                break
430        if vpc_id is None:
431            default_vpc = ec2.create_default_vpc()
432            vpc_id = default_vpc["Vpc"]["VpcId"]
433        securitygroup = ec2.create_security_group(
434            GroupName=security_group_name,
435            Description="Allows all.",
436            VpcId=vpc_id,
437        )
438        security_group_id = securitygroup["GroupId"]
439        ec2.authorize_security_group_ingress(
440            GroupId=security_group_id,
441            CidrIp="0.0.0.0/0",
442            IpProtocol="tcp",
443            FromPort=22,
444            ToPort=22,
445        )
446
447    network_interface: InstanceNetworkInterfaceSpecificationTypeDef = {
448        "AssociatePublicIpAddress": True,
449        "DeviceIndex": 0,
450        "Groups": [security_group_id],
451    }
452
453    say(f"launching instance {display_name or '(unnamed)'}")
454    with open(MZ_ROOT / "misc" / "scratch" / "provision.bash") as f:
455        provisioning_script = f.read()
456    kwargs: RunInstancesRequestServiceResourceCreateInstancesTypeDef = {
457        "MinCount": 1,
458        "MaxCount": 1,
459        "ImageId": ami,
460        "InstanceType": cast(InstanceTypeType, instance_type),
461        "UserData": provisioning_script,
462        "TagSpecifications": [
463            {
464                "ResourceType": "instance",
465                "Tags": [{"Key": k, "Value": v} for (k, v) in tags.items()],
466            }
467        ],
468        "NetworkInterfaces": [network_interface],
469        "BlockDeviceMappings": [
470            {
471                "DeviceName": "/dev/sda1",
472                "Ebs": {
473                    "VolumeSize": size_gb,
474                    "VolumeType": "gp3",
475                },
476            }
477        ],
478        "MetadataOptions": {
479            # Allow Docker containers to access IMDSv2.
480            "HttpPutResponseHopLimit": 2,
481        },
482    }
483    if key_name:
484        kwargs["KeyName"] = key_name
485    if instance_profile:
486        kwargs["IamInstanceProfile"] = {"Name": instance_profile}
487    i = boto3.resource("ec2").create_instances(**kwargs)[0]
488
489    return i

Launch and configure an ec2 instance with the given properties.

class CommandResult(typing.NamedTuple):
492class CommandResult(NamedTuple):
493    status: str
494    stdout: str
495    stderr: str

CommandResult(status, stdout, stderr)

CommandResult(status: str, stdout: str, stderr: str)

Create new instance of CommandResult(status, stdout, stderr)

status: str

Alias for field number 0

stdout: str

Alias for field number 1

stderr: str

Alias for field number 2

async def setup(i: mypy_boto3_ec2.service_resource.Instance, git_rev: str) -> None:
498async def setup(
499    i: Instance,
500    git_rev: str,
501) -> None:
502    def is_ready(i: Instance) -> bool:
503        return bool(
504            i.public_ip_address and i.state and i.state.get("Name") == "running"
505        )
506
507    done = False
508    async for remaining in ui.async_timeout_loop(60, 2):
509        print(
510            f"\rscratch> Waiting for instance to become ready: {remaining:0.0f}s remaining\033[K",
511            end="",
512            flush=True,
513            file=sys.stderr,
514        )
515        try:
516            i.reload()
517            if is_ready(i):
518                done = True
519                break
520        except ClientError:
521            pass
522    print(file=sys.stderr)
523    if not done:
524        raise RuntimeError(
525            f"Instance {i} did not become ready in a reasonable amount of time"
526        )
527
528    # Wait for SSH to be available
529    done = False
530    async for remaining in ui.async_timeout_loop(120, 2):
531        print(
532            f"\rscratch> Waiting for SSH access: {remaining:0.0f}s remaining\033[K",
533            end="",
534            flush=True,
535            file=sys.stderr,
536        )
537        try:
538            mssh(i, "true", quiet=True)
539            done = True
540            break
541        except CalledProcessError:
542            continue
543    print(file=sys.stderr)
544    if not done:
545        raise RuntimeError(
546            "SSH did not become available in a reasonable amount of time"
547        )
548
549    # Start git clone while provisioning continues
550    say("Cloning repo (provisioning continues in background)...")
551    mkrepo(i, git_rev)
552
553    # Wait for provisioning to finish
554    done = False
555    async for remaining in ui.async_timeout_loop(300, 2):
556        print(
557            f"\rscratch> Waiting for provisioning to complete: {remaining:0.0f}s remaining\033[K",
558            end="",
559            flush=True,
560            file=sys.stderr,
561        )
562        try:
563            mssh(i, "[[ -f /opt/provision/done ]]", quiet=True)
564            done = True
565            break
566        except CalledProcessError:
567            continue
568    print(file=sys.stderr)
569    if not done:
570        raise RuntimeError(
571            "Instance did not finish setup in a reasonable amount of time"
572        )
def launch_cluster( descs: list[MachineDesc], *, nonce: str | None = None, key_name: str | None = None, security_group_name: str = 'scratch-security-group', instance_profile: str | None = 'admin-instance', extra_tags: dict[str, str] | None = None, delete_after: datetime.datetime, git_rev: str = 'HEAD', extra_env: dict[str, str] | None = None) -> list[mypy_boto3_ec2.service_resource.Instance]:
575def launch_cluster(
576    descs: list[MachineDesc],
577    *,
578    nonce: str | None = None,
579    key_name: str | None = None,
580    security_group_name: str = DEFAULT_SECURITY_GROUP_NAME,
581    instance_profile: str | None = DEFAULT_INSTANCE_PROFILE_NAME,
582    extra_tags: dict[str, str] | None = None,
583    delete_after: datetime.datetime,
584    git_rev: str = "HEAD",
585    extra_env: dict[str, str] | None = None,
586) -> list[Instance]:
587    """Launch a cluster of instances with a given nonce"""
588
589    extra_tags = extra_tags or {}
590    extra_env = extra_env or {}
591
592    if not nonce:
593        nonce = util.nonce(8)
594
595    instances = [
596        launch(
597            key_name=key_name,
598            instance_type=d.instance_type,
599            ami=resolve_ami(d),
600            ami_user=d.ami_user,
601            tags={**d.tags, **extra_tags},
602            display_name=f"{nonce}-{d.name}",
603            size_gb=d.size_gb,
604            security_group_name=security_group_name,
605            instance_profile=instance_profile,
606            nonce=nonce,
607            delete_after=delete_after,
608        )
609        for d in descs
610    ]
611
612    async def setup_all() -> None:
613        await asyncio.gather(
614            *(
615                setup(i, git_rev if d.checkout else "HEAD")
616                for (i, d) in zip(instances, descs)
617            )
618        )
619
620    asyncio.run(setup_all())
621
622    for i in instances:
623        i.reload()
624
625    hosts_str = "".join(
626        f"{i.private_ip_address}\t{d.name}\n" for (i, d) in zip(instances, descs)
627    )
628    for i in instances:
629        mssh(i, "sudo tee -a /etc/hosts", input=hosts_str.encode())
630
631    for i in instances:
632        setup_ai_tools(i)
633
634    env = " ".join(f"{k}={shlex.quote(v)}" for k, v in extra_env.items())
635    for i, d in zip(instances, descs):
636        if d.launch_script:
637            mssh(
638                i,
639                f"(cd materialize && {env} nohup bash -c {shlex.quote(d.launch_script)}) &> mzscratch.log &",
640            )
641
642    return instances

Launch a cluster of instances with a given nonce

def whoami() -> str:
645def whoami() -> str:
646    return boto3.client("sts").get_caller_identity()["UserId"].split(":")[1]
def get_instance(name: str) -> mypy_boto3_ec2.service_resource.Instance:
649def get_instance(name: str) -> Instance:
650    """
651    Get an instance by instance id. The special name 'mine' resolves to a
652    unique running owned instance, if there is one; otherwise the name is
653    assumed to be an instance id.
654    """
655    if name == "mine":
656        filters: list[FilterTypeDef] = [
657            {"Name": "tag:LaunchedBy", "Values": [whoami()]},
658            {"Name": "instance-state-name", "Values": ["pending", "running"]},
659        ]
660        instances = [i for i in boto3.resource("ec2").instances.filter(Filters=filters)]
661        if not instances:
662            raise RuntimeError("can't understand 'mine': no owned instance?")
663        if len(instances) > 1:
664            raise RuntimeError(
665                f"can't understand 'mine': too many owned instances ({', '.join(i.id for i in instances)})"
666            )
667        instance = instances[0]
668        say(f"understanding 'mine' as unique owned instance {instance.id}")
669        return instance
670    return boto3.resource("ec2").Instance(name)

Get an instance by instance id. The special name 'mine' resolves to a unique running owned instance, if there is one; otherwise the name is assumed to be an instance id.

def list_instances( owners: list[str] | None = None, all: bool = False) -> list[mypy_boto3_ec2.service_resource.Instance]:
673def list_instances(
674    owners: list[str] | None = None, all: bool = False
675) -> list[Instance]:
676    """List AWS instances, optionally filtered by owner."""
677    filters: list[FilterTypeDef] = []
678    if not all:
679        if not owners:
680            owners = [whoami()]
681        filters.append({"Name": "tag:LaunchedBy", "Values": owners})
682    return list(boto3.resource("ec2").instances.filter(Filters=filters))

List AWS instances, optionally filtered by owner.

def get_old_instances() -> list[mypy_boto3_ec2.type_defs.InstanceTypeDef]:
685def get_old_instances() -> list[InstanceTypeDef]:
686    def exists(i: InstanceTypeDef) -> bool:
687        return i["State"]["Name"] != "terminated"
688
689    def is_old(i: InstanceTypeDef) -> bool:
690        delete_after = instance_typedef_tags(i).get("scratch-delete-after")
691        if delete_after is None:
692            return False
693        delete_after = float(delete_after)
694        return datetime.datetime.now(datetime.timezone.utc).timestamp() > delete_after
695
696    return [
697        i
698        for r in boto3.client("ec2").describe_instances()["Reservations"]
699        for i in r["Instances"]
700        if exists(i) and is_old(i)
701    ]