Documentation
¶
Overview ¶
Package firstboot is the Go client for the Firstboot API.
It is two layers. `fbapi` is generated from the platform's own `openapi.json` and is never edited by hand: every endpoint, every request and response type, every state enum. This package is the layer the generator cannot write, and it exists because the hard part of talking to this API is not sending a request:
- Knowing when a create is FINISHED. A create answers 202 and the resource converges in the background, so the client has to poll until the state is one that has settled. Which values those are differs per resource, and a value the client does not recognise must count as still working.
- Retrying WITHOUT creating a second server. Every create accepts an `Idempotency-Key`; this client sets one automatically and reuses it across its own retries, which is the whole reason a retry here is safe.
- Reading a rate limit's answer instead of guessing at it. A refused create carries `Retry-After`, measured from the moment a slot actually frees.
The three consumers this was built for (a Terraform provider, a CLI and an MCP server) all need those three things and would otherwise each write their own, differently.
Index ¶
- Constants
- Variables
- func CodeFromDetail(detail string) string
- func Done(kind, state string) bool
- func ErrorFrom(status int, model *fbapi.ErrorModel, header http.Header) error
- func WithIdempotencyKey(key string) fbapi.RequestEditorFn
- type APIError
- type AppListOption
- type Client
- func (c *Client) Apps(ctx context.Context, opts ...AppListOption) iter.Seq2[fbapi.AppBody, error]
- func (c *Client) Databases(ctx context.Context, opts ...DatabaseListOption) iter.Seq2[fbapi.DatabaseBody, error]
- func (c *Client) Domains(ctx context.Context, opts ...DomainListOption) iter.Seq2[fbapi.DomainBody, error]
- func (c *Client) LoadBalancers(ctx context.Context, opts ...LoadBalancerListOption) iter.Seq2[fbapi.LoadBalancerBody, error]
- func (c *Client) Networks(ctx context.Context, opts ...NetworkListOption) iter.Seq2[fbapi.NetworkBody, error]
- func (c *Client) Servers(ctx context.Context, opts ...ServerListOption) iter.Seq2[fbapi.ServerBody, error]
- func (c *Client) Volumes(ctx context.Context, opts ...VolumeListOption) iter.Seq2[fbapi.VolumeBody, error]
- func (c *Client) WaitForBuild(ctx context.Context, appCode, buildID string, opts ...WaitOption) (*fbapi.BuildBody, error)
- func (c *Client) WaitForDatabase(ctx context.Context, id string, opts ...WaitOption) (*fbapi.DatabaseBody, error)
- func (c *Client) WaitForDomain(ctx context.Context, id string, opts ...WaitOption) (*fbapi.DomainBody, error)
- func (c *Client) WaitForISO(ctx context.Context, id string, opts ...WaitOption) (*fbapi.IsoBody, error)
- func (c *Client) WaitForLoadBalancer(ctx context.Context, id string, opts ...WaitOption) (*fbapi.LoadBalancerBody, error)
- func (c *Client) WaitForServer(ctx context.Context, id string, opts ...WaitOption) (*fbapi.ServerBody, error)
- func (c *Client) WaitForServerAction(ctx context.Context, serverID string, actionID uuid.UUID, opts ...WaitOption) (*fbapi.ActionBody, error)
- func (c *Client) WaitForVolume(ctx context.Context, id uuid.UUID, opts ...WaitOption) (*fbapi.VolumeBody, error)
- func (c *Client) Zones(ctx context.Context, opts ...ZoneListOption) iter.Seq2[fbapi.DnsZoneBody, error]
- type DatabaseListOption
- type DomainListOption
- type LoadBalancerListOption
- type NetworkListOption
- type Option
- type Outcome
- type RetryPolicy
- type ServerListOption
- type StateError
- type TimeoutError
- type VolumeListOption
- type WaitOption
- type WaitOptions
- type ZoneListOption
Constants ¶
const ( EnvBaseURL = "FIRSTBOOT_API_URL" EnvToken = "FIRSTBOOT_TOKEN" )
Environment variables the client reads when an option is not given. They are the names the customer documentation already uses, so a reader who followed the API guide has them exported already.
const ( // DefaultISOWaitTimeout covers a multi-gigabyte download from a URL the // customer chose, over a link nobody here controls. DefaultISOWaitTimeout = 60 * time.Minute // DefaultBuildWaitTimeout covers an image build: dependency install, // compile, push. A cold cache on a first deploy is the slow case. DefaultBuildWaitTimeout = 30 * time.Minute // DefaultDomainWaitTimeout covers a REGISTRY's answer. Long enough for the // slow TLDs and nowhere near long enough for a transfer, which takes days -- // a caller waiting on one has to say so with WithTimeout. DefaultDomainWaitTimeout = 20 * time.Minute )
The three waits whose work is not this platform's, and whose budgets therefore have nothing to do with how fast a server boots.
const DefaultUserAgent = "firstboot-go"
DefaultUserAgent identifies this client in the platform's access log. Worth setting: "which client is hammering this endpoint" is otherwise answerable only by IP, and a CI runner's IP says nothing.
const DefaultWaitTimeout = 15 * time.Minute
DefaultWaitTimeout is generous on purpose. A create is measured against "SSH open in 77 s" on real hardware, but a cold template on a busy host is slower and a caller that gave up at two minutes would report a failure the platform did not have.
const IdempotencyHeader = "Idempotency-Key"
IdempotencyHeader is the header name, exported because a caller managing its own keys should not have to spell it.
const PageSize = 200
PageSize is the per-request page. 200 is the API's ceiling (a larger limit is REFUSED rather than clamped, so asking for more is an error, not a hint), and asking for the maximum is right here: an iterator's caller has already said it wants everything.
Variables ¶
var ( // ErrNoCapacity: the region has no host with room. Retrying later can work; // retrying immediately cannot. ErrNoCapacity = &APIError{Code: "NO_CAPACITY_IN_REGION"} // ErrPlanNotOffered: no host in that region sells the plan. Waiting does // not help -- this is a catalog fact, not a capacity one, and the two used // to be one 503 that told customers to wait for room that would not have // helped. ErrPlanNotOffered = &APIError{Code: "PLAN_NOT_OFFERED_IN_REGION"} // ErrInsufficientBalance: the wallet cannot cover the first month. ErrInsufficientBalance = &APIError{Code: "INSUFFICIENT_BALANCE"} // ErrCreateCooldown: too many resources created in the rolling window. The // response carries Retry-After and this client already waited on it, so // seeing this error means the wait exceeded the retry policy's budget. ErrCreateCooldown = &APIError{Code: "CREATE_COOLDOWN"} // ErrIdempotencyKeyReused: the same key was sent with a different body. // Never retryable as sent -- the caller is generating one key for two // requests, which is almost always a key built outside its loop. ErrIdempotencyKeyReused = &APIError{Code: "IDEMPOTENCY_KEY_REUSED"} // ErrIdempotencyConflict: another request holding the same key committed // between the lookup and the insert. Retryable, and this client retries it. ErrIdempotencyConflict = &APIError{Code: "IDEMPOTENCY_CONFLICT"} // ErrQuotaExceeded covers both levels; the API distinguishes them and a // caller almost never can act on the difference. ErrQuotaExceeded = &APIError{Code: "QUOTA_EXCEEDED"} // ErrOrganizationSuspended: nothing new is provisioned for this account. ErrOrganizationSuspended = &APIError{Code: "ORGANIZATION_SUSPENDED"} )
Sentinels for the refusals a caller can actually do something about. This is deliberately not every code the API can return: a list that tried to be exhaustive would go stale silently, while a caller comparing `apiErr.Code == "SOMETHING_NEW"` always works.
Functions ¶
func CodeFromDetail ¶
CodeFromDetail pulls the machine-readable code off a problem document's `detail`. Exported for the same reason as ErrorFrom: a caller holding a detail string from somewhere else should not re-derive the rule.
func ErrorFrom ¶
ErrorFrom builds an APIError from a generated response's parsed problem document. Exported because every consumer of this library needs it: the generated methods hand back a typed response rather than an error, so turning a non-2xx into something errors.Is can match is the caller's job, and three callers doing it three ways is three subtly different error surfaces.
if resp.JSON200 == nil {
return firstboot.ErrorFrom(resp.StatusCode(),
resp.ApplicationproblemJSONDefault, resp.HTTPResponse.Header)
}
func WithIdempotencyKey ¶
func WithIdempotencyKey(key string) fbapi.RequestEditorFn
WithIdempotencyKey returns a request editor that pins one key to one call. Use it when the key has to mean something outside this process:
resp, err := c.API.ServerCreateWithResponse(ctx, params, body,
firstboot.WithIdempotencyKey("tf:"+resourceAddress))
A key must identify the REQUEST, not the caller: the same key with a different body is refused with IDEMPOTENCY_KEY_REUSED, which is the API telling you the key was built outside the loop that varies.
Types ¶
type APIError ¶
type APIError struct {
// Status is the HTTP status. Kept because the code alone does not say
// whether waiting could help.
Status int
// Code is the machine-readable half: the leading token of `detail`, which
// this API writes as SCREAMING_SNAKE. Empty when the response carried none,
// which is the shape of a 500 -- those deliberately drop their detail.
Code string
// Detail is the whole `detail` field, code included. Some codes carry a
// human sentence after them (USER_DATA_NOT_SUPPORTED does); it is here
// rather than parsed off, because parsing it would invent a second contract.
Detail string
// Title is the problem document's title, e.g. "Unprocessable Entity".
Title string
// RequestID is the platform's own request id when the response carried one.
// It is the join key between what a caller saw and what the operator's log
// says, and quoting it in a support ticket is the difference between a
// diagnosis and a guess.
RequestID string
}
APIError is any refusal the API expressed as a problem document.
type AppListOption ¶
type AppListOption func(*fbapi.AppsListParams)
AppListOption narrows an app walk.
func AppsInProject ¶
func AppsInProject(id string) AppListOption
AppsInProject narrows to one project, or to "none".
func AppsWithTags ¶
func AppsWithTags(tags ...string) AppListOption
AppsWithTags narrows to apps carrying EVERY tag given.
type Client ¶
type Client struct {
// API is the generated client. Exported on purpose: this package wraps the
// calls that need wrapping and gets out of the way for the ~190 that do
// not. A consumer reaching straight for c.API is using this library
// correctly, not working around it.
API *fbapi.ClientWithResponses
// contains filtered or unexported fields
}
Client talks to one Firstboot account.
An API token is pinned to one organization for the life of the token, so a Client IS an organization: there is deliberately no per-call organization parameter and no switcher. Two organizations means two Clients.
func New ¶
New builds a Client. It fails rather than defaulting when the base URL or the token is missing: a client pointed at nothing produces a connection error pages later, and a client with no token produces 401s that read like a permissions problem.
func (*Client) Databases ¶
func (c *Client) Databases(ctx context.Context, opts ...DatabaseListOption) iter.Seq2[fbapi.DatabaseBody, error]
Databases walks every managed database instance in the organization.
func (*Client) Domains ¶
func (c *Client) Domains(ctx context.Context, opts ...DomainListOption) iter.Seq2[fbapi.DomainBody, error]
Domains walks every domain registration in the organization.
func (*Client) LoadBalancers ¶
func (c *Client) LoadBalancers(ctx context.Context, opts ...LoadBalancerListOption) iter.Seq2[fbapi.LoadBalancerBody, error]
LoadBalancers walks every load balancer in the organization.
func (*Client) Networks ¶
func (c *Client) Networks(ctx context.Context, opts ...NetworkListOption) iter.Seq2[fbapi.NetworkBody, error]
Networks walks every private network in the organization.
func (*Client) Servers ¶
func (c *Client) Servers(ctx context.Context, opts ...ServerListOption) iter.Seq2[fbapi.ServerBody, error]
Servers walks every server in the organization.
for srv, err := range c.Servers(ctx) {
if err != nil { return err }
fmt.Println(srv.Name, srv.State)
}
The filters `serversList` accepts (search, state, project) are applied BEFORE paging by the API, so a filtered walk scans the whole account rather than one page. Pass them through opts.
func (*Client) Volumes ¶
func (c *Client) Volumes(ctx context.Context, opts ...VolumeListOption) iter.Seq2[fbapi.VolumeBody, error]
Volumes walks every volume in the organization.
Note what this costs: a volume is billed at its provisioned size from creation to deletion whether or not it is attached, so a walk that finds detached ones is finding money.
func (*Client) WaitForBuild ¶
func (c *Client) WaitForBuild(ctx context.Context, appCode, buildID string, opts ...WaitOption) (*fbapi.BuildBody, error)
WaitForBuild polls one app build to its own terminal state.
Separate from the app for the same reason a power action is separate from its server: an app that is already running keeps running while a new version builds, so watching the APP shows nothing happening and cannot tell a finished build from one that never started. The build is the thing with an answer.
A cancelled build returns WITHOUT an error. It is terminal and it is the customer's own doing.
func (*Client) WaitForDatabase ¶
func (c *Client) WaitForDatabase(ctx context.Context, id string, opts ...WaitOption) (*fbapi.DatabaseBody, error)
WaitForDatabase polls until the database instance has settled.
It does NOT wait for `pending_apply` to clear. That flag tracks an edit reaching the appliance, which is a second thing to wait for and a different one: a resize leaves the state `active` throughout and only the flag moves. Waiting on the state here keeps this waiter answering one question.
func (*Client) WaitForDomain ¶
func (c *Client) WaitForDomain(ctx context.Context, id string, opts ...WaitOption) (*fbapi.DomainBody, error)
WaitForDomain polls a registration until the registry has answered.
The budget is its own and it is long, because the thing being waited on is not this platform: a registry answers a register in seconds for most TLDs and in minutes for some, and a transfer is measured in days rather than minutes. A caller waiting on a transfer should say so with WithTimeout rather than trust any default.
A domain that settles into `expired` or `redemption` is returned WITHOUT an error: the name was really registered and really lapsed later, which is not a failure of the call that bought it.
func (*Client) WaitForISO ¶
func (c *Client) WaitForISO(ctx context.Context, id string, opts ...WaitOption) (*fbapi.IsoBody, error)
WaitForISO polls until a custom ISO has downloaded.
The budget is its own: an ISO is a multi-gigabyte fetch from a URL the customer chose, over a link this platform does not control, so the default that suits a server create is far too short for it.
func (*Client) WaitForLoadBalancer ¶
func (c *Client) WaitForLoadBalancer(ctx context.Context, id string, opts ...WaitOption) (*fbapi.LoadBalancerBody, error)
WaitForLoadBalancer polls until the load balancer has settled.
A load balancer answers 202 and its data plane is configured afterwards, so the address in the create's response answers nothing until this returns.
func (*Client) WaitForServer ¶
func (c *Client) WaitForServer(ctx context.Context, id string, opts ...WaitOption) (*fbapi.ServerBody, error)
WaitForServer polls until the server has settled, and returns it.
A server that settles into `stopped` is returned WITHOUT an error: it is a real, finished, billable machine, and a library that called that a failure would be making a product judgement it has no standing to make. Only an `error_*` state produces a *StateError.
func (*Client) WaitForServerAction ¶
func (c *Client) WaitForServerAction(ctx context.Context, serverID string, actionID uuid.UUID, opts ...WaitOption) (*fbapi.ActionBody, error)
WaitForServerAction polls one power action to its own terminal state.
Separate from WaitForServer because the SERVER's state is not the answer for every action: a reboot leaves it `running` throughout, so a caller watching the server sees nothing happen and cannot tell success from a request that was never applied.
func (*Client) WaitForVolume ¶
func (c *Client) WaitForVolume(ctx context.Context, id uuid.UUID, opts ...WaitOption) (*fbapi.VolumeBody, error)
WaitForVolume polls until the volume has settled.
type DatabaseListOption ¶
type DatabaseListOption func(*fbapi.DatabasesListParams)
DatabaseListOption narrows a managed-database walk.
func DatabasesInProject ¶
func DatabasesInProject(id string) DatabaseListOption
DatabasesInProject narrows to one project, or to "none".
func DatabasesWithTags ¶
func DatabasesWithTags(tags ...string) DatabaseListOption
DatabasesWithTags narrows to instances carrying EVERY tag given.
type DomainListOption ¶
type DomainListOption func(*fbapi.DomainsListParams)
DomainListOption narrows a domain walk.
func DomainsInProject ¶
func DomainsInProject(id string) DomainListOption
DomainsInProject narrows to one project, or to "none".
func DomainsInState ¶
func DomainsInState(state string) DomainListOption
DomainsInState narrows to one lifecycle state, e.g. `active` or `expired`.
func DomainsWithTags ¶
func DomainsWithTags(tags ...string) DomainListOption
DomainsWithTags narrows to domains carrying EVERY tag given.
func SearchDomains ¶
func SearchDomains(q string) DomainListOption
SearchDomains matches a substring of the name.
type LoadBalancerListOption ¶
type LoadBalancerListOption func(*fbapi.LoadBalancersListParams)
LoadBalancerListOption narrows a load-balancer walk.
func LoadBalancersInProject ¶
func LoadBalancersInProject(id string) LoadBalancerListOption
LoadBalancersInProject narrows to one project, or to "none".
func LoadBalancersWithTags ¶
func LoadBalancersWithTags(tags ...string) LoadBalancerListOption
LoadBalancersWithTags narrows to load balancers carrying EVERY tag given.
type NetworkListOption ¶
type NetworkListOption func(*fbapi.NetworksListParams)
NetworkListOption narrows a private-network walk.
func NetworksInProject ¶
func NetworksInProject(id string) NetworkListOption
NetworksInProject narrows to one project, or to "none".
func NetworksWithTags ¶
func NetworksWithTags(tags ...string) NetworkListOption
NetworksWithTags narrows to networks carrying EVERY tag given.
type Option ¶
type Option func(*Client)
Option configures a Client. Options are applied in order, so a later one wins, which is what lets a caller override an environment default.
func WithBaseURL ¶
WithBaseURL sets the API origin, e.g. https://api.example.com. Overrides FIRSTBOOT_API_URL.
func WithHTTPClient ¶
WithHTTPClient replaces the underlying client. The retry and idempotency transports are layered ON TOP of whatever Transport it carries, so a caller supplying an instrumented client keeps their instrumentation.
func WithRetry ¶
func WithRetry(p RetryPolicy) Option
WithRetry replaces the retry policy. A zero RetryPolicy disables retrying.
func WithToken ¶
WithToken sets the API token (the `pat_` credential minted in the panel). Overrides FIRSTBOOT_TOKEN.
func WithUserAgent ¶
WithUserAgent appends a product token to the User-Agent, e.g. "terraform-provider-firstboot/0.1.0". The library's own token stays.
func WithoutAutoIdempotency ¶
func WithoutAutoIdempotency() Option
WithoutAutoIdempotency stops the client generating an `Idempotency-Key` for creates that do not carry one.
There is one honest reason to use it and it is not performance: a caller that manages keys itself across process restarts, where a key generated per process is the wrong scope. Turning it off to "keep requests simple" removes the protection that makes this client's retries safe.
type Outcome ¶
type Outcome int
Outcome classifies one state value.
const ( // Working: the value will change on its own. Keep polling. Working Outcome = iota // Ready: the resource reached the state a successful create aims at. Ready // Settled: terminal, but not what a create was waiting for -- a server that // is `stopped`, a database that is `suspended`. Waiting longer is pointless; // whether it is a problem is the caller's judgement, not this library's. Settled // Failed: terminal and a failure. The waiters turn this into a *StateError. Failed )
type RetryPolicy ¶
type RetryPolicy struct {
// MaxAttempts includes the first one, so 1 means "no retry" and 0 means the
// same thing said by accident.
MaxAttempts int
// Base is the first backoff; each attempt doubles it up to Max.
Base time.Duration
Max time.Duration
// MaxRetryAfter caps how long the client will honour a server-sent
// Retry-After. The platform's create cooldown can answer with most of an
// hour, and a library that sleeps for an hour inside one call has stopped
// being a library. Past this the error is returned and the caller decides.
MaxRetryAfter time.Duration
}
RetryPolicy bounds how hard the client tries. The zero value retries nothing, which is what WithRetry(RetryPolicy{}) means.
func DefaultRetryPolicy ¶
func DefaultRetryPolicy() RetryPolicy
DefaultRetryPolicy is the backoff the customer documentation already prescribes for polling (1s to 15s), applied to transport failures.
type ServerListOption ¶
type ServerListOption func(*fbapi.ServersListParams)
ServerListOption narrows a server walk.
func SearchServers ¶
func SearchServers(q string) ServerListOption
SearchServers matches a name, IP address or image name, partially.
func ServersInProject ¶
func ServersInProject(id string) ServerListOption
ServersInProject narrows to one project, or to the servers in none of them with the literal "none".
func ServersInState ¶
func ServersInState(bucket fbapi.ServersListParamsState) ServerListOption
ServersInState narrows to `running`, `stopped` or `other`. It is a BUCKET, not a state value: `other` is everything that is neither of the first two, which is why it cannot take an `error_provisioning`.
func ServersWithTags ¶
func ServersWithTags(tags ...string) ServerListOption
ServersWithTags narrows to servers carrying EVERY tag given.
type StateError ¶
type StateError struct {
Kind string // "server", "volume", ...
ID string
State string
// Code is the resource's own error_code when it carries one. A server has
// it; not every resource does.
Code string
}
StateError is returned by the waiters when a resource settles into a failure rather than into success. It is NOT an APIError: every request involved succeeded, and the thing that failed is the work.
func (*StateError) Error ¶
func (e *StateError) Error() string
type TimeoutError ¶
TimeoutError is returned when a waiter's budget ran out. It reports the LAST state seen rather than only "timed out", because those are two different conversations: a server still in `provisioning` after ten minutes is a platform question, one that reached `stopped` is the caller's own.
func (*TimeoutError) Error ¶
func (e *TimeoutError) Error() string
type VolumeListOption ¶
type VolumeListOption func(*fbapi.VolumeListParams)
VolumeListOption narrows a volume walk.
func VolumesInProject ¶
func VolumesInProject(id string) VolumeListOption
VolumesInProject narrows to one project, or to the volumes in none of them with the literal "none".
func VolumesOnServer ¶
func VolumesOnServer(id string) VolumeListOption
VolumesOnServer narrows to the volumes attached to one server.
func VolumesWithTags ¶
func VolumesWithTags(tags ...string) VolumeListOption
VolumesWithTags narrows to volumes carrying EVERY tag given.
type WaitOption ¶
type WaitOption func(*WaitOptions)
WaitOption is the functional form, for callers who want one knob.
func WithProgress ¶
func WithProgress(f func(state string)) WaitOption
WithProgress reports every observed state.
type WaitOptions ¶
type WaitOptions struct {
// Timeout is the total budget. Zero means DefaultWaitTimeout.
Timeout time.Duration
// Interval is the first poll delay; it doubles up to MaxInterval. Zero
// means the schedule the customer documentation prescribes (1s to 15s).
Interval time.Duration
MaxInterval time.Duration
// OnState, when set, is called with every state observed, including
// repeats. It exists so a CLI can render progress and a Terraform provider
// can log one line per transition without either of them re-polling.
OnState func(state string)
}
WaitOptions bounds a wait.
type ZoneListOption ¶
type ZoneListOption func(*fbapi.DnsZonesListParams)
ZoneListOption narrows a DNS-zone walk.
func ZonesInProject ¶
func ZonesInProject(id string) ZoneListOption
ZonesInProject narrows to one project, or to "none".
func ZonesWithTags ¶
func ZonesWithTags(tags ...string) ZoneListOption
ZonesWithTags narrows to zones carrying EVERY tag given.