Documentation
¶
Overview ¶
Package ec2 launches one instance per job in a cloud region.
THIS IS THE AVAILABILITY STORY, NOT A PROOF OF THE ABSTRACTION. A tier that lists `providers: [firecracker, ec2]` may be placed on either, so losing the bare-metal host does not take the `runs-on` label down with it — which is the difference between self-hosted CI you can rely on and self-hosted CI you can rely on until the power goes out.
TWO THINGS ABOUT THIS BACKEND ARE UNLIKE EVERY OTHER ONE, and both follow from the compute being somewhere else:
The node running it is an ORCHESTRATOR. It holds credentials and calls an API; nothing runs on the machine billet is running on. So what that machine has is not what this node can offer, and `node.max_vcpu` / `node.max_memory` are required rather than detected. They are NOT a spending limit, though it is easy to read them as one: the allocator charges a job the selected shape's size while this backend buys the first declared shape that FITS, so shapes larger than their tiers multiply the real spend.
And the instance IS the isolation boundary, so this backend can run fork pull-request code that the docker backend must refuse. That boundary is the kernel, though, and not the network: untrusted work runs only once a separate security group has been described for it.
Index ¶
- Constants
- Variables
- func BuilderIAMActions() []string
- func BuilderOwner(deployment, name string) string
- func BuilderOwnerPattern(deployment string) string
- func BuilderPayloadIAMActions() []string
- func BuilderPromoteIAMActions() []string
- func BuilderVerifyIAMActions() []string
- func CacheAttachIAMActions() []string
- func CacheDescribeIAMActions() []string
- func CheckInterruptionQueue(ctx context.Context, region string, creds awscreds.Source, queueURL string) (string, error)
- func CheckPayloadBucket(bucket string) error
- func CheckReachable(ctx context.Context, cfg config.EC2Config, opts ...Option) error
- func OnDemandPriceUSDPerHour(ctx context.Context, ec2Region, instanceType string, creds awscreds.Source) (config.USDPerHour, error)
- func QueueProbeInconclusive(err error) bool
- func RegionMayBeDisabled(err error) bool
- func RuntimeDescribeIAMActions() []string
- func RuntimeLaunchIAMActions() []string
- func RuntimeTagIAMActions() []string
- func RuntimeTerminateIAMActions() []string
- func SpotIAMActions() []string
- type BuildSpec
- type DryRunOutcome
- type DryRunResult
- type ImageInfo
- type InstanceTypeInfo
- type Option
- type ProfileCheck
- type Provider
- func (p *Provider) Accepts(trust provider.TrustClass) error
- func (p *Provider) AcknowledgeInterruption(ctx context.Context, notice *provider.InterruptionNotice) error
- func (p *Provider) AttachVolume(ctx context.Context, instanceName string, slot int, volume string) error
- func (p *Provider) BuildImage(ctx context.Context, spec BuildSpec) (string, error)
- func (p *Provider) Destroy(ctx context.Context, id string) (provider.Teardown, error)
- func (p *Provider) DetachVolume(ctx context.Context, instanceName string, slot int, volume string) error
- func (p *Provider) DryRunLaunch(ctx context.Context, image string, trust provider.TrustClass, ...) (DryRunResult, error)
- func (p *Provider) Find(ctx context.Context, name string) (*provider.Instance, bool, error)
- func (p *Provider) GuestVolumeDevice(_ int, volume string) string
- func (p *Provider) Kind() config.ProviderKind
- func (p *Provider) Launch(ctx context.Context, spec provider.Spec) (*provider.Instance, error)
- func (p *Provider) List(ctx context.Context) ([]*provider.Instance, error)
- func (p *Provider) NextInterruption(ctx context.Context) (*provider.InterruptionNotice, error)
- func (p *Provider) Quotas(ctx context.Context) ([]provider.Quota, error)
- func (p *Provider) VerifyImage(ctx context.Context, spec VerifySpec) error
- type SecurityGroupInfo
- type SubnetInfo
- type VerifySpec
Constants ¶
const ( IAMRunInstances = "ec2:RunInstances" IAMTerminateInstances = "ec2:TerminateInstances" IAMDescribeInstances = "ec2:DescribeInstances" IAMDescribeImages = "ec2:DescribeImages" IAMCreateTags = "ec2:CreateTags" IAMDescribeSubnets = "ec2:DescribeSubnets" IAMDescribeSecurityGroups = "ec2:DescribeSecurityGroups" IAMAttachVolume = "ec2:AttachVolume" IAMDetachVolume = "ec2:DetachVolume" IAMDescribeVolumes = "ec2:DescribeVolumes" IAMCreateImage = "ec2:CreateImage" IAMGetConsoleOutput = "ec2:GetConsoleOutput" IAMSQSReceiveMessage = "sqs:ReceiveMessage" IAMSQSDeleteMessage = "sqs:DeleteMessage" IAMSQSGetQueueAttributes = "sqs:GetQueueAttributes" IAMS3PutObject = "s3:PutObject" IAMS3GetObject = "s3:GetObject" IAMS3DeleteObject = "s3:DeleteObject" )
IAM action names for the operations this backend performs, kept beside the code that performs them so the node's IAM policy grants exactly what this package calls. internal/awspolicy imports these and assembles the policy; a drift test pins the assembled document. A permission with no direct API call site is commented as such — ec2:CreateTags is needed because create-time tagging (a TagSpecification on RunInstances, CreateVolume, CreateSnapshot and CreateImage) requires it. There is now ONE standalone CreateTags call, and only on the builder path: a verified AMI's contract tag is written after the image has been booted and proved, so it cannot be a create-time tag.
const AMIContract = 2
AMIContract is what a runner AMI must satisfy for this billet to use it, and goes up whenever the image has to carry a newly required property.
The analogue of firecracker's GuestContract, and it exists for the same reason: an image is built once and used for months, so "which billet made this" has to be a fact recorded ON the artifact rather than something inferred later. A creation date cannot answer it — an image made after a commit is not proof it was built from that commit — and an untagged image is the pre-contract case rather than a passing one.
1: /etc/docker/daemon.json selects the classic image store, so the Docker cache publishes with the images in it rather than an empty filesystem.
2: and the image carries the toolcache GitHub's declaration names, with the variables that make it findable.
THE TAG IS A PROMOTION, NOT A CREATE-TIME CLAIM, and that is the whole content of the number now. CreateImage stamps who owns the image and which billet made it; the contract tag is added afterwards, by CreateTags, once billet has BOOTED the image and proved the properties on the artifact. So an unstamped image is one nothing has verified — which both readers already treat as "no answer, rebuild" — rather than a claim nobody checked.
const BuilderOwnerPrefix = "billet-ami-build-"
BuilderOwnerPrefix begins the owner-tag value `billet ami build` stamps on the builder instance it launches — a per-build identity distinct from a deployment id (which is 32 hex characters). A bundled IAM policy scopes the builder's permissions to this prefix so they isolate the builder without being confused with a deployment's job instances.
const BuilderPayloadKeyPrefix = "billet-payload-"
BuilderPayloadKeyPrefix is the one object-name prefix `billet ami build` stages under, and it is what scopes the payload grant.
THE KEYS SIT AT THE BUCKET ROOT. stagePayload builds `billet-payload-<digest>-<nonce>.tar.gz` and payloadStager refuses a key containing a slash, so there is no configurable prefix to grant on — which is better than one: the grant reaches exactly the objects billet writes and nothing else an operator keeps in the same bucket.
const DefaultBuilderDiskGiB = 80
DefaultBuilderDiskGiB is the root volume a build gets when nobody says.
Exported because `billet ami build` prints it in --builder-disk's help, and a default a flag describes must be the default the code uses.
NOT A ROUND NUMBER SOMEBODY LIKED. Canonical's noble images declare an 8GiB root and GitHub's declared package set is most of it, so the previous behaviour -- writing no Ebs.VolumeSize at all and inheriting whatever the base image said -- left provisioning to die on ENOSPC. Under `set -e` that aborts before the poweroff that signals success, so it produced no image rather than a broken one; what it cost was a paid builder and a failure that reads as an apt problem.
MEASURED ON THE PRODUCED IMAGE, not derived, and re-measured when parity grew. Read by `billet ami verify` from a machine booted off the AMI:
26.8GiB used of 76.4GiB usable, 49.6GiB free (root_used_kib=28106956) /opt/hostedtoolcache: 5.2GiB (toolcache_kib=5435176)
THE OLD FIGURE WAS 7GiB AND 30 WAS ENOUGH FOR IT. Parity took the content to 26.8GiB -- the six-runtime toolcache, five JDKs, three .NET SDKs, PowerShell and its four modules, and the Android SDK with three NDKs -- so a 30GiB volume now leaves about a gigabyte free against a floor the provisioning script itself sets at ten, and every build would be refused before it started.
80 BECAUSE 80 IS WHAT WAS MEASURED, AND NOTHING ELSE HAS BEEN.
This was briefly 60, derived as "26.8 measured plus headroom", and that is the derivation this constant has now been wrong about twice. A review put the arithmetic plainly: 60GiB is about 57.3 usable, leaving 30.5 after the image -- and the number that matters is not what the finished image occupies but the PEAK during the build, because the installers unpack archives onto this same filesystem and delete them as they go. That peak is unmeasured. The preflight free-space check cannot see it either: it runs once, before provisioning, so a transient high-water mark fails later with ENOSPC on a volume that passed.
So the default is the size a real build actually completed on. Erring high costs a few cents of EBS for the life of one builder; erring low costs the build, and the failure arrives forty minutes in as an out-of-space error inside whichever install step happened to be running.
const OwnerTagKey = ownerTag
OwnerTagKey is the tag billet stamps on every instance and cache volume/snapshot it creates, carrying the deployment identity. A bundled IAM policy conditions the destructive actions on this tag: on its exact VALUE (the deployment id) when that is known, isolating one deployment from another in a shared account, or on PRESENCE otherwise. Either holds because the RUNTIME role only ever tags at create time (a TagSpecification, which the policy restricts to ec2:CreateAction, and in value mode to this deployment's own owner), so it can carry the tag only onto resources it is itself creating. The BUILDER role gets one standalone CreateTags besides — the contract promotion — and that one is scoped to image resources ALREADY carrying the per-build owner tag, so it can still only speak about what it made.
Variables ¶
var ErrPromotionUncertain = errors.New("ec2: whether the contract tag was written is unknown")
ErrPromotionUncertain accompanies a failure that happened AFTER CreateTags was accepted, so whether the image carries its contract tag is unknown.
THE THREE-VALUED ANSWER AGAIN, and the third state is the one a caller collapses if you let it. "The verification failed" and "the tag may or may not be there" are different facts, and BuildImage's message told an operator the image was definitely unstamped in both cases — which sends them to re-run a verification against an image that may already be correct, or worse, to go looking for a tag they were told is absent. The same rule the credential paths follow: could-not- tell is never no.
Functions ¶
func BuilderIAMActions ¶
func BuilderIAMActions() []string
BuilderIAMActions are what `billet ami build` needs beyond the runtime set — it snapshots a stopped builder into an AMI with CreateImage.
func BuilderOwner ¶ added in v0.7.0
BuilderOwner is the owner-tag value a build stamps, and BuilderOwnerPattern is what an IAM policy must match to reach it. THEY ARE ONE FUNCTION APART ON PURPOSE: a tagger and a policy deciding this separately is the defect the pair was written to close (issue #56). The value carried no deployment id while the runtime's did, so a value-scoped policy isolated a deployment's job instances and not its builders, and two deployments in one account could image, terminate, read the console of and stamp each other's builds.
MEASURED 2026-09-04 with iam:SimulateCustomPolicy against a live account: a role scoped to `billet-ami-build-<deployment>-*` is allowed on its own builder, implicitly denied on another deployment's, and implicitly denied on an ordinary job instance of its own deployment. The same held at create time against aws:RequestTag, which AWS authorizes separately, so both moved together.
AN EMPTY DEPLOYMENT IS THE ACCOUNT-WIDE MODE, not an oversight: that mode scopes by tag presence because there is no id to scope to, and a build run before a deployment has minted one has nothing to fold in. The value and the pattern then keep the shape they had, which is what leaves that mode working unchanged.
func BuilderOwnerPattern ¶ added in v0.7.0
BuilderOwnerPattern is the IAM match for every builder of one deployment, or for every builder in the account when no deployment is named.
func BuilderPayloadIAMActions ¶ added in v0.7.0
func BuilderPayloadIAMActions() []string
BuilderPayloadIAMActions is what staging the shared installers costs, and all three are performed on the same object by one build.
PUT uploads the archive, GET is what the PRESIGNED url the builder hands the guest resolves to — a presigned URL grants no more than its signer holds, so without GetObject the builder signs a URL that answers 403 and the build fails downloading its own payload — and DELETE is the cleanup that runs as each build ends. Declared here, beside the code that performs them, so the generator cannot grant something billet does not do.
func BuilderPromoteIAMActions ¶
func BuilderPromoteIAMActions() []string
BuilderPromoteIAMActions is the one standalone CreateTags billet makes, and it is standalone for a reason that cannot be worked around: the AMI contract tag records that billet BOOTED the image and proved its properties, which is not knowable at the instant CreateImage creates it. A bundled policy scopes it to image resources carrying the per-build owner tag, which CreateImage stamped.
func BuilderVerifyIAMActions ¶
func BuilderVerifyIAMActions() []string
BuilderVerifyIAMActions is how a build reads the verdict off the image it just made: the verifier instance prints its report to the serial console and billet reads it back. There is no other channel that needs no key pair, no agent and no inbound access.
func CacheAttachIAMActions ¶
func CacheAttachIAMActions() []string
CacheAttachIAMActions are attach/detach of the fenced EBS volume, conditioned by a bundled policy on the owner tag so the role touches only billet's volumes.
func CacheDescribeIAMActions ¶
func CacheDescribeIAMActions() []string
CacheDescribeIAMActions is the volume describe, which acts on "*".
func CheckInterruptionQueue ¶
func CheckInterruptionQueue( ctx context.Context, region string, creds awscreds.Source, queueURL string, ) (string, error)
CheckInterruptionQueue proves the spot interruption queue exists and this identity may read it — via GetQueueAttributes, NEVER ReceiveMessage, which would consume (and hide for the visibility timeout) a real interruption warning some node needed. Returns the queue's ARN.
THE QUEUE URL IS VALIDATED HERE, at the exported boundary, the way ec2.New re-validates it: the request below is signed with the operator's credentials and posted to the URL's own host, so a caller that skipped config.Load must not be able to aim it anywhere. One attempt, no retry ladder: check is interactive and rerunning is cheaper than masking a flap.
func CheckPayloadBucket ¶ added in v0.7.0
CheckPayloadBucket applies the rule the stager enforces, so a policy or a module input cannot accept a bucket the build will refuse.
DOTS ARE THE CASE THIS EXISTS FOR. They are legal in S3 and unusable here: the virtual-hosted host a dotted name produces is not covered by S3's wildcard certificate, so the fetch fails TLS verification. Accepting one in an IAM grant or a Terraform variable means an apply that succeeds, a policy that looks right, and a build that refuses the bucket it was pointed at.
func CheckReachable ¶
CheckReachable proves a set of credentials can reach the EC2 API in a region.
WHAT IT PROVES, EXACTLY: that credentials resolve, that the region and endpoint name something that answers, and that this identity is permitted to call DescribeInstances. It issues one read-only request whose filter deliberately matches nothing.
WHAT IT DOES NOT PROVE: permission to RunInstances, that the subnet and security groups exist, or that the AMI is visible. The subnet, security groups and AMIs are what the cloud preflight's read-only describes add (DescribeSubnet, DescribeSecurityGroups, DescribeImageStates). The launch PERMISSION needs a dry-run, which is a write-SHAPED call — DryRun=true has no side effect, but it is still a launch request — so `billet check` makes it only behind --authorize (DryRunLaunch), an operator opting into AWS's own authorization test rather than a diagnostic doing it uninvited. TerminateInstances cannot be dry-run (it checks the instance id before the permission verdict), so its grant stays advisory.
func OnDemandPriceUSDPerHour ¶
func OnDemandPriceUSDPerHour( ctx context.Context, ec2Region, instanceType string, creds awscreds.Source, ) (config.USDPerHour, error)
OnDemandPriceUSDPerHour fetches the on-demand hourly rate of one shape in one region from the AWS Price List API, so `billet init` can fill the price_usd_per_hour a config must carry rather than leaving the operator to look it up.
THE PRICE IS NOT AN ADMISSION GATE (config records it only to REPORT the maximum configured exposure), so a fetch that cannot answer unambiguously is not fatal to billet — it is fatal to guessing. This returns an error the caller turns into a prompt for an explicit --price rather than writing a number it is unsure of: a single positive hourly USD rate across the returned products is used; zero, or more than one DISTINCT rate, is refused. Distinctness is judged on the exact published rational, BEFORE rounding, so two rates that would round to the same millionth are still seen as two — only the single surviving rate is then rounded into the six decimals the config grammar admits.
func QueueProbeInconclusive ¶
QueueProbeInconclusive reports whether a queue-probe failure is a fact about the CHECKING identity rather than the queue: a role provisioned before sqs:GetQueueAttributes joined SpotIAMActions refuses the probe while consuming warnings perfectly well, so AccessDenied must stay advisory or the probe fails every pre-upgrade node.
func RegionMayBeDisabled ¶
RegionMayBeDisabled reports whether an error is the shape an AWS region that is NOT ENABLED on the account returns. Measured, twice, with VALID credentials (an IAM user and an SSO role that both work in us-east-1): af-south-1 answers
<Code>AuthFailure</Code> <Message>AWS was not able to validate the provided access credentials</Message>
— indistinguishable from a bad key by content. An operator whose key works elsewhere must be told the region is the suspect, or they will rotate credentials that were never wrong.
func RuntimeDescribeIAMActions ¶
func RuntimeDescribeIAMActions() []string
RuntimeDescribeIAMActions are the read-only describes, including the two the cloud preflight makes on the subnet and security groups — a node whose role cannot describe its own subnet would fail that check.
func RuntimeLaunchIAMActions ¶
func RuntimeLaunchIAMActions() []string
RuntimeLaunchIAMActions is RunInstances, which a bundled policy leaves on "*": it creates an instance, volumes and a network interface at once, and scoping it tightly enough to be safe is exactly the kind of multi-resource condition that denies a legitimate launch. The instances it creates are tagged, which is what the tag-conditioned teardown below relies on.
THE ONE BOUND ON IT IS A DENY, not a narrower Allow: RunInstances also authorizes every snapshot a block-device mapping names, so the bundled policy denies this action outright for any snapshot resource the deployment does not own. billet's own launches name no snapshot, so nothing this package sends is affected.
func RuntimeTagIAMActions ¶
func RuntimeTagIAMActions() []string
RuntimeTagIAMActions is CreateTags, restricted by a bundled policy to create-time tagging (ec2:CreateAction) — a node's runtime never calls CreateTags on its own, so this both matches its usage and stops the role from stamping billet's tags onto a resource it did not create.
func RuntimeTerminateIAMActions ¶
func RuntimeTerminateIAMActions() []string
RuntimeTerminateIAMActions is TerminateInstances, conditioned by a bundled policy on the owner tag's presence.
func SpotIAMActions ¶
func SpotIAMActions() []string
SpotIAMActions are what a spot node needs to consume its interruption queue, which is how billet learns a spot instance is about to be reclaimed — plus GetQueueAttributes, which is how `billet check` proves the queue answers WITHOUT consuming a warning. A role provisioned before the probe existed lacks it; the probe classifies that refusal as advisory rather than failing a working node.
Types ¶
type BuildSpec ¶
type BuildSpec struct {
// BaseImage is the AMI the builder starts from. It must be EBS-backed and
// Ubuntu 24.04.
//
// UBUNTU BECAUSE THE DECLARATION IS UBUNTU'S. GitHub publishes what a hosted
// runner image contains as an apt package set for noble, and billet's
// firecracker guest is built from exactly that. Provisioning this backend from
// a dnf distribution meant hand-translating those names into a second,
// unpublished list — which is the two-pins problem `runnerrelease` exists to
// prevent, one backend drifting from the other with nothing to notice.
BaseImage string
// InstanceType is the shape of the BUILDER, which has nothing to do with the
// shapes jobs will later run on. Bigger only makes the build faster.
InstanceType string
// BuilderDiskGiB is the root volume the BUILDER launches with. Zero takes
// DefaultBuilderDiskGiB.
//
// THIS WAS UNSET AND THAT IS A DEFECT, not a simplification. Nothing here
// wrote Ebs.VolumeSize at all, so every build inherited whatever the base
// image declared -- 8GiB on Canonical's noble images. The declared package
// set alone is most of that, and the toolcache does not fit in what is left,
// so provisioning dies on ENOSPC. Under `set -e` that aborts before the
// poweroff that signals success, so it produces no image rather than a broken
// one; the cost is a paid builder and a failure that reads as an apt problem.
//
// It is the BUILDER's disk and not the runner's. CreateImage snapshots this
// volume, so it also becomes the size of the root every job launched from the
// image starts with -- which is why it is sized to what the image needs plus
// working room, rather than to the largest build anybody might run.
BuilderDiskGiB int64
// Arch is the runner build to install: "x64" or "arm64". It must match
// InstanceType, and nothing here can check that — a mismatch produces an image
// whose runner will not execute.
Arch string
// RunnerVersion is the actions/runner release to install, without the "v".
RunnerVersion string
// Name is the name given to the produced AMI. AWS requires it to be unique
// within the account and region.
Name string
// Verify boots the produced AMI and asserts the contract on it before the
// contract tag is written. It is the caller's default rather than this
// package's: a zero BuildSpec that skipped verification would make the safe
// behaviour the one you have to remember.
Verify bool
// VerifyInstanceType is the shape the VERIFIER runs on, which has nothing to do
// with the builder's or with any job's. Empty takes defaultVerifierType for the
// spec's architecture.
VerifyInstanceType string
// CACertPEM, when set, is a PEM bundle of one or more X.509 CAs baked into the
// image's HOST trust store. The EC2 cache client speaks HTTPS to billet's
// cache endpoint, whose certificate a private issuer signs; without that
// issuer in the trust store every cache request fails its TLS handshake and
// the job falls back to a cold fetch. It is validated before any paid builder
// launches. The anchor lands in the host trust store only — job CONTAINERS do
// not inherit it, which is correct: the cache client runs on the host.
CACertPEM string
// PayloadBucket is where the shared installers are staged when they will not
// fit in user data.
//
// EMPTY MEANS EMBED, WHICH IS WHAT EVERY BUILD DID UNTIL THE SCRIPT OUTGREW
// 16384 BYTES. A build whose script still fits needs no bucket and no new
// permission; one whose script does not is refused with the name of this
// field rather than by EC2 with a parameter error.
PayloadBucket string
// contains filtered or unexported fields
}
BuildSpec describes an AMI to build.
type DryRunOutcome ¶
type DryRunOutcome int
DryRunOutcome classifies an EC2 DryRun: whether the request would have been authorized, refused for permission, or refused for another reason.
const ( // DryRunInconclusive is any code that is not a permission verdict — a bad AMI, a // shape not offered in the zone, an invalid parameter. It is the ZERO VALUE on // purpose: in a classifier the dangerous mistake is a false "authorized", so an // unset or unexpected result must read as "proved nothing", never as a pass. DryRunInconclusive DryRunOutcome = iota // DryRunAuthorized is DryRunOperation: the request is well-formed and this // identity may make it — the launch would proceed. DryRunAuthorized // This is the gap the describe-only preflight cannot see. DryRunUnauthorized )
type DryRunResult ¶
type DryRunResult struct {
Outcome DryRunOutcome
Code string
}
DryRunResult is one dry-run's classification and the AWS code behind it.
type ImageInfo ¶
type ImageInfo struct {
ImageID string
State string
Found bool
// Contract is the AMIContract the image was stamped with, and BuiltBy the
// billet that stamped it. Contract is 0 for an image built before billet
// tagged its output — which is not "contract zero" but "no answer", and is
// reported as needing a rebuild for the same reason an old contract is.
Contract int
BuiltBy string
}
ImageInfo is one AMI's launch readiness. Found is false when the id resolves to nothing — a not-yet-built placeholder, a typo, or an AMI in another account or region — with State carrying the AWS code (e.g. InvalidAMIID.Malformed) when there was one.
func DescribeImageStates ¶
func DescribeImageStates( ctx context.Context, region, endpoint string, creds awscreds.Source, imageIDs []string, ) ([]ImageInfo, error)
DescribeImageStates reports each AMI's launch readiness, one describe per id so a malformed or not-found placeholder does not fail the lookup of a sibling that resolves. An InvalidAMIID.* code becomes a not-found result rather than an error, because "the AMI is not built yet" is a finding the check reports, not a failure of the check itself; any other API error is returned.
type InstanceTypeInfo ¶
InstanceTypeInfo is what one EC2 shape holds, as DescribeInstanceTypes reports it. `billet init --provider ec2` reads it so an operator can name a shape and have billet write the vcpu and memory an instance_types entry must DECLARE, rather than looking those up by hand and copying a number that overcommits the host the allocator escrowed against if it is wrong.
func DescribeInstanceTypes ¶
func DescribeInstanceTypes( ctx context.Context, region, endpoint string, creds awscreds.Source, types []string, ) ([]InstanceTypeInfo, error)
DescribeInstanceTypes reports what each named shape holds, following the reply's pagination to the end. It is NOT part of the node's runtime IAM set: it runs at `billet init` time under the operator's own credentials, not under the launched node's least-privilege role, so adding ec2:DescribeInstanceTypes to a node's policy would widen a grant nothing at runtime exercises.
A shape AWS does not offer in the region comes back absent rather than as an error, so the caller checks that every type it asked for is present: billet must not silently write a config missing a shape the operator named, because a tier derived from it would then have nothing to buy.
type Option ¶
type Option func(*Provider)
Option configures a Provider.
func WithCredentials ¶
WithCredentials sets where AWS credentials come from. The default is the environment, then this instance's own IAM role.
func WithHTTPClient ¶
WithHTTPClient sets the client used for API calls, for a test or for a deployment that needs a proxy.
func WithLogger ¶
WithLogger sets the logger. The default is slog.Default().
type ProfileCheck ¶
type ProfileCheck int
ProfileCheck is the three-valued answer about an instance profile. The values are distinct on purpose: Missing is a misconfiguration a launch will fail on; Unknown means the CHECKING identity may not read IAM, which says nothing about the profile — advisory, never fatal.
const ( // Unknown is the ZERO VALUE on purpose: an uninitialized verdict must be // the least confident answer, not the most. ProfileUnknown ProfileCheck = iota ProfileFound ProfileMissing )
func CheckInstanceProfile ¶
func CheckInstanceProfile( ctx context.Context, region, endpoint string, creds awscreds.Source, name string, ) (ProfileCheck, string, error)
CheckInstanceProfile asks IAM whether the named instance profile exists. region is the deployment's EC2 region, from which the partition-global IAM endpoint AND its signing region are derived — IAM is global per partition, and signing a China or GovCloud request as us-east-1 is a SignatureDoesNotMatch that would read as a permanently Unknown verdict. endpoint overrides the host for tests only. reason carries the API's own words for the Unknown verdict.
type Provider ¶
type Provider struct {
// contains filtered or unexported fields
}
Provider launches EC2 instances, one per job.
func New ¶
New builds an EC2 provider. owner names this billet deployment and is written onto every instance it starts.
func (*Provider) Accepts ¶
func (p *Provider) Accepts(trust provider.TrustClass) error
Accepts reports whether this backend may run work of that trust class.
UNTRUSTED IS PERMITTED HERE, unlike the container backend, because a whole instance is a real isolation boundary: fork pull-request code gets its own kernel and its own machine, and the machine is destroyed afterwards.
BUT ONLY ONCE ITS NETWORK HAS BEEN DESCRIBED. The boundary an instance provides is the kernel, not the VPC — a fork's job in the same security group as everything else reaches whatever that group reaches, which on a subnet somebody already had is usually a good deal more than they are picturing. Defaulting to the trusted group would be deciding that on their behalf, silently, in the direction that cannot be undone once a job has run.
UNKNOWN is refused outright, and that is not the same judgement. Untrusted is a classification billet made; unknown means it could not classify the job at all, so there is no basis for choosing either group.
func (*Provider) AcknowledgeInterruption ¶
func (p *Provider) AcknowledgeInterruption( ctx context.Context, notice *provider.InterruptionNotice, ) error
AcknowledgeInterruption removes a warning only after the runner has durably recorded and acted on it.
func (*Provider) AttachVolume ¶
func (p *Provider) AttachVolume( ctx context.Context, instanceName string, slot int, volume string, ) error
AttachVolume hot-attaches one owned EBS cache volume and waits for AWS to report the attachment complete before the guest is told to discover it.
func (*Provider) BuildImage ¶
BuildImage produces an AMI containing the GitHub Actions runner and Docker.
WHY BILLET DRIVES THIS ITSELF rather than shipping a Packer template: the contract between billet and an image is six lines of shell, and a build tool that has to be kept in sync with those six lines is a second place for them to be wrong. billet already speaks four EC2 actions; this adds one.
HOW IT KNOWS PROVISIONING FINISHED, without SSH, an agent, or any IAM the builder does not already have: the provisioning script ends in `poweroff`. A successful build is an instance that STOPPED ITSELF, which billet can see with the DescribeInstances it already makes. A failed one never reaches that line, so it stays running and the wait below times out against a machine still holding its console log — which is the state you want to debug from, rather than an image made from a half-provisioned disk.
THE BUILDER IS TERMINATED ON EVERY FAILURE PATH THAT HAS AN ID, which is not the same as always, and the difference is worth stating because this is the one thing here that costs money for as long as it exists.
If RunInstances commits and its response is lost — a transport failure, a context expiring mid-reply — launchBuilder returns an error and no id, so there is nothing to terminate and the builder runs until somebody notices. It carries the owner tag, which is how it is found.
A CLIENT TOKEN NARROWS THAT TO A RECOVERY rather than a leak: the token is derived from the image name, which is already unique per account and region, so re-running the same build returns the SAME builder instead of buying a second one. That behaviour is not assumed — a live run measured EC2 refusing a reused token with IdempotentParameterMismatch, which is the same machinery.
func (*Provider) Destroy ¶
Destroy terminates an instance, whether or not it is still running.
Idempotent: an id that is already gone is success. Teardown runs on paths that have already failed once, and erroring there turns recoverable state into stuck state.
IT DOES NOT CONFIRM THE MACHINE IS GONE. TerminateInstances returns when AWS accepts the request, and an idempotent NotFound may be an eventually consistent miss. The caller transfers the lease to custody, which keeps capacity charged and checks the instance out of band while this serial command queue remains available. `shutting-down` stays in List because the guest may still be executing there. An absence is trusted only after the instance has appeared in inventory or the eventually-consistent stray grace has elapsed.
func (*Provider) DetachVolume ¶
func (p *Provider) DetachVolume( ctx context.Context, instanceName string, slot int, volume string, ) error
DetachVolume uses the volume identity persisted in cache custody. An instance termination can remove the attachment before cleanup runs, so rediscovering a volume from its former instance and slot would lose the only handle to it.
func (*Provider) DryRunLaunch ¶
func (p *Provider) DryRunLaunch( ctx context.Context, image string, trust provider.TrustClass, instanceType config.EC2InstanceType, disk config.ByteSize, ) (DryRunResult, error)
DryRunLaunch asks EC2 whether the RunInstances the launch WOULD make — same image, shape, network for the trust class, instance profile, spot, disk and OWNER TAG — is authorized, without launching anything.
DryRun IS SAFE FROM A DIAGNOSTIC, which is why `billet check` gates this behind --authorize rather than running it by default: DryRun=true has no side effect — AWS validates the request and checks IAM, then refuses with DryRunOperation (would have worked) or UnauthorizedOperation (may not) and starts nothing. It is AWS's own authorization test, which is exactly the permission the read-only describes cannot confirm.
THE OWNER TAG MUST BE THE DEPLOYMENT'S, or a per-deployment IAM policy — which conditions ec2:CreateTags on the exact sh.billet.owner value — refuses the launch's TagSpecification and the whole RunInstances fails as UnauthorizedOperation. The real launch tags with the deployment id; so must this, or the diagnostic asks a different question than the launch. The caller constructs the provider with the deployment owner for exactly this reason.
func (*Provider) Find ¶
Find reports the instance with that name, including a retained terminal record.
func (*Provider) GuestVolumeDevice ¶
GuestVolumeDevice names the persistent udev link created for an EBS NVMe device. The API's /dev/sdX attachment name is not the name Nitro exposes.
func (*Provider) Kind ¶
func (p *Provider) Kind() config.ProviderKind
Kind reports the backend this is.
func (*Provider) NextInterruption ¶
NextInterruption blocks for one warning from the configured SQS queue.
func (*Provider) Quotas ¶
Quotas reports the account ceiling this node's budget runs against.
ONE LIMIT, DELIBERATELY. An ec2 node may buy several shapes, but the ceiling that binds them is a single vCPU allowance across the standard families rather than a limit per shape — so the useful sentence is "this account will run N vCPUs and you have configured M", which is a comparison `billet check` can make against node.max_vcpu without knowing anything about the catalogue.
WHAT IT DOES NOT COVER, and the report says so: a deployment declaring a shape outside the standard families — a GPU, a metal, a burstable-unlimited spot pool — runs against a different allowance this does not read. Reporting the standard one is still worth more than reporting nothing, and claiming it covers everything would be the overreach ADR-005 warns about.
func (*Provider) VerifyImage ¶
func (p *Provider) VerifyImage(ctx context.Context, spec VerifySpec) error
VerifyImage boots one instance from an AMI, makes it assert the contract on itself, and stamps the contract tag if it does.
WHY THIS EXISTS AT ALL. Every other claim billet makes about a runner image is checked on the BUILDER, before CreateImage — on a machine that has been apt-installed, part-configured and never rebooted. That is not the machine the image produces, and the difference is not academic: the Docker gate asserted a storage driver against a daemon apt had already started, so it read the answer from before daemon.json was written and failed every build against an image that was correct. Anything a service reads at start, anything cloud-init does at first boot, and anything a job's own `env -i` can or cannot see are all invisible from there.
HOW IT READS THE ANSWER, with no key pair, no agent and no inbound access: the verifier prints a bracketed report to the serial console and billet reads it back with GetConsoleOutput. That is the same shape of signalling BuildImage already relies on to know provisioning finished, and needs no IAM the builder does not already have besides the read itself.
PROVEN ON A REAL BUILD, 2026-08-28, us-west-2. `billet ami build` produced ami-0af6ca1a9ff63a09a, booted it, and read back:
verdict=ok step=done docker_driver=overlay2 docker_root=/var/lib/docker docker_server=29.1.3 root_free_kib=18576852 root_total_kib=29378688 root_used_kib=10785452 runner=2.336.0 toolcache_kib=5360256 tc_node=22.23.2 24.20.0 tc_go=1.24.13 1.25.14 1.26.7 tc_python=3.10.21 3.11.16 3.12.14 3.13.15 3.14.7 tc_java_temurin_hotspot_jdk=8.0.504-1 11.0.32-9 17.0.20-1 21.0.12-1 25.0.4-1
then stamped the contract and terminated both machines. `docker_driver=overlay2` is the line the whole issue is about: the in-build gate read `overlayfs` off the builder's own daemon for exactly this image shape.
THE VERIFIER IS TERMINATED ON EVERY FAILURE PATH THAT HAS AN ID, which is not the same as always — the same gap BuildImage documents for the builder, and worth restating because the second bound is weaker here than it looks. If RunInstances commits and its response is lost, launchVerifier returns an error and no id, so there is nothing to terminate. The script's own poweroff covers most of that, and NOT the case this command exists for: an image with broken cloud-init, or one that panics on boot, never runs the script at all. Such an instance carries the per-build owner tag, which is how it is found.
WHAT IT DOES NOT PROVE, said here rather than discovered later: the nonce makes a block THIS run's, not TRUE. An image that prints `verdict=ok` without doing anything passes, exactly as a base image whose own policy powers the machine off would satisfy the build's success signal. This is a check against mistake — against the artifact differing from what the builder measured — and the trust it rests on is the operator's own image, which is the trust `billet ami build` already rests on.
type SecurityGroupInfo ¶
SecurityGroupInfo is what one DescribeSecurityGroups item tells the preflight.
func DescribeSecurityGroups ¶
func DescribeSecurityGroups( ctx context.Context, region, endpoint string, creds awscreds.Source, groupIDs []string, ) ([]SecurityGroupInfo, error)
DescribeSecurityGroups reports each group's VPC. A group id that resolves to nothing is an error, for the same reason as a missing subnet.
type SubnetInfo ¶
SubnetInfo is what one DescribeSubnets item tells the preflight.
func DescribeSubnet ¶
func DescribeSubnet( ctx context.Context, region, endpoint string, creds awscreds.Source, subnetID string, ) (SubnetInfo, error)
DescribeSubnet reports the subnet's VPC, availability zone and state. A subnet id that resolves to nothing is an error rather than an empty result: the check asked about a specific subnet and "there is no such subnet" is the answer it needs, not silence.
type VerifySpec ¶
type VerifySpec struct {
// Image is the AMI to verify. It has to be available: this launches from it.
Image string
// InstanceType is the shape the VERIFIER runs on, which has nothing to do with
// the builder's or with any job's. Empty takes defaultVerifierType for the
// image's own architecture.
InstanceType string
// Name prefixes the verifier instance's Name tag. Empty takes the image id,
// which is what a standalone verification has.
Name string
}
VerifySpec describes an image to boot and assert on.