Help us shape the future of Go! Take the 2026 Go Developer Survey

state

package
v0.17.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: AGPL-3.0 Imports: 47 Imported by: 0

Documentation

Overview

Package state holds sqlc-generated queries and repository types.

This store is the sole record of how every app runs (R-020), which sets the bar: every mutation is audited, every object is exportable, nothing important lives only in memory. Several requirements are enforced by database constraints rather than by code — see the package CLAUDE.md before changing the schema.

Index

Constants

View Source
const (
	StateDraft     = "draft"
	StateProposed  = "proposed"
	StateDeploying = "deploying"
	StateRunning   = "running"
	StateDegraded  = "degraded"
	StateStopped   = "stopped"
	StateFailed    = "failed"
	StateArchived  = "archived"
)

App lifecycle states (design 05 §1.1).

View Source
const (
	AttemptTaken   = "taken"
	AttemptSkipped = "skipped"
	AttemptFailed  = "failed"
)

Backup attempt outcomes.

View Source
const (
	HostRoleControl = "control"
	HostRoleApp     = "app"
)

Host roles (R-414).

View Source
const (
	DeployPending    = "pending"
	DeployBuilding   = "building"
	DeployApplying   = "applying"
	DeploySucceeded  = "succeeded"
	DeployFailed     = "failed"
	DeploySuperseded = "superseded"

	// Deploy approval (R-154 – R-159). A deploy that needs approval is a
	// deployment that waits in awaiting_approval, tied to one spec revision
	// (R-156), and moves to pending — the ordinary path — once it has its
	// approvals. Rejected and expired are where a request that never ran
	// ends. None of the three is in flight: a request waiting for somebody
	// does not stop the app being deployed some other way.
	DeployAwaitingApproval = "awaiting_approval"
	DeployRejected         = "rejected"
	DeployExpired          = "expired"
)

Deployment statuses.

View Source
const (
	DecisionApprove = "approve"
	DecisionReject  = "reject"
)

Decisions on a deploy that needs approval.

View Source
const (
	TriggerManual   = "manual"
	TriggerRollback = "rollback"
)

Deployment triggers.

View Source
const (
	DetectionRunning      = "running"
	DetectionReady        = "ready"
	DetectionNeedsAnswers = "needs_answers"
	DetectionUnknown      = "unknown"
	DetectionBlocked      = "blocked"
	DetectionFailed       = "failed"
)

Detection statuses. The first is Pando's; the rest come from the auction.

View Source
const (
	DestinationWebhook = "webhook"
	DestinationNotify  = "notify"
)

Subscription destinations.

View Source
const (
	DeliveryPending   = "pending"
	DeliverySucceeded = "succeeded"
	DeliveryFailed    = "failed"
)

Delivery statuses.

View Source
const (
	DefaultPageSize = 100
	MaxPageSize     = 500
)

Page sizes. The default is a screenful; the maximum bounds what one request may read, whatever it asks for.

View Source
const (
	HeartbeatInterval = 10 * time.Second
	ReplicaStale      = 45 * time.Second
)

HeartbeatInterval is how often a replica says it is alive, and ReplicaStale how long it may go without saying so before it is taken to have stopped.

Stale is several heartbeats, so one slow query is not a death; and short enough that a deploy left by a lost pod is recorded as interrupted within a minute rather than blocking its app's next deploy.

View Source
const (
	FlowSignIn = "sign_in"
	FlowTest   = "test"
)

The two things a flow is for.

View Source
const (
	TokenDelegated = "delegated"
	TokenAccount   = "account"
)

Token kinds (R-058, R-060).

View Source
const AppOwnersChannel = "pando_app_owners"

AppOwnersChannel is the channel migration 79's triggers notify when who owns an app may have changed.

View Source
const AppRole = "pando_app"

AppRole is the Postgres role Pando serves traffic as.

It is deliberately not the role that owns the schema, and the reason is more specific than it first appears. REVOKE does work against a table's owner — after REVOKE UPDATE, has_table_privilege reports false even for the owner. But an owner holds grant option implicitly, so it can hand the privilege straight back to itself:

GRANT UPDATE ON audit_events TO pando_app;   -- succeeds, run as pando_app
UPDATE audit_events SET action = 'something else';

Against a role that owns the table, the REVOKE is therefore a speed bump and not a boundary: one statement undoes it, and that statement is available to exactly the process an attacker would be running inside. Ownership is the property that has to be denied, not the privilege.

So the owner creates and migrates; this role reads and writes, owns nothing, and cannot grant itself anything (R-027).

View Source
const ArchiverRole = "pando_audit_archiver"

ArchiverRole is the Postgres role audit retention runs as (R-348).

Retention removes months of audit events, which the application role must never be able to do (R-027). So it is a third role: it owns nothing, reads the log, records archives, and may call the two functions that make and drop a month's partition. Those functions run as the owner and refuse, in the database, a month younger than the floor or one without an archive of every row — so this role cannot remove recent history either, and nothing else can remove any.

View Source
const FirstAccountLock int64 = 46046

FirstAccountLock serializes making the install's first account, whether a person submits the setup form (Users.ClaimFirst) or a starting replica makes it from PANDO_ADMIN_PASSWORD (bootstrap.Run). Session and transaction advisory locks on one key exclude each other, so both paths share it.

View Source
const LocalAdapterID = "idp_local"

LocalAdapterID is the identity adapter seeded on a fresh install (R-041).

View Source
const MaxAttempts = 3

MaxAttempts is how many times a deploy or a detection is started before a replica stopping under it is taken as the reason, rather than bad luck [P]. A build that takes its replica down with it (out of memory, say) would otherwise move from replica to replica until every one had fallen over.

View Source
const PendingJoinLifetime = 10 * time.Minute

PendingJoinLifetime is how long a host's join stays pending before it is forgotten (R-415 [P]): the joining host confirms it as soon as it has written what it was given (hosts.Joiner), so a join still pending this long failed on the joining side.

View Source
const PendingStale = 30 * time.Second

PendingStale is how old a pending attempt is before it counts as a failure: its check never settled it, as when a replica stopped mid-check.

View Source
const PendingWait = 10 * time.Second

PendingWait is how long Reserve waits for attempts still being checked when only they stand between an attempt and the limit, before refusing it.

View Source
const SigningKeyField = "signing_key"

SigningKeyField is the field a webhook's signing key is kept under.

View Source
const SnapshotSuffix = "_pre_upgrade"

SnapshotSuffix names the copy beside the database it copies.

View Source
const SubscriptionsChannel = "pando_subscriptions"

SubscriptionsChannel is the channel a change to what routing reads about a subscription is announced on, by trigger (migration 61): one made, removed, turned on or off, or given other events or another app.

View Source
const SuspendedByAdmin = "admin"

Who suspended an account (users.suspended_by).

View Source
const TotalCap = 10000

TotalCap is how far a list's total is counted exactly (O-53). Counting every match costs a read of every match, which at 100,000 accounts is the whole table on every page; a count that stops past the cap costs at most the cap. A list holding more reports TotalCap+1, which the API sends as TotalCap with `total_is_lower_bound` ("10,000+").

View Source
const UnlockLifetime = 24 * time.Hour

UnlockLifetime is how long an entered passcode lets a browser in before it is asked again. [P] A day: long enough that nobody re-enters it through a working session, short enough that a borrowed laptop stops working.

View Source
const UnusedOAuthClientLifetime = 24 * time.Hour

UnusedOAuthClientLifetime is how long a registered MCP client nobody has approved is kept [P]: long enough for anyone who registers one to get to the approval page, short enough that open registration grows nothing.

Variables

View Source
var ErrAlreadySetUp = errs.New(errs.ValidInvalid, "This installation is already set up.").
	WithRemedy("Sign in with an existing account. An administrator can create one for you.")

ErrAlreadySetUp is ClaimFirst's refusal once an installation has an account.

View Source
var ErrBadCursor = errs.New(errs.ValidInvalid,
	"The cursor must be the next_cursor value from a previous page of the same list.").
	WithRemedy("Leave the cursor out to start from the first page.")

ErrBadCursor refuses a cursor that did not come from a previous page.

View Source
var ErrBuiltinRole = errs.New(errs.ValidInvalid, "Pando's built-in roles cannot be changed.").
	WithRemedy("Make a custom role with the permissions you want, and grant that.")

ErrBuiltinRole refuses a change to one of Pando's own roles (R-081). The trigger refuses it too; this says so in a sentence.

View Source
var ErrIdentityTaken = errs.New(errs.ValidInvalid,
	"That identity already signs in to a different account.").
	WithRemedy("To move it here, link it again with replace_account set. The other account is kept, " +
		"suspended, as an alias of this one — never deleted, because apps may hold data under its ID.")

ErrIdentityTaken is Link's refusal when the identity already reaches a different account and the caller did not ask to move it.

View Source
var ErrInstallHost = errs.New(errs.StateInvalid, "This is the installation's own first host, which cannot be removed from the cluster. Every installation is a cluster of at least this host.").
	WithRemedy("Remove the other hosts instead. To retire this machine, move the installation to another machine from a backup.")

ErrInstallHost refuses removing the install's own first host (R-414): it is recorded again at its next start under a new ID, which would leave its apps' homes and its history naming a host that no longer exists.

View Source
var ErrSCIMConflict = errs.New(errs.ValidInvalid, "A resource with that name already exists for this provider.")

ErrSCIMConflict is a SCIM uniqueness conflict (RFC 7644 §3.3).

View Source
var ErrSetupTokenWrong = errs.New(errs.AuthInvalid, "That setup token is not the one this installation is waiting for.").
	WithRemedy("Pando printed the setup token to its log when it first started, as setup_token on the line " +
		"\"this installation is not set up yet\". With Docker Compose: docker compose logs pando | grep setup_token. " +
		"If the log no longer has it, run pando admin setup-token where Pando runs for a new one " +
		"(with Docker Compose: docker compose exec pando pando admin setup-token).")

ErrSetupTokenWrong is ClaimFirst's refusal of a missing or mistaken token.

Functions

func CanCopyDatabase

func CanCopyDatabase(ctx context.Context, ownerURL string) error

CanCopyDatabase reports whether the account in ownerURL may copy Pando's database. The bundled Compose file's account is a superuser; an external database's may not be (design 00 §1.1).

func ClusterJoinAddrKey added in v0.17.0

func ClusterJoinAddrKey(addr string) string

ClusterJoinAddrKey is the key refused joins from a client address are counted under, as wrong passwords are (R-404 applied to R-415).

func ConnectArchiver

func ConnectArchiver(ctx context.Context, ownerURL string, password secret.Value) (*pgxpool.Pool, error)

ConnectArchiver opens a small pool held as ArchiverRole (R-348).

Two connections: retention is one loop doing one month at a time.

func DefaultSCIMIdentityAttribute

func DefaultSCIMIdentityAttribute(kind string) string

DefaultSCIMIdentityAttribute is which SCIM attribute a provider of this kind signs people in with, when the administrator has not said.

func DropSnapshot

func DropSnapshot(ctx context.Context, ownerURL string) error

DropSnapshot removes the copy, once the new version has been healthy long enough (R-359). Removing one that does not exist is not an error.

func HeaderField

func HeaderField(name string) string

HeaderField is the field a custom header's value is kept under.

func Migrate

func Migrate(ctx context.Context, ownerURL string) error

Migrate applies pending migrations as the owning role.

func ProviderIDTaken added in v0.10.0

func ProviderIDTaken(providerID string) error

ProviderIDTaken refuses a new provider an ID another already has. A create sent twice, its first answer lost, lands here too, and the provider it added is the one wanted: its addresses are already registered upstream.

func Regrant

func Regrant(ctx context.Context, ownerURL string) error

Regrant applies the grant policy again and verifies it, as a start does.

For after a DR restore (R-212): pg_restore recreates every table, partition and function, and a recreated table arrives with the owner's default privileges — which hand the application role UPDATE and DELETE — until the next start re-applies the policy. This closes that window instead.

func RestoreSnapshot

func RestoreSnapshot(ctx context.Context, ownerURL string) error

RestoreSnapshot replaces Pando's database with the copy and removes the copy. Anything still connected to the migrated database is disconnected.

func SignInAddrKey

func SignInAddrKey(addr string) string

SignInAddrKey is the key wrong passwords from a client address are counted under (R-404).

func SignInCodeKey added in v0.9.0

func SignInCodeKey(userID string) string

SignInCodeKey is the key wrong two-factor codes for an account are counted under (R-405). By account ID: the password was right, so it is known.

func SignInUsernameKey

func SignInUsernameKey(username string) string

SignInUsernameKey is the key wrong passwords for username are counted under (R-404): as typed, whether or not the account exists.

func Snapshot

func Snapshot(ctx context.Context, ownerURL string) error

Snapshot copies Pando's database to SnapshotName, replacing an older copy. Pando must be stopped: a database cannot be copied while anything is connected to it, so this retries briefly while the last connections close.

func SnapshotName

func SnapshotName(ownerURL string) (string, error)

SnapshotName is the copy's name for the database ownerURL names.

func SuspendedBySCIM

func SuspendedBySCIM(providerID string) string

SuspendedBySCIM is the suspended_by value for a SCIM client acting for a provider.

Types

type AIAssignment

type AIAssignment struct {
	Function  string    `json:"function"`
	AdapterID string    `json:"adapter_id"`
	Model     string    `json:"model,omitempty"`
	UpdatedBy string    `json:"updated_by"`
	UpdatedAt time.Time `json:"updated_at"`
}

AIAssignment is one stored assignment of an AI function to an adapter (R-259).

type AIAssignments

type AIAssignments struct {
	// contains filtered or unexported fields
}

AIAssignments reads and writes ai_assignments.

func NewAIAssignments

func NewAIAssignments(db *DB) *AIAssignments

func (*AIAssignments) Delete

func (s *AIAssignments) Delete(ctx context.Context, function string) error

Delete removes a function's assignment, turning the function off. Removing one that is not assigned is not an error: the function is off either way.

func (*AIAssignments) Get

func (s *AIAssignments) Get(ctx context.Context, function string) (AIAssignment, bool, error)

Get returns one function's stored assignment.

func (*AIAssignments) List

func (s *AIAssignments) List(ctx context.Context) ([]AIAssignment, error)

List returns every stored assignment, by function.

func (*AIAssignments) Put

Put assigns a function to an adapter, or changes the model of an existing assignment to the same adapter.

A function another adapter already handles is refused, and the refusal is the database's: the primary key on function admits one row, and the conflict clause updates that row only when it names the same adapter. No row back means a different adapter holds it, which is ErrAssigned — never a silent move from one adapter to another.

type AcmeAccount

type AcmeAccount struct {
	Email        string
	Directory    string
	KeyPEM       secret.Value
	Registration json.RawMessage
}

AcmeAccount is the ACME account the edge's certificates are ordered with.

type Activity

type Activity struct {
	// contains filtered or unexported fields
}

Activity is the store half of idle apps.

func NewActivity

func NewActivity(db *DB) *Activity

func (*Activity) IdleCandidates

func (a *Activity) IdleCandidates(ctx context.Context, d IdleDefaults, now time.Time) ([]IdleApp, error)

IdleCandidates lists the apps the idle pass could act on now: those idle long enough to be owed a notice for something in force, and those holding a notice, which may be stale. Every other app would be read and left alone.

func (*Activity) IdleStatus

func (a *Activity) IdleStatus(ctx context.Context, appID string) (IdleStatus, error)

IdleStatus reads an app's idle status.

func (*Activity) RecordActivity

func (a *Activity) RecordActivity(ctx context.Context, seen map[string]time.Time) error

RecordActivity writes when each app was last used, one statement for the batch. GREATEST, because two replicas flush independently and an older flush landing second must not move an app's clock backwards.

func (*Activity) SetIdleNotice

func (a *Activity) SetIdleNotice(ctx context.Context, appID string, which IdleNotice, at *time.Time) error

SetIdleNotice records when the owner was told, or clears it with nil.

func (*Activity) SetIdleSettings

func (a *Activity) SetIdleSettings(ctx context.Context, appID string, s IdleSettings) error

SetIdleSettings replaces an app's own settings. Any notice already given is withdrawn, because it was given under the old settings; the next pass gives a new one if the new settings call for it.

func (*Activity) StopForIdle

func (a *Activity) StopForIdle(ctx context.Context, appID string) (bool, error)

StopForIdle stops an app and records why, in one write: the proxy cache is emptied by the desired state changing, and the page a visitor sees must say why in the same moment (R-396). Only an app that is meant to be running, so an app its owner stopped since the pass read it is left as they left it.

type AdapterConfig

type AdapterConfig struct {
	ID        string          `json:"id"`
	Category  string          `json:"category"`
	Kind      string          `json:"kind"`
	Name      string          `json:"name"`
	Config    json.RawMessage `json:"-"`
	IsDefault bool            `json:"is_default"`
	Enabled   bool            `json:"enabled"`

	// UpdatedAt is when the configuration last changed. Adapters are loaded
	// at startup (R-253), so one changed after Pando started is not what is
	// running.
	UpdatedAt time.Time `json:"-"`
}

AdapterConfig is a configured adapter instance.

type AdapterCredentials

type AdapterCredentials struct {
	// contains filtered or unexported fields
}

AdapterCredentials stores the credentials an adapter needs, encrypted (R-190, R-194; O-20).

The same arrangement as Secrets, one scope up: the row holds ciphertext or an external reference that the install's secrets adapter produced, and there is no plaintext column. Core never encrypts. adapter_configs.config stays what it always was — non-secret settings — and the database refuses a `credentials` key in it, so this is the only place a credential can be kept.

func NewAdapterCredentials

func NewAdapterCredentials(db *DB, adapter api.SecretsAdapter, adapterRef string) *AdapterCredentials

func NewIdentityCredentials

func NewIdentityCredentials(db *DB, adapter api.SecretsAdapter, adapterRef string) *AdapterCredentials

NewIdentityCredentials stores identity providers' secrets — an OIDC client secret — the same way, in their own table (identity_adapters is not adapter_configs). A different scope in the sealed reference as well, so a provider's ciphertext cannot be replayed as an adapter's even if two IDs matched.

func NewNotifyAuthorizations added in v0.11.0

func NewNotifyAuthorizations(db *DB, adapter api.SecretsAdapter, adapterRef string) *AdapterCredentials

NewNotifyAuthorizations stores the connection of a notification adapter while it is in progress: what the adapter needs back at the callback (the Slack app it created, and the app's client secret), and the state the callback presents. Sealed like a credential and under its own scope.

func NewRegistryCredentials

func NewRegistryCredentials(db *DB, adapter api.SecretsAdapter, adapterRef string) *AdapterCredentials

NewRegistryCredentials stores the credential an app's image is pulled with (issue #41), keyed by the app. Its own table and scope rather than the app's secrets: an app secret can be named by an env entry and so reach the app, and the credential that pulls the app's image is never the app's to read. App-owned (O-30). A source credential is not: since issue #127 a source connection is the install's (O-3), stored in adapter_credentials.

func NewSourceAuthorizations

func NewSourceAuthorizations(db *DB, adapter api.SecretsAdapter, adapterRef string) *AdapterCredentials

NewSourceAuthorizations stores an OAuth authorization of a source connection while it is in progress (issue #127): the device code or PKCE verifier, and the state a web callback presents. Sealed like a credential and under its own scope, so a pending flow cannot be replayed as the connection's token.

func (*AdapterCredentials) Delete

func (c *AdapterCredentials) Delete(ctx context.Context, adapterID, field string) error

Delete removes one credential.

func (*AdapterCredentials) Fields

func (c *AdapterCredentials) Fields(ctx context.Context) (map[string][]string, error)

Fields names the credentials each adapter has, never their values. It is what GET /adapters reports, so the console can say "an API key is set" without anything being able to read it back.

func (*AdapterCredentials) Put

func (c *AdapterCredentials) Put(ctx context.Context, adapterID, field string, v secret.Value) error

Put stores or rotates one credential. An empty value removes it.

func (*AdapterCredentials) Resolve

func (c *AdapterCredentials) Resolve(ctx context.Context, adapterID string) (map[string]secret.Value, error)

Resolve decrypts one adapter's credentials, for Configure at startup.

The only place they leave storage. The values stay secret.Value until the adapter reads them, so none of them can be logged on the way.

type Adapters

type Adapters struct {
	// contains filtered or unexported fields
}

Adapters reads configured adapter instances.

func NewAdapters

func NewAdapters(db *DB) *Adapters

func (*Adapters) DeleteInCategory

func (a *Adapters) DeleteInCategory(ctx context.Context, id, category string) error

DeleteInCategory removes an adapter configuration of one category, with its credentials (ON DELETE CASCADE). The category is part of the condition so a route for one kind of adapter cannot remove another kind's — disconnecting a source cannot remove the runtime.

func (*Adapters) List

func (a *Adapters) List(ctx context.Context) ([]AdapterConfig, error)

List returns configured adapters.

Config is loaded but never serialized: an adapter's configuration can hold credentials, and GET /adapters is a list of what exists, not a dump of how it authenticates.

func (*Adapters) Save added in v0.6.0

func (a *Adapters) Save(ctx context.Context, c AdapterConfig) (Replaced, error)

Save stores an adapter configuration, and returns the adapter it replaced as its category's default, if any.

Saving one as the default is a handover (issue #220): the previous default is cleared and the new one set in one transaction, so there is never a moment with two defaults or none. The category's default is locked for the transaction, so two saves racing to be the default hand over one after the other rather than both clearing the same row.

func (*Adapters) Upsert

func (a *Adapters) Upsert(ctx context.Context, c AdapterConfig) error

Upsert stores an adapter configuration. See Save.

type Allocations

type Allocations struct {
	// contains filtered or unexported fields
}

Allocations reports what apps already hold on a runtime adapter (R-242).

func NewAllocations

func NewAllocations(db *DB) *Allocations

func (*Allocations) AllocatedOn

func (a *Allocations) AllocatedOn(ctx context.Context, adapterRef, excludeAppID string) (planner.Allocation, error)

AllocatedOn sums the resources of apps pinned to a runtime adapter.

Excludes the app being planned: replanning must not count an app's own current allocation against itself, which would make any app that already fits look like it no longer does.

Only apps that are actually running or deploying count. A stopped app holds no CPU or memory, and counting it would refuse deploys to make room for something that is not there.

Read live on every call, never from a cache: this is the plan-time check of R-242, and a stale sum is a deploy let through onto a full host. What each app reserves is copied from its pinned revision onto the app by a trigger when the pin moves (migration 60), so the sum is an index-only scan of apps_allocation_idx rather than a JSON walk of every running app's revision (issue #72). Which apps count is still decided here, from state.

A deploy in flight counts too, from the moment it is created: what its revision asks for is reserved on its runtime until it ends (migration 62), so a deploy not yet pinned is not room for another. An app counts once, at the larger of what its pin holds and what its deploy reserves, resource by resource: a redeploy of a running app is not two apps.

func (*Allocations) Hold

func (a *Allocations) Hold(ctx context.Context, runtimeRef string, fn func(context.Context) error) error

Hold runs fn with the runtime's capacity to itself (R-242): no other Hold on the same runtime, on any replica, runs until fn returns. A deploy's plan check and its creation run inside one, so the second of two deploys racing for the last room is planned against the first one's reservation rather than beside it.

A transaction-scoped advisory lock, released when the transaction ends and with it if the connection is lost, so a lock cannot outlive the call that took it. On a connection of its own, outside the pool: fn does its work on pooled connections, and callers waiting for the lock while each holding a pooled one could leave the holder none to finish with.

type AnonymousGrant

type AnonymousGrant struct {
	ID           string
	PasscodeHash string
}

AnonymousGrant is an app's grant to everyone, with its passcode digest if it has one.

type App

type App struct {
	ID           string `json:"id"`
	Name         string `json:"name"`
	Slug         string `json:"slug"`
	OwnerUserID  string `json:"owner_user_id"`
	State        string `json:"state"`
	DesiredState string `json:"desired_state"`
	PinnedSpecID string `json:"pinned_spec_id,omitempty"`

	// StoppedForIdle says Pando stopped this app because nobody had used it
	// (R-396), so a stopped app is not taken for a failed one, or for one its
	// owner stopped. Starting it clears this (migration 69).
	StoppedForIdle bool `json:"stopped_for_idle,omitempty"`

	// StoppedForDisk says Pando stopped this app because it stayed over its
	// disk limit (R-403). Starting it clears this (migration 71).
	StoppedForDisk bool `json:"stopped_for_disk,omitempty"`

	// StoppedForOrphan says Pando stopped this app because none of its owners
	// was active any more (R-408). Starting it clears this (migration 79).
	StoppedForOrphan bool `json:"stopped_for_orphan,omitempty"`

	// Orphaned says none of the app's owners is active: every account that
	// owns it is suspended or deleted and no group owns it (R-407).
	Orphaned bool `json:"orphaned,omitempty"`

	// Source is where the app comes from, recorded at creation so detection has
	// something to clone before any spec exists to carry it.
	Source spec.Source `json:"source"`

	CreatedAt time.Time  `json:"created_at"`
	UpdatedAt time.Time  `json:"updated_at"`
	DeletedAt *time.Time `json:"deleted_at,omitempty"`

	// Routing is the pinned spec's routing block, so a caller can say where
	// the app is without fetching a whole spec revision to find out.
	//
	// Zero until an app has a pinned spec: before that it has no address at
	// all, which is a different thing from having one nobody can reach.
	Routing spec.Routing `json:"routing,omitzero"`

	// Address is where to open this app, rendered for the request that asked.
	// Filled in by the API layer, which is the only place that knows what host
	// the caller reached Pando on. See spec.Address.
	Address string `json:"address,omitempty"`

	// SecurityScore is the newest score of the revision this app is running
	// (R-310). Nil for an app that has never been scanned, which is not a score
	// of zero (R-318) — the list shows the difference.
	//
	// Which of the two stored numbers this is — every finding, or only the ones
	// with a fix — is host policy's choice, applied by the API layer.
	SecurityScore *int `json:"security_score,omitempty"`

	// SecurityScoreFixable is the same scan counting only fixable findings, so
	// the API layer can answer either question without a second query.
	SecurityScoreFixable *int `json:"-"`

	// SecurityVerdict is that score placed against host policy: ok, insecure,
	// unscanned or inert. Filled in by the API layer, because it depends on a
	// policy document and on whether a scanner is configured, and neither is
	// the store's to know.
	SecurityVerdict string `json:"security_verdict,omitempty"`

	// SecurityScanning says a scan of this app is running now, whoever started
	// it. Filled in by the API layer from the security service, which is the
	// one place that knows (R-310, R-261).
	SecurityScanning bool `json:"security_scanning,omitempty"`

	// SecurityAwaitingImage says the app's only scan read its source alone,
	// before there was a revision: detection's, of a repository Pando builds.
	// That number is the source's and not the app's (R-311), so
	// SecurityScore is empty until a scan reads the image the app runs. The
	// source scan's score and findings are at GET /apps/{id}/security.
	SecurityAwaitingImage bool `json:"security_awaiting_image,omitempty"`

	// IconUpdatedAt is when the app's tile image last changed, and nil when it
	// has none (R-340). The time rather than a flag so a client can put it in
	// the image's URL and a browser never shows yesterday's picture from cache.
	// The bytes are at GET /apps/{id}/icon; no list carries them.
	IconUpdatedAt *time.Time `json:"icon_updated_at,omitempty"`

	// Favorite is whether the caller has marked this app as a favorite
	// (R-341). Only GET /me/apps fills it in: it is a fact about the person
	// asking, and no other list is answering a question about them.
	Favorite bool `json:"favorite,omitempty"`

	// SectionID is the caller's launcher section this app is filed under, and
	// empty when it is under "Your apps" (R-342). Like Favorite, only GET
	// /me/apps fills it in.
	SectionID string `json:"section_id,omitempty"`

	// CanManage, on the launcher list (GET /me/apps), is whether the caller can
	// also administer the app: whether GET /apps lists it for them. The
	// control plane's answer beside the data plane's, so the launcher offers
	// "Manage" without asking about each tile (R-070, R-071).
	CanManage bool `json:"can_manage,omitempty"`

	// OwnerName is the owner's display name, falling back to the username
	// they sign in with, as the app's sharing list names them. Only the
	// admin list (GET /apps) fills it in (discussion #262).
	OwnerName string `json:"owner_name,omitempty"`

	// Access is who can open the app: "private" for its owner alone,
	// "shared" for its owner and people or groups it is shared with by name,
	// "public" for anyone on the internet, "passcode" for anyone with the
	// passcode — whether the app has a grant to anonymous (R-075, R-075a),
	// and failing that whether it has a data grant to anyone but its owner.
	// Only the admin list (GET /apps) fills it in, from the same query as
	// the rows (discussion #262); one app's grants are at GET
	// /apps/{id}/grants.
	Access string `json:"access,omitempty"`

	// NoticeCount is how many of the app's notices the caller has not
	// ignored (R-412): what its Overview asks of them, counted. Only the
	// admin list (GET /apps) fills it in; the notices themselves are at GET
	// /apps/{id}/notices.
	NoticeCount int `json:"notice_count,omitempty"`

	// Detection is where the app's detection has got to — running and at
	// which stage, or how it finished — so a draft says why it is still a
	// draft (issue #80). Filled in by the API layer on GET /apps and GET
	// /apps/{id}; nil for an app that has never been through detection.
	Detection *DetectionSummary `json:"detection,omitempty"`
}

App is a deployable unit.

type AppFate added in v0.17.0

type AppFate struct {
	Deleted        bool
	DiscardStorage bool
}

AppFate is whether an app is deleted, and whether its delete discarded its storage (R-204).

type AppLimitReached

type AppLimitReached struct {
	Owned int
	Limit int
}

AppLimitReached is CreateWithin's refusal: the owner already owns Limit apps or more (R-244).

func (*AppLimitReached) Error

func (e *AppLimitReached) Error() string

type AppLimits

type AppLimits struct {
	// contains filtered or unexported fields
}

AppLimits reads and writes users' and groups' own limits.

func NewAppLimits

func NewAppLimits(db *DB) *AppLimits

func (*AppLimits) Facts

func (l *AppLimits) Facts(ctx context.Context, userID string) (LimitFacts, error)

Facts reads a person's limit facts.

func (*AppLimits) GroupMaxApps

func (l *AppLimits) GroupMaxApps(ctx context.Context, groupID string) (*int, error)

GroupMaxApps reads a group's own limit, nil when not set.

func (*AppLimits) SetGroupMaxApps

func (l *AppLimits) SetGroupMaxApps(ctx context.Context, groupID string, maxApps *int) error

SetGroupMaxApps sets a group's own limit, or clears it with nil.

func (*AppLimits) SetUserMaxApps

func (l *AppLimits) SetUserMaxApps(ctx context.Context, userID string, maxApps *int) error

SetUserMaxApps sets a user's own limit, or clears it with nil.

type AppWithStorage

type AppWithStorage struct {
	AppID string
	Spec  []byte

	// Retain is how many daily copies to keep (R-211).
	Retain int
}

AppWithStorage is an app that has data worth backing up (R-210).

type ApprovalDecision

type ApprovalDecision struct {
	PrincipalID   string    `json:"principal_id"`
	PrincipalName string    `json:"principal_name"`
	Decision      string    `json:"decision"`
	Comment       string    `json:"comment,omitempty"`
	DecidedAt     time.Time `json:"decided_at"`
}

ApprovalDecision is one person's answer to a deploy that needs approval.

type ApprovalReason

type ApprovalReason struct {
	Reason  string `json:"reason"`
	Message string `json:"message"`
}

ApprovalReason is why a deploy needed approval. The store holds the reason; the approval service, which owns the wording, fills in Message.

type Apps

type Apps struct {

	// ReservedHostnames lists the hostnames no app may be reached at, because
	// Pando answers on them itself: its external URL's host and a routing
	// adapter's console hostname.
	// An app given one would receive the console's traffic on the console's
	// origin (2026-10-16 assessment, finding 2). Nil reserves none.
	ReservedHostnames func(ctx context.Context) []string

	// PortRangeStart and PortRangeEnd are the install's port range, which a
	// port a spec names must be in to be claimed when it is pinned: outside
	// it, nothing publishes the port (design 03 §4.2). Zero checks no range.
	PortRangeStart, PortRangeEnd int
	// contains filtered or unexported fields
}

func NewApps

func NewApps(db *DB) *Apps

func (*Apps) Archive

func (a *Apps) Archive(ctx context.Context, appID string) error

func (*Apps) AwaitingTeardown

func (a *Apps) AwaitingTeardown(ctx context.Context, limit int) ([]TeardownTarget, error)

AwaitingTeardown returns archived apps whose bundle is still standing.

The leak this closes: nothing ever called Destroy, so a deleted app's containers and its private network stayed up. Docker's default pool holds about thirty networks and Pando takes one per app, so an install that adds and removes apps eventually cannot start one.

Returns the routing adapter ref too, because the route outlives the app the same way — a Traefik file per deleted app, accumulating.

An app deleted before anything was accepted has no revision, and may still have an image: detection builds what an app would deploy (R-311). Its runtime and builder come from the proposal that built it, so the image and the build cache go with the app rather than staying on the host (R-224).

func (*Apps) ByID

func (a *Apps) ByID(ctx context.Context, appID string) (App, bool, error)

ByID returns an app.

func (*Apps) ByPath

func (a *Apps) ByPath(ctx context.Context, requestPath string) (App, *spec.AppSpec, string, bool, error)

ByPath resolves the path-mode app whose path is the longest prefix of the request's path, on whole segments, and returns that path.

Case-sensitive, as a URL's path is: paths are stored lowercase, and the proxy strips exactly the prefix that matched.

The candidates are built here and matched with = ANY, so the lookup is the unique index on address_path rather than a scan comparing the request's path with every app's. An app's path matches when it is the request's path, or the request's path continues past it with a slash — which is exactly when it is one of pathCandidates.

func (*Apps) ByRouting

func (a *Apps) ByRouting(ctx context.Context, by, value string) (App, *spec.AppSpec, bool, error)

ByRouting resolves a running app from how it is addressed.

Used by the proxy on every request, so it reads the pinned spec in the same query rather than making a second round trip per request, and matches on indexed columns of apps rather than on the pinned spec's JSON: the address columns are written with the pin (migrations 35 and 46), so they say what the pinned spec says.

`by` is "hostname", "slug", "id" or "port". "slug" finds a path-mode app only.

func (*Apps) CheckAddress

func (a *Apps) CheckAddress(ctx context.Context, appID string, r spec.Routing) error

CheckAddress reports whether another live app already holds the address r would claim for appID, saying which address and why. nil means it is free.

Asked before a change is saved, so the person choosing hears it then, and again inside Pin, which is where it is decided: the unique indexes refuse an exact duplicate there however two writers race, and this refuses the rest — a path inside or around another app's, or one that takes another app's slug.

func (*Apps) ClearIcon

func (a *Apps) ClearIcon(ctx context.Context, appID string) error

ClearIcon removes an app's tile image. Clearing one that is not there is not an error: the outcome asked for is the outcome that holds.

func (*Apps) Create

func (a *Apps) Create(ctx context.Context, name, slug, ownerUserID, createdBy string, src spec.Source) (App, error)

Create inserts an app and its two grants in one transaction.

R-073: app creation writes two grant rows, one per plane, independently revocable. They are written here rather than by the handler so that an app cannot exist without them — a control grant without a data grant would leave the owner able to manage an app they cannot open.

func (*Apps) CreateRevision

func (a *Apps) CreateRevision(ctx context.Context, appID string, s *spec.AppSpec, origin spec.Origin, createdBy string) (Revision, error)

CreateRevision appends a spec revision.

The revision number is assigned inside the transaction from the current maximum, so two concurrent edits cannot both claim the same number — the UNIQUE (app_id, revision) constraint is the backstop, and this is what keeps it from being hit in normal operation.

func (*Apps) CreateSection

func (a *Apps) CreateSection(ctx context.Context, userID, name string) (Section, error)

CreateSection makes a new section for a person.

func (*Apps) CreateWithin

func (a *Apps) CreateWithin(ctx context.Context, name, slug, ownerUserID, createdBy string, src spec.Source, limit int) (App, error)

CreateWithin is Create, refused with *AppLimitReached when the owner already owns limit apps or more; 0 is no limit (R-244).

Counted in the transaction that inserts, under a lock on the owner's row, so two creates at once cannot both see room for one more.

func (*Apps) DeleteSection

func (a *Apps) DeleteSection(ctx context.Context, userID, sectionID string) (bool, error)

DeleteSection removes one of a person's sections. Its apps go back to "Your apps" by the placements' cascade. False when they have no such section.

func (*Apps) DiscardStorage

func (a *Apps) DiscardStorage(ctx context.Context, appID string) error

DiscardStorage records that a delete settled what becomes of the app's storage — discarded, or backed up first — so the teardown destroys the volumes with the bundle rather than leaving them on disk (R-204, R-224).

func (*Apps) DueForBackup

func (a *Apps) DueForBackup(ctx context.Context, takenSince, triedSince time.Time, limit int) ([]AppWithStorage, error)

DueForBackup lists up to limit apps with storage that are due a rolling backup (R-211): none taken since takenSince, and no attempt of any outcome since triedSince, so a destination that is down is tried again on the retry interval rather than on every pass. The app whose last attempt is oldest comes first, and one never attempted before any.

Due-driven rather than a walk over every app with storage (issue #72): the job asks for what is due and gets a page of it, however many apps there are.

func (*Apps) ForgetVolume

func (a *Apps) ForgetVolume(ctx context.Context, appID, volumeID string) error

ForgetVolume removes a volume row whose storage has been destroyed.

By app and ID: a volume ID is unique only within its app.

func (*Apps) IconOf

func (a *Apps) IconOf(ctx context.Context, appID string) (Icon, bool, error)

IconOf returns an app's tile image, and false when it has none.

func (*Apps) Known

func (a *Apps) Known(ctx context.Context, appID string) (bool, error)

Archive soft-deletes an app.

Soft delete because the audit log references it, and an audit trail pointing at a row that no longer exists answers fewer questions than one that does. Volumes are ON DELETE RESTRICT and must be resolved first (R-204) — the caller handles the keep-or-discard decision. Known reports whether this installation ever created the app, deleted ones included. It is how startup tells this install's app networks from another install's on the same Docker host (issue #55).

func (*Apps) ListAll

func (a *Apps) ListAll(ctx context.Context) ([]App, error)

ListAll returns every app, for a principal whose install role reaches every app with app.view (install.apps.view, R-081). The caller decides that; this only reads.

Unpaginated, for the readers that need every app at once (the access assistant); the API pages with ListAllPage.

func (*Apps) ListAllPage

func (a *Apps) ListAllPage(ctx context.Context, page Page) ([]App, string, int, error)

ListAllPage is one page of ListAll, newest first. Page.Query matches the name or slug.

func (*Apps) ListForPrincipal

func (a *Apps) ListForPrincipal(ctx context.Context, p authz.Principal) ([]App, error)

ListForPrincipal returns apps the principal can see on the control plane.

Scoped by grant rather than returning everything and filtering afterward: a list endpoint that leaks the existence of apps is a smaller problem than one that leaks their contents, but it is still a leak.

Unpaginated; the API pages with ListForPrincipalPage.

func (*Apps) ListForPrincipalPage

func (a *Apps) ListForPrincipalPage(ctx context.Context, p authz.Principal, page Page) ([]App, string, int, error)

ListForPrincipalPage is one page of ListForPrincipal, newest first, with the cursor for the next page and how many apps match in all. Page.Query matches the name or slug.

func (*Apps) ListForUse

func (a *Apps) ListForUse(ctx context.Context, p authz.Principal, page Page, every bool) ([]App, string, error)

ListForUse returns one page of the apps the principal owns, or holds a DATA-plane grant on directly or through a group (R-264), with the cursor for the next page (empty after the last).

A grant to anonymous is not one of them. A public app can be opened by everyone, but being public alone never puts it on anyone's launcher: an app appears there because it was shared with you, not because it was shared with the world (R-264).

Deliberately a different query from ListForPrincipal. Two planes, two endpoints: the launcher shows what you can open, not what you can manage.

A UNION of indexed lookups — owned (apps_owner_user_idx), and a direct user or token grant and a group grant (grants_principal_idx) — rather than a join of every app's data grants under OR'd predicates, which read every grant in the install for each launcher load (issue #72).

Paged, because an app shared with a large group is on every member's launcher, and an install can hold 20,000 of them. Favorites come first, then apps filed in one of the person's sections, then the rest, each by name, so the first page holds what the person arranged. The page is chosen before anything else is read: routing, icons and CanManage are read for one page.

CanManage says whether the principal can also administer each app: what GET /apps would list for them (control-plane grants, or every app when every is set — install.apps.view, which the caller decides). Read here so the launcher need not ask GET /apps about each tile; it is the control plane's answer, reported beside the data plane's, never mixed into it (R-070).

Page.Query matches the name or slug.

func (*Apps) ListRevisions

func (a *Apps) ListRevisions(ctx context.Context, appID string) ([]Revision, error)

ListRevisions returns an app's revisions, newest first.

func (*Apps) Live

func (a *Apps) Live(ctx context.Context, appID string) (bool, error)

Live reports whether an app still exists and has not been deleted. A question worth asking again just before acting on an app read a while ago.

func (*Apps) LiveApps

LiveApps lists every app a candidate policy could block, for design 05 §3's preview.

Pinned revision, not newest: a preview answers "what would break", and what would break is what the app's next deploy would actually try to do. Draft and archived apps are excluded — a draft has never deployed and an archived one is not going to.

The anonymous grant comes along because a policy that forbids it (R-076) has no spec to detect it in, and an app that anyone on the internet can reach under a policy that says they may not is precisely the case an admin needs named before they save.

Only the apps f names (issue #72): the planner works out from the candidate policy which apps any of its checks could fail, and the rest are not read. Whether an app is shared with everyone comes from one pass over the anonymous grants rather than two lookups per app.

func (*Apps) MarkBundleDestroyed

func (a *Apps) MarkBundleDestroyed(ctx context.Context, appID string) error

MarkBundleDestroyed records that the runtime confirmed the bundle is gone.

func (*Apps) OrphanedVolumes

func (a *Apps) OrphanedVolumes(ctx context.Context, limit int) ([]OrphanedVolume, error)

OrphanedVolumes lists storage that is safe to reclaim.

Narrow on purpose. A volume qualifies only when its app is deleted *and* a backup of that app exists — R-204 says volumes outlive the apps that mount them, and this does not weaken that. It stops the storage outliving the last thing that could ever want it, which is a different claim.

A delete made since apps.discard_storage exists removes the volume rows and records the decision on the app instead, and the teardown destroys the volumes with the bundle (Apps.DiscardStorage). This pass covers volumes whose rows a delete left behind before that.

func (*Apps) Pin

func (a *Apps) Pin(ctx context.Context, appID, specID, newState, pinnedBy string) error

Pin points the app at a revision.

Records the pinning as an event in the same transaction, because retention pruning must never remove a revision that was once live (R-152) and that fact has to survive the pointer moving on during a rollback. The event log doubles as the rollback history.

func (*Apps) PlaceApp

func (a *Apps) PlaceApp(ctx context.Context, userID, sectionID, appID string) (bool, error)

PlaceApp puts an app into one of a person's sections, moving it out of any other. False when they have no such section.

func (*Apps) PruneSpecRevisions

func (a *Apps) PruneSpecRevisions(ctx context.Context) (int, error)

PruneSpecRevisions trims revision history to each app's retention setting.

R-152 keeps the last ten pinned specs so a rollback has something to roll back to. Two exemptions, and both matter more than the saving:

  • **A revision that was ever pinned is never pruned.** Rollback is repointing at a revision that provably existed, and spec_pins is the append-only record of which those are. Pruning one would make a rollback target vanish from under a person who is looking at it.
  • **The currently pinned revision is never pruned**, which the first rule already covers, but a deployment also references it by foreign key and would refuse.
  • **A revision a scan describes is never pruned** (R-319). The scan is append-only and names its revision; the foreign key refuses the delete.

Batched (issue #72): a statement reads pruneAppsPerBatch apps, in ID order, and removes at most prunePerApp revisions from each, oldest first, so no statement's cost or lock grows with the install. The pass walks every app; an app with a longer backlog loses the rest on later passes.

Returns how many rows went.

func (*Apps) Rename

func (a *Apps) Rename(ctx context.Context, appID, name string) error

Rename changes an app's display name.

func (*Apps) RenameSection

func (a *Apps) RenameSection(ctx context.Context, userID, sectionID, name string) (Section, bool, error)

RenameSection renames one of a person's sections. False when they have no section with that ID.

func (*Apps) RevisionByID

func (a *Apps) RevisionByID(ctx context.Context, specID string) (Revision, bool, error)

RevisionByID returns one revision.

func (*Apps) RevisionByNumber

func (a *Apps) RevisionByNumber(ctx context.Context, appID string, number int) (Revision, bool, error)

RevisionByNumber returns an app's revision by its number.

func (*Apps) Sections

func (a *Apps) Sections(ctx context.Context, userID string) ([]Section, error)

Sections returns a person's sections, oldest first — the order they made them, which is the order they appear in.

func (*Apps) SetDesiredState

func (a *Apps) SetDesiredState(ctx context.Context, appID, desired string) error

SetDesiredState records what a human asked for.

Separate from state, which is what is true. The reconciler reads both: the gap between them is the work it has to do.

func (*Apps) SetFavorite

func (a *Apps) SetFavorite(ctx context.Context, userID, appID string, favorite bool) error

SetFavorite marks or unmarks an app as one of a user's favorites (R-341). Both directions are idempotent: the outcome asked for is the one that holds.

func (*Apps) SetIcon

func (a *Apps) SetIcon(ctx context.Context, appID, contentType string, data []byte) error

SetIcon replaces an app's tile image. The caller has already checked the bytes are an image of an allowed type; the table's CHECK is the backstop.

func (*Apps) SetSource

func (a *Apps) SetSource(ctx context.Context, appID string, src spec.Source) error

SetSource replaces an app's source.

For `pando deploy ./`, which turns an app into one fed by uploads. Recorded on the app rather than inferred at deploy time: R-020 makes the spec the sole record of how an app runs, and a source nobody wrote down is a deploy nobody can explain afterwards.

func (*Apps) SetState

func (a *Apps) SetState(ctx context.Context, appID, appState string) error

SetState records what is observed to be true.

func (*Apps) SetStateIf

func (a *Apps) SetStateIf(ctx context.Context, appID, from, to string) error

SetStateIf moves an app to `to` only if it is in `from`, so a caller settling its own transition never overwrites one somebody else made since.

func (*Apps) UnplaceApp

func (a *Apps) UnplaceApp(ctx context.Context, userID, sectionID, appID string) error

UnplaceApp takes an app out of a section, back to "Your apps". Taking out an app that is not in it is not an error.

func (*Apps) VolumeCount

func (a *Apps) VolumeCount(ctx context.Context, appID string) (int, error)

VolumeCount returns how many volumes an app holds, for the delete decision.

func (*Apps) WithAutoDeploy

func (a *Apps) WithAutoDeploy(ctx context.Context) ([]App, error)

WithAutoDeploy returns apps that track a ref (R-141).

Off by default, so this is normally empty and the poll costs one indexed query (apps_auto_deploy_idx). Auto-deploy is a property of the spec someone reviewed and pinned: apps.auto_deploy is derived from the pinned revision by a trigger whenever the pin moves, and nothing else can set it (000056), so it cannot be changed without a revision.

func (*Apps) WithStorage

func (a *Apps) WithStorage(ctx context.Context) ([]AppWithStorage, error)

WithStorage lists live apps that declare volumes, for rolling backups.

Only apps with storage. An app with none has nothing a rolling backup would hold that its spec revisions do not already, and taking one anyway would fill the destination with empty bundles nobody wants to page through.

The volumes test is jsonb_typeof and not a coalesce. `AppSpec.Volumes` is tagged without omitempty, so an app that declares no storage stores `"volumes": null` — and `->` on a key holding JSON null answers with that null, not SQL NULL, so the coalesce never fired and jsonb_array_length was handed a scalar. Postgres raises 22023, the reconciler logged "could not list apps for rolling backups", and no app on the installation was backed up (R-210).

A typeof guard beside it is not enough on its own: AND does not promise an evaluation order, and the planner is free to reach jsonb_array_length first — it did. So the emptiness test is a comparison, which is defined for every input this column can hold.

type AttemptLimit added in v0.14.0

type AttemptLimit struct {
	Key    string
	Max    int
	Window time.Duration
}

AttemptLimit is how many failures a key may have within a window before another attempt is refused without being checked.

type AuditCursor

type AuditCursor struct {
	TxID uint64
	ID   int64
}

AuditCursor is a position in the audit stream, as the state store keeps it: the stream's (txid, id) order (design 12 §2).

type AuditSinkState

type AuditSinkState struct {
	AdapterID string `json:"adapter_id"`

	// HasCursor is false until the first delivery. CursorTxID and CursorID are
	// the last event delivered, in the stream's (txid, id) order.
	HasCursor  bool   `json:"-"`
	CursorTxID uint64 `json:"-"`
	CursorID   int64  `json:"-"`

	DeliveredAt    *time.Time `json:"delivered_at,omitempty"`
	CursorAt       *time.Time `json:"cursor_at,omitempty"`
	DeliveredCount int64      `json:"delivered_count"`

	LastError     string     `json:"last_error,omitempty"`
	LastErrorAt   *time.Time `json:"last_error_at,omitempty"`
	FailingSince  *time.Time `json:"failing_since,omitempty"`
	Attempts      int        `json:"attempts"`
	NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`

	DisabledAt     *time.Time `json:"disabled_at,omitempty"`
	DisabledReason string     `json:"disabled_reason,omitempty"`

	GapFrom *time.Time `json:"gap_from,omitempty"`
	GapTo   *time.Time `json:"gap_to,omitempty"`
}

AuditSinkState is where one audit sink has got to (design 12 §5.2).

type AuditSinks

type AuditSinks struct {
	// contains filtered or unexported fields
}

AuditSinks stores audit sink delivery state. It never touches audit_events except to read it (R-027).

func NewAuditSinks

func NewAuditSinks(db *DB) *AuditSinks

func (*AuditSinks) Backlog

func (s *AuditSinks) Backlog(ctx context.Context, st AuditSinkState, limit int) (int, error)

Backlog counts events past a cursor, at most limit: counting a large backlog exactly would cost what the backlog does.

func (*AuditSinks) Delivered

func (s *AuditSinks) Delivered(ctx context.Context, adapterID string, txid uint64, id int64, n int, at time.Time) (bool, error)

Delivered moves a sink's cursor after a successful send and clears its error. It returns whether the sink had been failing, so the recovery is audited once (R-383).

func (*AuditSinks) Disable

func (s *AuditSinks) Disable(ctx context.Context, adapterID, reason string, at time.Time) (bool, error)

Disable turns a failing sink off: its adapter row, so it stops holding archival and the adapters screen shows it off, and its state, so the reason is shown beside it (R-383, R-386). It reports whether this call changed anything, so the caller audits and notifies once. A sink declared in the configuration file has no row to turn off; its state alone records it.

func (*AuditSinks) Failed

func (s *AuditSinks) Failed(ctx context.Context, adapterID, message string, at, next time.Time) (AuditSinkState, error)

Failed records a failed send and when to try again. It returns the state after, so the caller can tell a first failure from a repeated one.

func (*AuditSinks) Get

func (s *AuditSinks) Get(ctx context.Context, adapterID string, start *AuditCursor) (AuditSinkState, error)

Get returns one sink's state, creating it when it has none: positioned at the start of the live log, or at start when given — a sink that starts now (R-382).

func (*AuditSinks) Holds

func (s *AuditSinks) Holds(ctx context.Context, st AuditSinkState, lo, hi time.Time) (bool, error)

Holds reports whether a sink has events in [lo, hi) it has not been sent (R-386). A sink with no cursor yet starts at the oldest event, so it holds every month that has any.

func (*AuditSinks) List

func (s *AuditSinks) List(ctx context.Context) ([]AuditSinkState, error)

List returns every sink's state.

func (*AuditSinks) Resume

func (s *AuditSinks) Resume(ctx context.Context, adapterID string, at time.Time) (AuditSinkState, error)

Resume clears a sink Pando turned off and somebody turned back on, and records the range it missed (R-386): events after its cursor that the archiver removed while it did not hold. The archive covering them is the backfill.

func (*AuditSinks) Skipped

func (s *AuditSinks) Skipped(ctx context.Context, adapterID string, txid uint64, id int64, at time.Time) error

Skipped moves a sink's cursor past events it was not sent — every one a filter dropped — without counting a delivery.

type AuthzStore

type AuthzStore struct {
	// contains filtered or unexported fields
}

AuthzStore implements authz.Store against Postgres.

func NewAuthzStore

func NewAuthzStore(db *DB) *AuthzStore

func (*AuthzStore) AnonymousAccess

func (s *AuthzStore) AnonymousAccess(ctx context.Context, appID string) (granted, passcode bool, err error)

AnonymousAccess reports whether an app is shared with everyone, and whether that sharing asks for a passcode.

func (*AuthzStore) ControlGrantsFor

func (s *AuthzStore) ControlGrantsFor(ctx context.Context, appID string, p authz.Principal) ([]authz.Grant, error)

ControlGrantsFor returns control-plane grants matching the principal.

Group membership is joined live (R-079) rather than read from the principal, so a grant made to a group the caller joined a moment ago is honored without waiting for a cache.

func (*AuthzStore) ControlGrantsForApps

func (s *AuthzStore) ControlGrantsForApps(ctx context.Context, appIDs []string, p authz.Principal) (map[string][]authz.Grant, error)

ControlGrantsForApps is ControlGrantsFor for several apps in one query (authz.BatchStore), with an entry for every app asked about, so a list that shows what the caller may do on each row asks once rather than per row (issue #72).

func (*AuthzStore) DataFacts

func (s *AuthzStore) DataFacts(ctx context.Context, appID string, p authz.Principal, passcodeToken string) (authz.DataFacts, error)

DataFacts reads every fact CheckData decides on, in one query: what IsOwner, HasDataGrant, AnonymousAccess and PasscodeUnlocked answer, by the same conditions. passcodeToken is the unlock this request brought for the app, or "" for none. It decides nothing; authz.CheckData does, in its fixed order, and R-029 holds there: nothing about the control plane is read here beyond the owner of record, R-072's one implication.

func (*AuthzStore) DeployApprovers

func (s *AuthzStore) DeployApprovers(ctx context.Context, appID string) ([]string, error)

DeployApprovers returns the active people who may approve a deploy of the app: holders of install.deploys.approve, and holders of app.deploy.approve on this app, directly or through a group (R-155). For telling them a request is waiting (R-159), not for deciding whether they may: that is the authorizer's, which also applies host policy.

func (*AuthzStore) GroupsForUser

func (s *AuthzStore) GroupsForUser(ctx context.Context, userID string) ([]string, error)

GroupsForUser resolves group membership live (R-079).

func (*AuthzStore) HasDataGrant

func (s *AuthzStore) HasDataGrant(ctx context.Context, appID string, p authz.Principal) (bool, error)

HasDataGrant reports whether the principal may use the app.

func (*AuthzStore) InstallGrantsFor

func (s *AuthzStore) InstallGrantsFor(ctx context.Context, p authz.Principal) ([]authz.Grant, error)

InstallGrantsFor returns the principal's installation-wide grants (O-17).

`app_id IS NULL` is the whole definition of install scope, and the schema guarantees such a row carries an install-scoped role: grants.role_scope is tied to roles.scope by a composite foreign key, and a CHECK ties role_scope to whether app_id is null. So this cannot return a grant carrying app verbs however the row was written.

Group membership counts, as it does per app. Token principals do too — an account token acts for its user (design 06 §2) — which means an install-wide administrator's token is administrative, and that is the point of R-262's "an agent holds what its user holds" rather than an exception to it.

func (*AuthzStore) InstallVerbHolders

func (s *AuthzStore) InstallVerbHolders(ctx context.Context, verb authz.Verb) ([]string, error)

InstallVerbHolders returns the active people holding an install verb, directly or through a group. For telling them something — an update is available (R-362) — never for deciding what they may do: that is the authorizer's, which also applies host policy.

func (*AuthzStore) InstallVerbsFor

func (s *AuthzStore) InstallVerbsFor(ctx context.Context, p authz.Principal) ([]string, error)

InstallVerbsFor returns every installation-wide verb the principal holds.

For GET /me, so the console can decide whether to show the Admin entry and what to put in it (R-265) without guessing. The list is the server's answer, which is what keeps the console a client of the API rather than a second opinion about authorization (R-261).

func (*AuthzStore) IsOwner

func (s *AuthzStore) IsOwner(ctx context.Context, appID, userID string) (bool, error)

IsOwner reports whether userID is the app's owner of record (R-031).

func (*AuthzStore) PasscodeUnlocked

func (s *AuthzStore) PasscodeUnlocked(ctx context.Context, appID, token string) (bool, error)

PasscodeUnlocked reports whether token is a live unlock of the app. Live means unexpired and made under the app's current anonymous grant: the grant's own row is joined, so an unlock outlives neither the grant nor — since SetPasscode clears them — the passcode it was made with.

func (*AuthzStore) Role

func (s *AuthzStore) Role(ctx context.Context, roleID string) (authz.Role, error)

Role returns a role by ID.

func (*AuthzStore) UserStatus

func (s *AuthzStore) UserStatus(ctx context.Context, userID string) (string, error)

UserStatus returns a user's status, or "deleted" if no such user.

A missing user resolving to "deleted" rather than an error matters for R-059: a delegated token whose owner was hard-removed must be orphaned, not produce a lookup failure that some caller might treat as transient and retry past.

type AutoDeployCheck

type AutoDeployCheck struct {
	AppID       string    `json:"app_id"`
	CheckedAt   time.Time `json:"checked_at"`
	FoundRef    string    `json:"found_ref,omitempty"`
	FoundCommit string    `json:"found_commit,omitempty"`

	// AttemptedCommit is tried once (O-58). Auto-deploy does not try it
	// again, whatever came of it.
	AttemptedCommit string     `json:"attempted_commit,omitempty"`
	AttemptedAt     *time.Time `json:"attempted_at,omitempty"`
	DeploymentID    string     `json:"deployment_id,omitempty"`

	// Error is why the last check or attempt went nowhere, or empty.
	Error string `json:"error,omitempty"`
}

AutoDeployCheck is the last time auto-deploy looked at an app (R-141): what the watched branch or release pointed at, and the last commit it tried.

type AutoDeployChecks

type AutoDeployChecks struct {
	// contains filtered or unexported fields
}

AutoDeployChecks stores one AutoDeployCheck per app.

func NewAutoDeployChecks

func NewAutoDeployChecks(db *DB) *AutoDeployChecks

func (*AutoDeployChecks) Attempted

func (c *AutoDeployChecks) Attempted(ctx context.Context, appID, ref, commit, deploymentID, message string) error

Attempted records that auto-deploy tried commit: the deployment it started, or why it could not start one.

func (*AutoDeployChecks) ByApp

func (c *AutoDeployChecks) ByApp(ctx context.Context, appID string) (AutoDeployCheck, bool, error)

ByApp is the app's last check, and whether there has been one.

func (*AutoDeployChecks) Checked

func (c *AutoDeployChecks) Checked(ctx context.Context, appID, ref, commit, message string) error

Checked records a check that found ref at commit, or an error, and tried nothing. The last attempt is kept: it is what stops a retry.

type AutoDeploySecrets

type AutoDeploySecrets struct {
	// contains filtered or unexported fields
}

AutoDeploySecrets stores each app's auto-deploy webhook secret, sealed by the secrets adapter (R-142, R-190, O-58): ciphertext or an external reference, never the value. The same arrangement as SubscriptionSecrets.

func NewAutoDeploySecrets

func NewAutoDeploySecrets(db *DB, adapter api.SecretsAdapter, adapterRef string) *AutoDeploySecrets

func (*AutoDeploySecrets) Delete

func (k *AutoDeploySecrets) Delete(ctx context.Context, appID string) error

Delete removes an app's webhook secret, which turns its webhook off.

func (*AutoDeploySecrets) Get

func (k *AutoDeploySecrets) Get(ctx context.Context, appID string) (secret.Value, bool, error)

Get opens an app's webhook secret, and says whether it has one.

func (*AutoDeploySecrets) Has

func (k *AutoDeploySecrets) Has(ctx context.Context, appID string) (bool, error)

Has says whether an app has a webhook secret, without opening it.

func (*AutoDeploySecrets) Put

func (k *AutoDeploySecrets) Put(ctx context.Context, appID string, v secret.Value) error

Put stores or replaces an app's webhook secret.

type AwaitingApproval

type AwaitingApproval struct {
	Deployment
	AppName string `json:"app_name"`
	AppSlug string `json:"app_slug"`
}

AwaitingApproval is a deploy waiting for approval, with the app it is for: the list of everything waiting across apps (GET /approvals).

type AwaitingKey

type AwaitingKey struct {
	StartedAt time.Time
	ID        string
}

AwaitingKey is where a page of ListAwaitingAfter starts: after the request with this start time and ID. The zero key starts from the oldest.

func AwaitingKeyFrom

func AwaitingKeyFrom(cursor string) (AwaitingKey, error)

AwaitingKeyFrom reads a cursor made by Cursor.

func (AwaitingKey) Cursor

func (k AwaitingKey) Cursor() string

Cursor encodes the key for a next_cursor.

type Backup

type Backup struct {
	ID         string `json:"id"`
	AppID      string `json:"app_id,omitempty"`
	Kind       string `json:"kind"`
	AdapterRef string `json:"adapter_ref"`
	ObjectName string `json:"object_name"`
	SizeBytes  int64  `json:"size_bytes"`

	Manifest backup.Manifest `json:"manifest"`

	// RetainUntil is nil when the backup is kept until explicitly discarded:
	// always for on_delete (R-204), and for any kind whose destination owns
	// retention itself (R-217).
	RetainUntil *time.Time `json:"retain_until,omitempty"`

	CreatedBy string    `json:"created_by"`
	CreatedAt time.Time `json:"created_at"`
}

Backup is one stored backup.

type BackupAttempt

type BackupAttempt struct {
	AppID string `json:"app_id"`

	// AppName is filled in when attempts are listed, for a screen that shows
	// them. Not stored: an app's name can change.
	AppName string `json:"app_name,omitempty"`

	AttemptedAt time.Time `json:"attempted_at"`
	Outcome     string    `json:"outcome"`
	BackupID    string    `json:"backup_id,omitempty"`
	Message     string    `json:"message,omitempty"`
	Remedy      string    `json:"remedy,omitempty"`

	// LiveVolumes are the volumes a backup that was taken copied while the
	// app was running, so a file written during the copy may be inconsistent
	// in it (R-423). A database dumped by its engine is never one of them.
	LiveVolumes []string `json:"live_volumes,omitempty"`
}

BackupAttempt is the last scheduled rolling backup of one app and what came of it (issue #87).

A backup that did not happen used to leave nothing behind but a server log line, so an app that had not been backed up in a week looked exactly like one that had. Outcome is "taken", "skipped" or "failed"; Message and Remedy say why and what to do about it, to the R-105 standard, and are empty when the backup was taken.

type Backups

type Backups struct {
	// contains filtered or unexported fields
}

Backups records what has been backed up and where (design 02 §2.8).

func NewBackups

func NewBackups(db *DB) *Backups

func (*Backups) Attempts

func (b *Backups) Attempts(ctx context.Context, appID string, limit int) ([]BackupAttempt, error)

Attempts returns the last scheduled backup attempt of each live app, or of one app when appID is set. Newest first, and at most limit of them (backup_attempts_recent_idx): one row per app is 20,000 rows on a large install, and the newest are the ones to read (issue #72).

func (*Backups) ByID

func (b *Backups) ByID(ctx context.Context, backupID string) (Backup, bool, error)

ByID returns one backup.

func (*Backups) Expired

func (b *Backups) Expired(ctx context.Context, now time.Time) ([]Backup, error)

Expired returns backups whose retention has passed (R-211).

Never returns an on_delete row: those have no retain_until at all, and the schema refuses to give them one. The WHERE clause says so anyway, because a pruning query that relies on a constraint elsewhere is one migration away from deleting the backups R-204 promises to keep.

func (*Backups) Forget

func (b *Backups) Forget(ctx context.Context, backupID string) error

Forget removes the record of a backup whose object has been deleted.

The object goes first, then this. The other order leaves an object nothing references, which is invisible and accumulates.

func (*Backups) List

func (b *Backups) List(ctx context.Context, appID string, page Page) ([]Backup, string, error)

List returns one page of backups, newest first, keyset-paged on (created_at, id), with the cursor for the next page (empty after the last). An empty appID lists every app's, and the install's bundles.

A page rather than every row: each carries its manifest, and an install that takes a rolling backup of 20,000 apps a day holds a great many (issue #72). backups_list_idx and backups_app_idx serve the order.

func (*Backups) NewID

func (b *Backups) NewID() string

NewID returns an ID for a backup about to be written.

Allocated before the bundle is built, because the ID is the object name at the destination — so a crash mid-write leaves an object nothing references rather than a row pointing at nothing. The first is garbage; the second is a restore that cannot find its bundle.

func (*Backups) Record

func (b *Backups) Record(ctx context.Context, rec Backup) error

Record stores a backup that has been fully written.

Called after the destination's Close returns, never before. A row written first would claim a bundle exists during the window where it does not, and that window is exactly when a disaster is most likely to interrupt.

func (*Backups) RecordAttempt

func (b *Backups) RecordAttempt(ctx context.Context, a BackupAttempt) error

RecordAttempt replaces the app's last attempt with this one.

type BundleSource

type BundleSource struct {
	// contains filtered or unexported fields
}

BundleSource implements backup.StateSource against Postgres.

Separate from Backups: one records what was backed up, the other supplies what goes in a bundle. Merging them would put the thing being backed up and the record of the backup behind one type, and the record has to survive the thing.

func NewBundleSource

func NewBundleSource(db *DB) *BundleSource

func (*BundleSource) AdapterConfigs

func (b *BundleSource) AdapterConfigs(ctx context.Context) ([]byte, error)

AdapterConfigs exports adapter configuration as readable JSON.

Also in the pg_dump, and deliberately duplicated: this copy is for the operator rebuilding an install by hand, which is exactly the situation where the database is the thing that will not load.

func (*BundleSource) Counts

func (b *BundleSource) Counts(ctx context.Context) (map[string]int, error)

Counts are the object counts the manifest records (R-215).

Checksums prove the bytes arrived. Counts prove the bytes describe the install the operator thinks they are restoring, which is the question somebody staring at a restore prompt actually has.

func (*BundleSource) HostPolicy

func (b *BundleSource) HostPolicy(ctx context.Context) ([]byte, error)

HostPolicy exports the policy document.

func (*BundleSource) ServicesForApp added in v0.17.0

func (b *BundleSource) ServicesForApp(ctx context.Context, appID string) ([]backup.ServiceRef, error)

ServicesForApp lists one app's provisioned services, for a per-app backup to dump with their engines' own tools (R-423).

func (*BundleSource) ServicesToSnapshot

func (b *BundleSource) ServicesToSnapshot(ctx context.Context) ([]backup.ServiceRef, error)

ServicesToSnapshot lists every provisioned service (R-131, R-212).

Every one, not only those the backup will call Snapshot on. The manifest records both counts, and a bundle that says "5 services, 0 snapshotted" because they all live in app volumes is very different from a bundle that says it because the provisioner was unreachable — but only if the first number is counted here rather than inferred from the second.

func (*BundleSource) VolumesForApp

func (b *BundleSource) VolumesForApp(ctx context.Context, appID string) ([]backup.VolumeRef, error)

VolumesForApp lists one app's volumes, for a per-app backup (R-204).

Includes volumes whose app is already archived: R-204 keeps a volume after its app is gone, and the final backup is taken at exactly that moment.

func (*BundleSource) VolumesToSnapshot

func (b *BundleSource) VolumesToSnapshot(ctx context.Context) ([]backup.VolumeRef, error)

VolumesToSnapshot lists live volumes with the runtime that holds each.

Volumes belonging to deleted apps are included: R-204 keeps a volume after its app is gone, and a DR bundle that dropped exactly the data somebody is still deciding whether to keep would defeat the point of keeping it.

type CLISignIn added in v0.13.0

type CLISignIn struct {
	CodeHash      string
	UserID        string
	ApprovedBy    string
	CodeChallenge string
	TokenName     string
	CreatedAt     time.Time
	ExpiresAt     time.Time
}

CLISignIn is a CLI sign-in someone approved in the browser and the CLI has not collected yet (R-406, design 06 §3.5).

type CLISignIns added in v0.13.0

type CLISignIns struct {
	// contains filtered or unexported fields
}

CLISignIns stores approvals.

func NewCLISignIns added in v0.13.0

func NewCLISignIns(db *DB) *CLISignIns

func (*CLISignIns) Create added in v0.13.0

func (c *CLISignIns) Create(ctx context.Context, s CLISignIn) error

Create stores an approval. Expired ones are removed by the retention job (Retention.SSOFlows), not here, as with sign-in flows.

func (*CLISignIns) Take added in v0.13.0

func (c *CLISignIns) Take(ctx context.Context, codeHash string) (CLISignIn, bool, error)

Take removes the approval a code digest names and returns it, if it has not expired. Removed whatever the caller does next: a code is tried once, so a wrong verifier spends it as surely as the right one.

type Cluster added in v0.17.0

type Cluster struct {
	// contains filtered or unexported fields
}

Cluster stores the cluster's hosts and its join tokens.

func NewCluster added in v0.17.0

func NewCluster(db *DB, key []byte) *Cluster

NewCluster returns the cluster store. key must be tokenkey.Size bytes.

func (*Cluster) AddHost added in v0.17.0

func (c *Cluster) AddHost(ctx context.Context, h Host) (Host, error)

AddHost records a host that has joined, confirmed.

func (*Cluster) CertRevoked added in v0.17.0

func (c *Cluster) CertRevoked(ctx context.Context, sha string) (bool, error)

CertRevoked reports whether the certificate with fingerprint sha belonged to a host removed from the cluster (R-415).

func (*Cluster) CheckJoinToken added in v0.17.0

func (c *Cluster) CheckJoinToken(ctx context.Context, presented secret.Value) (JoinToken, error)

CheckJoinToken checks the presented token without spending a use or locking anything (R-415): its secret, and that it is neither revoked nor expired. Whether a use is left is checked when Join spends one, after the uses of pending joins past their lifetime are given back. A join makes every slow check between the two, holding no connection meanwhile.

func (*Cluster) ClearHostLost added in v0.17.0

func (c *Cluster) ClearHostLost(ctx context.Context, hostID string) error

ClearHostLost records that a lost host is back in the cluster. Its copies of the apps that failed over are removed by whoever calls this, never restarted (R-419).

func (*Cluster) ConfirmHostCert added in v0.17.0

func (c *Cluster) ConfirmHostCert(ctx context.Context, hostID, current string) (bool, error)

ConfirmHostCert records that the host presented current, its newest certificate: the one before it is no longer the host's. It reports whether that changed anything.

func (*Cluster) ConfirmJoin added in v0.17.0

func (c *Cluster) ConfirmJoin(ctx context.Context, hostID string) (bool, error)

ConfirmJoin records that the host whose join is pending presented the certificate its join issued (R-415): from here it is listed and takes part in the cluster. It reports whether that changed anything.

func (*Cluster) CreateJoinToken added in v0.17.0

func (c *Cluster) CreateJoinToken(ctx context.Context, roles []string, maxUses int, createdBy string, expiresAt time.Time) (IssuedJoinToken, error)

CreateJoinToken mints a join token for roles, usable maxUses times until expiresAt.

func (*Cluster) Fresh added in v0.17.0

func (c *Cluster) Fresh(ctx context.Context) (accounts, apps bool, err error)

Fresh reports whether this install has ever had an account or an app. A Pando that has either cannot join a cluster (R-415): that would be merging two installs, and two audit logs with them. Deleted ones count.

func (*Cluster) Host added in v0.17.0

func (c *Cluster) Host(ctx context.Context, hostID string) (Host, error)

Host reads one host whose join is not pending.

func (*Cluster) HostByCert added in v0.17.0

func (c *Cluster) HostByCert(ctx context.Context, sha string) (Host, bool, error)

HostByCert is the host, pending or not, holding the certificate with fingerprint sha: the one it joined or last renewed with, the one before it, or the one a control host answers pings with. Whether sha is revoked is the caller's to check (CertRevoked).

func (*Cluster) Hosts added in v0.17.0

func (c *Cluster) Hosts(ctx context.Context) ([]Host, error)

Hosts lists the cluster's hosts, oldest first: every one but those whose join is pending (ConfirmJoin).

func (*Cluster) Join added in v0.17.0

func (c *Cluster) Join(ctx context.Context, presented secret.Value, h Host) (JoinToken, Host, error)

Join spends one use of the presented token and records h, pending, with the token's roles, in one short transaction (R-415): whatever is slow — the cluster's readiness, the runtime's checks, issuing the host's certificates — is done before it (CheckJoinToken), so the token's row is locked only for the spend and the insert. A pending join of the same name and the same key (h.KeySHA256), or one past PendingJoinLifetime, is forgotten first and its use given back, so a join that failed on the joining side can be tried again under its name. A pending join of the same name and another key is another machine's, and this join is refused while it lasts (pendingClash). When recording the host fails, the use is given back too. The host is listed once it confirms the join (ConfirmJoin).

func (*Cluster) JoinTokens added in v0.17.0

func (c *Cluster) JoinTokens(ctx context.Context) ([]JoinToken, error)

JoinTokens lists every join token, newest first, with the uses of joins pending past their lifetime given back first (forgetPending).

func (*Cluster) LostHosts added in v0.17.0

func (c *Cluster) LostHosts(ctx context.Context) (map[string]bool, error)

LostHosts is the set of hosts marked lost.

func (*Cluster) MarkHostLost added in v0.17.0

func (c *Cluster) MarkHostLost(ctx context.Context, hostID, by string) (bool, error)

MarkHostLost records that the host is lost, by the majority rule or a person (by). It reports whether the host was not lost before; marking a host lost twice keeps the first time and who said so.

func (*Cluster) OnePerHost added in v0.17.0

func (c *Cluster) OnePerHost(ctx context.Context, job, host string, pass func(context.Context)) (bool, error)

OnePerHost runs pass unless another replica on host is running job's pass, and reports whether it ran (R-417): replicas of one host share its edge, so one of them writing its routes is enough. host is what the replicas of one host share — the ID their join gave them, or one name for the install's own first host. The lock is the transaction's, so a replica that dies mid-pass lets go of it.

func (*Cluster) RecordFirstHost added in v0.17.0

func (c *Cluster) RecordFirstHost(ctx context.Context, h Host) (Host, bool, error)

RecordFirstHost records h as the install's own first host (R-414) unless one is recorded already: a host no join token brought in. It reports whether it recorded h. Replicas of one install share the database and are one host between them; a host that joined is never recorded here.

func (*Cluster) RecordHostFence added in v0.17.0

func (c *Cluster) RecordHostFence(ctx context.Context, hostID, fenceID string) (bool, error)

RecordHostFence records fenceID as the host's latest fence, and reports whether it was new: the leader audits each fence once, however many times it hears of it.

func (*Cluster) RedeemJoinToken added in v0.17.0

func (c *Cluster) RedeemJoinToken(ctx context.Context, presented secret.Value) (JoinToken, error)

RedeemJoinToken spends one use of the presented "<id>.<secret>" token and returns it, or refuses. The use is taken in the same statement that checks the token is live, so two hosts joining at once with a single-use token cannot both succeed.

func (*Cluster) RemoveHost added in v0.17.0

func (c *Cluster) RemoveHost(ctx context.Context, hostID string) (Host, error)

RemoveHost removes a host from the cluster, and revokes every certificate it held, in one transaction, so that no host, listener or runtime takes them again (R-415): its current, previous and ping certificates by fingerprint, and its probe certificates, which are never recorded, by its ID on every row (Revoked). The install's own first host is refused (ErrInstallHost).

func (*Cluster) RenewHostCert added in v0.17.0

func (c *Cluster) RenewHostCert(ctx context.Context, hostID, presented, next string, nextPEM []byte) (Host, bool, error)

RenewHostCert records next, whose PEM is nextPEM, as the certificate of the host that presented its current one (R-421). It is a compare-and-set on presented: only the first renewal of a certificate is recorded, and keeps presented as the one before until the host presents next (ConfirmHostCert). A renewal that finds presented no longer current — one that overlapped another, or was tried again after its answer was lost — is told so (false) with the row as it is now, whose NextCertPEM is what the first was answered with, so the pins always hold the certificate the host ends up using.

func (*Cluster) RevokeJoinToken added in v0.17.0

func (c *Cluster) RevokeJoinToken(ctx context.Context, tokenID string) (JoinToken, error)

RevokeJoinToken revokes a join token. Revoking one already revoked is not an error; one that does not exist is.

func (*Cluster) Revoked added in v0.17.0

func (c *Cluster) Revoked(ctx context.Context) (Revoked, error)

Revoked lists every removed host's ID and certificate fingerprint, for the hosts that answer pings to refuse them (R-415, hosts.Members).

func (*Cluster) RotateHostCert added in v0.17.0

func (c *Cluster) RotateHostCert(ctx context.Context, hostID, next string) error

RotateHostCert records next as the certificate the host's agent holds now, the current one kept as the one before (prev_cert_sha256), so a connection to the agent accepts either until it serves next (R-416, R-421). A control host renews its own certificate (hosts.ControlPeers). next, when it is revoked, is refused.

func (*Cluster) SetHostFenced added in v0.17.0

func (c *Cluster) SetHostFenced(ctx context.Context, hostID string, fenced bool) (bool, error)

SetHostFenced records whether the host says it is fenced: cut off from most of the cluster, with its apps stopped (R-421). It reports whether that changed. The leader calls it with what each host answers; every replica's reconciler reads it (Standbys.Held) and leaves a fenced host's apps stopped, so the control plane never undoes a fence.

func (*Cluster) SetHostPing added in v0.17.0

func (c *Cluster) SetHostPing(ctx context.Context, hostID, address, certSHA256 string) error

SetHostPing records where a control host answers the cluster's liveness checks, its cluster listener, and the fingerprint of the certificate it answers with (R-421): a control host keeps its own row current with it (hosts.ControlPeers). The agent's address and pin, which the runtime reaches the host through (R-416), stay as the host joined with them.

func (*Cluster) TouchHost added in v0.17.0

func (c *Cluster) TouchHost(ctx context.Context, hostID string) error

TouchHost records that a host was heard from.

type ConnectOptions

type ConnectOptions struct {
	// OwnerURL is the connection string Pando is given. It must be able to run
	// migrations and manage AppRole.
	OwnerURL string

	// ConnectTimeout bounds the retry loop. Compose starts Pando and Postgres
	// together, so Postgres will not be accepting connections when Pando first
	// dials — this is the standard Compose race and is handled rather than
	// assumed away.
	ConnectTimeout time.Duration

	// SkipMigrate is for tests that manage schema themselves.
	SkipMigrate bool

	// MaxConns caps the application pool; zero is pgx's default
	// (config.Database.MaxConns explains the choice).
	MaxConns int32
}

ConnectOptions configures the bootstrap sequence.

type DB

type DB struct {
	*pgxpool.Pool
	// contains filtered or unexported fields
}

DB is a connection to the state store, held as the application role.

func Connect

func Connect(ctx context.Context, opts ConnectOptions) (*DB, error)

Connect brings the state store up and returns a pool held as AppRole.

The sequence is: wait for Postgres, migrate as owner, provision the application role, apply the grant policy, verify it, then reconnect as the application role. Every step after the wait is idempotent and re-runs on each start, so a grant that drifts is corrected rather than discovered later.

func ConnectCopy

func ConnectCopy(ctx context.Context, ownerURL string, appPassword secret.Value) (*DB, error)

ConnectCopy connects as AppRole to a copy of a database PrepareTemplate prepared, whose password it returned.

The audit log's protection is verified again on the copy rather than assumed from the template: it is the one property that must hold of every database Pando serves from (R-027).

func (*DB) Archiver

func (db *DB) Archiver() *pgxpool.Pool

Archiver is the pool held as ArchiverRole, or nil when this connection has none (R-348). Only audit retention uses it.

func (*DB) Close

func (db *DB) Close()

Close releases the pool.

func (*DB) Exclusive

func (db *DB) Exclusive(ctx context.Context, key int64, fn func() error) error

Exclusive runs fn holding the session advisory lock key, waiting for it if another process holds it.

For work that must happen once however many replicas start at the same moment: the second waits, then finds it done.

func (*DB) Listen

func (db *DB) Listen(ctx context.Context, channel string, changed func(payload string), connected func(bool))

Listen holds a connection of its own LISTENing on channel until ctx ends, calling changed for each notification. connected is told true once the LISTEN is in place and false whenever the connection is lost; a lost connection is opened again, with backoff, and told true again.

Its own connection rather than one from the pool: a pooled connection goes back to the pool still listening, and a listener holds its connection for as long as the process runs.

func (*DB) Replica

func (db *DB) Replica() string

Replica is this process's replica ID.

func (*DB) SchemaVersion

func (db *DB) SchemaVersion() uint

SchemaVersion is the migration version this database is at.

type Deliveries

type Deliveries struct {
	// contains filtered or unexported fields
}

Deliveries stores deliveries and their attempts.

func NewDeliveries

func NewDeliveries(db *DB) *Deliveries

func (*Deliveries) Claim

func (s *Deliveries) Claim(ctx context.Context, now time.Time, lease time.Duration, limit int) ([]Delivery, error)

Claim takes up to limit deliveries that are due, on subscriptions that are on, and pushes each one's next attempt out by lease. A Pando that stops mid-send leaves the delivery to be retried when the lease runs out: at least once, never zero times (R-366).

func (*Deliveries) Drop

func (s *Deliveries) Drop(ctx context.Context, deliveryID, reason string) error

Drop ends a delivery without an attempt — its owner can no longer see what it is about (R-368). Recorded, so the log says why nothing was sent.

func (*Deliveries) Get

func (s *Deliveries) Get(ctx context.Context, deliveryID string) (Delivery, bool, error)

Get reads one delivery and every attempt at it.

func (*Deliveries) List

func (s *Deliveries) List(ctx context.Context, subscriptionID, before string, limit int) ([]Delivery, error)

List returns one subscription's deliveries, newest first, before the delivery ID given (a cursor) when one is.

func (*Deliveries) Record

func (s *Deliveries) Record(ctx context.Context, deliveryID string, a DeliveryAttempt, status string, next *time.Time) error

Record writes an attempt and where the delivery stands after it: status, and when to try next if it is still pending.

func (*Deliveries) Redeliver

func (s *Deliveries) Redeliver(ctx context.Context, deliveryID, by string) error

Redeliver queues a delivery again, now, with the whole retry schedule ahead of it.

type Delivery

type Delivery struct {
	ID             string            `json:"id"`
	SubscriptionID string            `json:"subscription_id"`
	EventID        string            `json:"event_id"`
	EventName      string            `json:"event"`
	Status         string            `json:"status"`
	Attempts       int               `json:"attempts"`
	NextAttemptAt  *time.Time        `json:"next_attempt_at,omitempty"`
	LastAttemptAt  *time.Time        `json:"last_attempt_at,omitempty"`
	LastStatusCode *int              `json:"last_status_code,omitempty"`
	LastError      string            `json:"last_error,omitempty"`
	RedeliveredBy  string            `json:"redelivered_by,omitempty"`
	RoundBase      int               `json:"-"`
	CreatedAt      time.Time         `json:"created_at"`
	AttemptLog     []DeliveryAttempt `json:"attempt_log,omitempty"`
}

Delivery is one event to one subscription.

type DeliveryAttempt

type DeliveryAttempt struct {
	Attempt     int       `json:"attempt"`
	AttemptedAt time.Time `json:"attempted_at"`
	StatusCode  *int      `json:"status_code,omitempty"`
	Error       string    `json:"error,omitempty"`
	DurationMS  int       `json:"duration_ms"`
}

DeliveryAttempt is one try.

type Deployment

type Deployment struct {
	ID      string `json:"id"`
	AppID   string `json:"app_id"`
	SpecID  string `json:"spec_id"`
	Trigger string `json:"trigger"`
	Status  string `json:"status"`

	// ResultState is what the app was doing when this deploy finished:
	// "running", or "degraded" when it started and never reported healthy.
	// Empty for a deploy that failed before it got that far.
	//
	// The deploy's own status answers "did Pando do the work"; this answers
	// "did the app come up", which is what somebody reading a list of deploys
	// is asking. Three rows reading "Deployed" for an app that had never
	// served a request is a true answer to the wrong question.
	ResultState string `json:"result_state,omitempty"`

	ErrorCode   string     `json:"error_code,omitempty"`
	ErrorDetail string     `json:"error_detail,omitempty"`
	StartedAt   time.Time  `json:"started_at"`
	FinishedAt  *time.Time `json:"finished_at,omitempty"`
	CreatedBy   string     `json:"created_by"`

	// SpecRevision is the revision number SpecID is, and RequestedByName who
	// CreatedBy is, for showing: a person deciding whether to approve a
	// deploy wants "revision 7, asked for by Ada", not two IDs.
	SpecRevision    int    `json:"spec_revision,omitempty"`
	RequestedByName string `json:"requested_by_name,omitempty"`

	// Deploy approval (R-154 – R-159), on a deploy that needed it. The count
	// and the expiry are fixed when the request is made, so a policy edit
	// does not move a waiting request's goalposts.
	ApprovalsRequired int                `json:"approvals_required,omitempty"`
	ApprovalExpiresAt *time.Time         `json:"approval_expires_at,omitempty"`
	ApprovalReasons   []ApprovalReason   `json:"approval_reasons,omitempty"`
	Approvals         []ApprovalDecision `json:"approvals,omitempty"`

	// CanDecide is whether the caller may approve or reject this deploy. Set
	// by the approval service, per caller, on a deploy that is waiting; the
	// store never fills it in.
	CanDecide bool `json:"can_decide,omitempty"`

	// QueuePosition is how many queued deploys a queued one is behind: 0 for
	// the next to be taken (issue #93). Absent on one that is not waiting in
	// the queue. Set by the handlers from QueuePositions; the store's own
	// reads never fill it in.
	QueuePosition *int `json:"queue_position,omitempty"`

	// NewerRevision is the newest revision of the app's spec, when a deploy
	// that named no revision shipped the pinned one and a newer one exists —
	// an edit nobody has deployed. Set by the deploy handler on its reply, so
	// a client that sent no revision is told rather than left to find out;
	// the store never fills it in.
	NewerRevision int `json:"newer_revision,omitempty"`
}

Deployment is one attempt to make an app match a spec.

Its own table, separate from the app, because a build failure fails the deployment and leaves the app untouched (R-146). Collapsing them would make that distinction impossible to express.

type Deployments

type Deployments struct {
	// contains filtered or unexported fields
}

Deployments stores deployment records.

func NewDeployments

func NewDeployments(db *DB) *Deployments

func (*Deployments) ByID

func (d *Deployments) ByID(ctx context.Context, deploymentID string) (Deployment, bool, error)

ByID returns one deployment.

func (*Deployments) Claim

func (d *Deployments) Claim(ctx context.Context, limit int) ([]Deployment, error)

Claim takes up to limit queued deployments for this replica, oldest first, and returns them (issue #72, O-32).

FOR UPDATE SKIP LOCKED, so replicas claiming at once take different rows and none waits on another; claiming stamps replica_id, which is what makes a deployment this replica's to run, to fence (SetStatus, Finish) and to serve the live log of. The claimant's heartbeat is the lease: RecoverInFlight puts back what a stopped replica had claimed.

func (*Deployments) Create

func (d *Deployments) Create(ctx context.Context, appID, specID, trigger, createdBy string) (Deployment, error)

Create records a new deployment, queued: pending with no replica running it. Any replica's deploy queue claims it (Claim) when it has room (issue #72, O-32).

func (*Deployments) CreateAwaiting

func (d *Deployments) CreateAwaiting(ctx context.Context, appID, specID, trigger, createdBy string,
	required int, expiresAt *time.Time, reasons []string) (Deployment, error)

CreateAwaiting records a deploy that needs approval before it runs: tied to one spec revision (R-156), needing required approvals, waiting until expiresAt (nil waits until somebody answers), for the reasons given.

func (*Deployments) Decide

func (d *Deployments) Decide(ctx context.Context, deploymentID, principalID, decision, comment string) error

Decide records one principal's decision on a deploy. A principal decides once per request (the table's primary key): deciding again replaces the earlier answer rather than counting twice, so a retried approval is harmless and two approvals from one person are one.

func (*Deployments) Decisions

func (d *Deployments) Decisions(ctx context.Context, deploymentIDs []string) (map[string][]ApprovalDecision, error)

Decisions returns the decisions on each deployment, oldest first, with each decider's name for showing.

func (*Deployments) EndAwaiting

func (d *Deployments) EndAwaiting(ctx context.Context, deploymentID, status, errorCode, message string) (bool, error)

EndAwaiting closes a deploy that is still waiting for approval — rejected, expired, superseded, or failed because its plan no longer passes — and reports whether it was still waiting. Conditional for the same reason as StartApproved: whichever of a rejection, an expiry and a final approval lands first decides.

func (*Deployments) ExpireDue

func (d *Deployments) ExpireDue(ctx context.Context, now time.Time) ([]Deployment, error)

ExpireDue ends every request whose wait ran out at or before now, and returns them. A request with no expiry waits until somebody answers.

func (*Deployments) Finish

func (d *Deployments) Finish(ctx context.Context, deploymentID, status, errorCode, message string) error

func (*Deployments) InFlight

func (d *Deployments) InFlight(ctx context.Context, appID string) (bool, error)

InFlight reports whether a deployment is already running for an app.

Used to refuse a concurrent deploy rather than queue one: two deploys racing on the same bundle is how an app ends up in a state neither of them intended.

func (*Deployments) LastImage

func (d *Deployments) LastImage(ctx context.Context, appID string) (string, error)

LastImage returns the image the app's newest successful deploy shipped.

What a rescan looks at (R-312): the image that is running, not one derived from the app's name. Empty for an app that has never deployed, or one whose deploys never carried an image — an app that runs somebody else's published image has one, and an app that has never been built does not.

func (*Deployments) ListAwaiting

func (d *Deployments) ListAwaiting(ctx context.Context) ([]AwaitingApproval, error)

ListAwaiting returns every deploy waiting for approval on an app that has not been deleted, oldest request first, with its decisions so far. Who may see each is the caller's to decide.

func (*Deployments) ListAwaitingAfter

func (d *Deployments) ListAwaitingAfter(ctx context.Context, after AwaitingKey, limit int) ([]AwaitingApproval, error)

ListAwaitingAfter is ListAwaiting from after a key, at most limit of them (a negative limit reads them all).

func (*Deployments) ListForApp

func (d *Deployments) ListForApp(ctx context.Context, appID string) ([]Deployment, error)

ListForApp returns an app's deployments, newest first, with the decisions on any that needed approval.

func (*Deployments) PriorState

func (d *Deployments) PriorState(ctx context.Context, deploymentID string) (string, error)

PriorState is what RecordPriorState stored, or "" if nothing was.

func (*Deployments) QueueDepth

func (d *Deployments) QueueDepth(ctx context.Context) (int, error)

QueueDepth is how many deployments are waiting for a replica to take them.

func (*Deployments) QueuePosition

func (d *Deployments) QueuePosition(ctx context.Context, deploymentID string) (int, bool, error)

QueuePosition is how many queued deploys are ahead of this one, in the order Claim takes them, and whether it is still waiting at all (issue #93).

func (*Deployments) QueuePositions

func (d *Deployments) QueuePositions(ctx context.Context, deps []Deployment) error

QueuePositions sets QueuePosition on each of deps still waiting in the queue. One read of the whole queue, which holds at most one deploy per app, and none when no deploy in deps is pending.

func (*Deployments) RanSuccessfully

func (d *Deployments) RanSuccessfully(ctx context.Context, appID, specID string) (bool, error)

RanSuccessfully reports whether a spec revision of an app has been deployed successfully before — what makes rolling back to it free of approval (R-157). A deploy that succeeded is the proof it ran; being pinned is not, since a revision can be pinned by hand without ever being deployed.

func (*Deployments) RecordPriorState

func (d *Deployments) RecordPriorState(ctx context.Context, deploymentID, appState string) error

RecordPriorState stores the app's state as it was when this deploy was started: what a deploy that stops before touching the runtime puts back (R-146). Stored, because the deploy runs from the queue on whichever replica claims it.

func (*Deployments) RecoverInFlight

func (d *Deployments) RecoverInFlight(ctx context.Context) (int64, error)

RecoverInFlight puts back in the queue every deployment claimed by a Pando process that is no longer running, and records as interrupted one that has been started MaxAttempts times. It returns how many it did either to.

Resume rather than record-interrupted (issue #72, O-32): every step a deploy takes before it commits is safe to take again — fetching the pinned commit and building it produce the same image, a scan of the same source is reused, provisioning finds the instance it made, and applying a bundle converges on the spec whatever state the last attempt left it in. So the deploy starts again from the top on whichever replica claims it next, and its user sees it finish rather than a failure that says "deploy again".

Only a stopped or silent process's work, never a live one's: a deploy belongs to the replica that claimed it until that replica stops heartbeating. Queued work nobody has claimed is nobody's to recover and is left in the queue. Called at startup and by the leader's sweeper.

func (*Deployments) Release

func (d *Deployments) Release(ctx context.Context, deploymentIDs []string) (int64, error)

Release puts deployments this replica claimed back in the queue, at a shutdown that stopped them part-way. Not counted as an attempt: a rolling restart is not the deploy's fault. One that finished first is left alone.

func (*Deployments) Runner

func (d *Deployments) Runner(ctx context.Context, deploymentID string) (string, error)

Runner names the replica running a deployment, or "" when none is recorded. It is where the deploy's live log is (deploy.LogStore).

func (*Deployments) RunningSpecID

func (d *Deployments) RunningSpecID(ctx context.Context, appID string) (string, error)

RunningSpecID is the spec revision the app's newest successful deploy shipped: what it runs now. Empty for an app that has never deployed.

What deploy approval measures a deploy against (R-154): the app's own approval requirement is read from it as well as from the spec being deployed, and an egress loosening counts only when the running spec does not already carry it.

func (*Deployments) SetEgressRules

func (d *Deployments) SetEgressRules(ctx context.Context, deploymentID string, rules egress.Rules) error

SetEgressRules records the egress rules a deployment runs with, so the reconciler can restore them rather than re-resolving policy under a running app (R-183, O-10).

func (*Deployments) SetImageRef

func (d *Deployments) SetImageRef(ctx context.Context, deploymentID, imageRef, digest string, perWorkload map[string]WorkloadImage) error

SetImageRef records the image a deployment ran, and what the runtime resolved it to.

The reference is what the reconciler restores a missing workload with, rather than rebuilding: rebuilding to correct drift would turn "someone killed a container" into "ship whatever is on the branch now", a far larger action than the one being corrected (R-120).

The digest is what makes "a workload exists with the wrong image" detectable. A reference cannot be compared against a running container — the container reports a digest — and a tag can point somewhere new without changing.

func (*Deployments) SetResultState

func (d *Deployments) SetResultState(ctx context.Context, deploymentID, appState string) error

Finish closes a deployment out. SetResultState records what the app was doing when the deploy finished.

func (*Deployments) SetStatus

func (d *Deployments) SetStatus(ctx context.Context, deploymentID, status string) error

SetStatus advances a deployment.

Only this replica's deployment, or one nobody has claimed: a replica that was taken for dead and whose deploy another replica has since claimed must not move it under the one now running it (fenced, as Finish is).

func (*Deployments) StartApproved

func (d *Deployments) StartApproved(ctx context.Context, deploymentID string) (bool, error)

StartApproved moves a deploy that has its approvals from awaiting_approval to pending — queued, the ordinary path — and reports whether it did.

Conditional, so that two approvals arriving together start it once: the second finds it no longer waiting. And conditional on nothing else being in flight for the app, in the same statement, so an approval cannot start a deploy alongside one that began a moment earlier (the check-then-start a caller makes first has that gap). started_at becomes the time it started, so a list of deploys reads in the order they ran; when it was asked for is the deploy.request audit event.

func (*Deployments) SupersedeAwaiting

func (d *Deployments) SupersedeAwaiting(ctx context.Context, appID, exceptID string) ([]string, error)

SupersedeAwaiting ends every request for an app still waiting for approval, except the one named, and returns the ones it ended. A newer request supersedes an older one (R-156): approving the older would deploy a revision nobody is asking for any more.

func (*Deployments) Waiting

func (d *Deployments) Waiting(ctx context.Context, deploymentID string) (bool, error)

Waiting reports whether a deployment is queued and nobody has claimed it.

type Detection

type Detection struct {
	AppID     string            `json:"app_id"`
	Status    string            `json:"status"`
	Body      json.RawMessage   `json:"body"`
	Answers   map[string]string `json:"answers"`
	Commit    string            `json:"commit,omitempty"`
	StartedAt time.Time         `json:"started_at"`
	UpdatedAt time.Time         `json:"updated_at"`
}

Detection is an app's current proposal.

type DetectionSummary

type DetectionSummary struct {
	Status string `json:"status"`
	// Stage is the step a running detection is on (detect.StageFetching and
	// the rest). Empty once it has finished.
	Stage     string    `json:"stage,omitempty"`
	StartedAt time.Time `json:"started_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

DetectionSummary is where an app's detection has got to, without the proposal: enough for an app's own page and the app list to say why a draft is still a draft (issue #80).

type Detections

type Detections struct {
	// contains filtered or unexported fields
}

Detections stores detection proposals.

func NewDetections

func NewDetections(db *DB) *Detections

NewDetections returns a store over db.

func (*Detections) Claim

func (d *Detections) Claim(ctx context.Context, limit int) ([]string, error)

Claim takes up to limit queued detections for this replica, oldest first, and returns their apps' IDs. SKIP LOCKED, as Deployments.Claim.

func (*Detections) FailIfRunning

func (d *Detections) FailIfRunning(ctx context.Context, appID string, cause error) error

FailIfRunning records a failure for a detection that is still marked running, and leaves one that has already recorded its own outcome alone.

func (*Detections) Get

func (d *Detections) Get(ctx context.Context, appID string) (Detection, error)

Get returns an app's current detection.

func (*Detections) Queue

func (d *Detections) Queue(ctx context.Context, appID string) (Detection, error)

Queue is Start, returning the detection as it wrote it.

In the same statement, so the row returned is the queued one: read back afterwards, a detection another replica claimed and failed at once in the meantime came back failed, and an API answering "queued" reported the outcome of work that had only just begun (issue #72).

func (*Detections) Rebuild added in v0.5.0

func (d *Detections) Rebuild(ctx context.Context, appID string, body any) (bool, error)

Rebuild marks a finished detection running again on this replica, with body as its progress, so the proposal's image can be built and scanned after an answer changed what is built (R-311). False when the detection is not finished as ready or needs_answers: one already running, failed or blocked has nothing to build.

Claimed for this replica in the same statement, so no queue claims it as a detection to run. A replica that stops part-way leaves it for RecoverRunning, which returns it to the queue: detection runs again, with the answers kept, and builds.

func (*Detections) RecoverRunning

func (d *Detections) RecoverRunning(ctx context.Context) (int64, error)

RecoverRunning puts back in the queue every detection claimed by a Pando process that is no longer running, and records as interrupted one started MaxAttempts times (issue #72, as Deployments.RecoverInFlight). Detection writes nothing but its own row, so running it again from the start is always safe; answers given so far are kept, as on any re-run.

A detection that stayed "running" for good was a console spinning forever and a client polling for an answer that could not come (issue #55); this is what ends that, at startup and then periodically. Queued detections nobody has claimed are left in the queue.

func (*Detections) Release

func (d *Detections) Release(ctx context.Context, appIDs []string) (int64, error)

Release puts detections this replica claimed back in the queue, at a shutdown that stopped them part-way. Not counted as an attempt.

func (*Detections) Save

func (d *Detections) Save(ctx context.Context, appID, status string, body any, commit string) error

Save records the outcome of a detection run.

func (*Detections) SaveAnswers

func (d *Detections) SaveAnswers(ctx context.Context, appID string, answers map[string]string) error

SaveAnswers merges answers into an app's detection.

Merged rather than replaced: the console posts answers as they are given, which is the workflow R-105 describes — one question at a time, pasted into an assistant and pasted back.

func (*Detections) Start

func (d *Detections) Start(ctx context.Context, appID string) error

Start marks detection as running for an app, and queues it: running with no replica running it, until a replica's detection queue claims it (Claim, issue #72, O-32).

Answers are preserved across a re-run. Someone who answered "which service is primary" should not be asked again because detection was re-run for an unrelated reason (R-022 makes re-detection explicit, not free).

func (*Detections) Summaries

func (d *Detections) Summaries(ctx context.Context, appIDs []string) (map[string]DetectionSummary, error)

Summaries returns the detection summary of each of apps that has been through detection, keyed by app ID. An app that never has — one created from a spec — is absent.

type Disk

type Disk struct {
	// contains filtered or unexported fields
}

Disk is the state the disk pass reads and writes (R-403, issue #130).

func NewDisk

func NewDisk(db *DB) *Disk

NewDisk returns the disk pass's store.

func (*Disk) DiskCandidates

func (d *Disk) DiskCandidates(ctx context.Context) ([]DiskApp, error)

DiskCandidates lists every app meant to be running with a pinned spec. A failed app is left out: it is not running, and a failed app stays failed (R-151).

func (*Disk) SetDiskMark

func (d *Disk) SetDiskMark(ctx context.Context, app DiskApp, which DiskMark, at *time.Time) (bool, error)

SetDiskMark records, or with nil clears, one of an app's disk marks. Only on the run of the app the pass read: false, and nothing written, when it has been stopped, deleted or restarted since (CodeRabbit on #180).

func (*Disk) StopForDisk

func (d *Disk) StopForDisk(ctx context.Context, app DiskApp) (bool, error)

StopForDisk stops an app and records why, in one write, as StopForIdle does: the visitor page says why in the same moment the app stops. Only the run of the app the pass read, still holding the over-limit mark the pass read, and not failed (R-151): one its owner stopped or restarted since is left as they left it, so a stop always follows two readings over in the same run (R-403, CodeRabbit on #180).

type DiskApp

type DiskApp struct {
	AppID       string
	Name        string
	OwnerUserID string
	RuntimeRef  string
	DiskBytes   int64
	WarnedAt    *time.Time
	OverAt      *time.Time
	// StartedAt is when the app was last started. Every write the pass makes
	// requires it unchanged, so nothing read before a restart lands after it.
	StartedAt *time.Time
}

DiskApp is a running app the disk pass measures: its limit and runtime from its pinned spec, and what Pando has already said about it.

type DiskMark

type DiskMark string

DiskMark is one of the two marks the pass keeps on an app.

const (
	// DiskWarned is when the owner was told the app was near its limit.
	DiskWarned DiskMark = "warned"
	// DiskOver is the first reading over the limit.
	DiskOver DiskMark = "over"
)

type EdgeCertificate

type EdgeCertificate struct {
	Name     string
	Domains  []string
	CertPEM  []byte
	KeyPEM   secret.Value
	NotAfter time.Time // zero until the first issuance succeeds

	AttemptedAt time.Time
	LastError   string
}

EdgeCertificate is one certificate as stored.

type EdgeCertificates

type EdgeCertificates struct {
	// contains filtered or unexported fields
}

EdgeCertificates keeps the certificates Pando issues for the edge, its ACME account, and pending HTTP-01 challenges (migration 000052; R-169, R-174).

The account key and each certificate with its key are sealed by the install's secrets adapter, as adapter credentials are (R-190): the rows hold ciphertext or an external reference, never PEM.

func NewEdgeCertificates

func NewEdgeCertificates(db *DB, adapter api.SecretsAdapter, adapterRef string) *EdgeCertificates

func (*EdgeCertificates) Account

func (e *EdgeCertificates) Account(ctx context.Context, email, directory string) (AcmeAccount, bool, error)

Account reads the account for an email address at a CA.

func (*EdgeCertificates) Certificate

func (e *EdgeCertificates) Certificate(ctx context.Context, name string) (EdgeCertificate, bool, error)

Certificate reads one certificate. A row with only a failure recorded is returned with no PEM.

func (*EdgeCertificates) Challenge

func (e *EdgeCertificates) Challenge(ctx context.Context, token string) (string, bool, error)

Challenge is the key authorization for a pending token, unexpired.

func (*EdgeCertificates) DeleteChallenge

func (e *EdgeCertificates) DeleteChallenge(ctx context.Context, token string) error

DeleteChallenge removes a challenge once the CA has checked it, and any that have expired.

func (*EdgeCertificates) PutAccount

func (e *EdgeCertificates) PutAccount(ctx context.Context, a AcmeAccount) error

PutAccount stores the account key, sealed, and its registration.

func (*EdgeCertificates) PutCertificate

func (e *EdgeCertificates) PutCertificate(ctx context.Context, c EdgeCertificate) error

PutCertificate stores a newly issued certificate, clearing any failure.

func (*EdgeCertificates) PutChallenge

func (e *EdgeCertificates) PutChallenge(ctx context.Context, token, keyAuthorization, domain string, expires time.Time) error

PutChallenge records a pending HTTP-01 challenge for any replica to answer.

func (*EdgeCertificates) RecordFailure

func (e *EdgeCertificates) RecordFailure(ctx context.Context, name string, domains []string, message string, at time.Time) error

RecordFailure notes a failed issuance, keeping any certificate already held.

type ErrAssigned

type ErrAssigned struct{ Held AIAssignment }

ErrAssigned is returned by Put when another adapter holds the function. Held names that adapter.

func (ErrAssigned) Error

func (e ErrAssigned) Error() string

type Event

type Event struct {
	ID         string         `json:"id"`
	Name       string         `json:"name"`
	AppID      string         `json:"app_id,omitempty"`
	ActorKind  string         `json:"actor_kind"`
	ActorID    string         `json:"actor_id,omitempty"`
	OnBehalfOf string         `json:"on_behalf_of,omitempty"`
	RequestID  string         `json:"request_id,omitempty"`
	Data       map[string]any `json:"data"`
	OccurredAt time.Time      `json:"occurred_at"`
}

Event is one row of the outbox (issue #50, R-366).

type Events

type Events struct {
	// contains filtered or unexported fields
}

Events is the outbox.

func NewEvents

func NewEvents(db *DB) *Events

func (*Events) Emit

func (s *Events) Emit(ctx context.Context, e Event) (string, error)

Emit records an event core raises itself — adapter health, for one.

func (*Events) EmitRouted

func (s *Events) EmitRouted(ctx context.Context, e Event, subscriptionID string) (Delivery, error)

EmitRouted records an event that goes to one subscription and no other — a test delivery — and makes that delivery, in one transaction.

func (*Events) ForApp

func (s *Events) ForApp(ctx context.Context, appID, before string, limit int) ([]Event, error)

ForApp returns an app's recent events, newest first, older than the event ID before when one is given (R-378). As long as the outbox keeps them: this is a feed, and the audit log is the history.

func (*Events) Get

func (s *Events) Get(ctx context.Context, eventID string) (Event, bool, error)

Get reads one event.

func (*Events) Prune

func (s *Events) Prune(ctx context.Context, before time.Time, limit int) (int64, error)

Prune removes up to limit events older than before that have nothing left to deliver, and their deliveries and delivery attempts with them, and returns how many it removed. The outbox is not a history: the audit log is (R-224, R-366).

A batch at a time, so a backlog of a month's events is removed in short transactions the retention job repeats rather than in one that holds locks across the whole outbox (issue #72). The cascade into event_deliveries is by event_id, which is indexed (migration 000050).

func (*Events) Route

func (s *Events) Route(ctx context.Context, limit int, before time.Time, route func(context.Context, Event) ([]string, error)) ([]Event, error)

Route takes up to limit events nobody has routed yet that occurred before before (all of them, when it is zero), oldest first, asks route which subscriptions each one goes to, and queues a delivery for each, all in one transaction. Another Pando routing at the same time skips the rows this one holds, so an event is routed once.

An event route cannot decide about is left for the next pass rather than marked routed: a database hiccup must not lose an event (R-366).

The cutoff is for a router matching against a cached list of subscriptions: an event newer than the list could be meant for a subscription made after it, so it waits for the next list.

type Execer

type Execer interface {
	Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}

Execer is a pool or a transaction.

type FailedAttempts

type FailedAttempts struct {
	// contains filtered or unexported fields
}

FailedAttempts counts wrong guesses at something secret, for the limits on guessing: a passcode, per app and client address (R-075a), and a password at sign-in, per username and per client address (R-404). A key says which.

In the database rather than in memory (issue #72): with N replicas behind a load balancer, a count kept per process gave an attacker N times the attempts, and one more set every time a replica restarted.

func NewFailedAttempts

func NewFailedAttempts(db *DB) *FailedAttempts

func (*FailedAttempts) Clear

func (p *FailedAttempts) Clear(ctx context.Context, key string) error

Clear forgets key's failures, after a right guess.

func (*FailedAttempts) Fail added in v0.14.0

func (p *FailedAttempts) Fail(ctx context.Context, res Reservation) error

Fail settles a reservation's pending failures as failures: the guess was wrong.

func (*FailedAttempts) Prune

func (p *FailedAttempts) Prune(ctx context.Context, window time.Duration) error

Prune forgets every failure older than window: keys nobody tried again.

func (*FailedAttempts) Recent

func (p *FailedAttempts) Recent(ctx context.Context, key string, window time.Duration) (int, error)

Recent counts failures for key within window.

func (*FailedAttempts) Record

func (p *FailedAttempts) Record(ctx context.Context, key string, window time.Duration) error

Record counts one failure for key, and forgets failures older than window for it, which nothing will count again.

func (*FailedAttempts) Release added in v0.14.0

func (p *FailedAttempts) Release(ctx context.Context, res Reservation) error

Release takes back the failures a reservation counted: the guess was right, or it was never checked. One row per held failure, matched by key and time.

func (*FailedAttempts) Reserve added in v0.14.0

func (p *FailedAttempts) Reserve(ctx context.Context, limits ...AttemptLimit) (Reservation, error)

Reserve counts one pending failure against every limit before the guess is checked, unless one is already reached (R-075a, R-404, R-405). Counting first and in one transaction under each key's lock is what holds a burst of concurrent attempts to the limit: reading the count and recording the failure after the check let every request in the burst see the same count.

A limit reached only because attempts are still being checked is not a refusal yet: Reserve waits, up to PendingWait, for them to settle, so that concurrent right passwords are never refused because of each other. The caller settles the reservation with Fail when the guess was wrong and Release when it was right or not a guess at all.

func (*FailedAttempts) Wait

func (p *FailedAttempts) Wait(ctx context.Context, key string, window time.Duration) (time.Duration, error)

Wait is how long until the oldest failure for key within window leaves it, which is when one more attempt is allowed once a limit is reached. Zero when there is none. Measured by the database's clock, which recorded them.

type GrantRow

type GrantRow struct {
	ID            string `json:"id"`
	AppID         string `json:"app_id"`
	Plane         string `json:"plane"`
	PrincipalKind string `json:"principal_kind"`
	PrincipalID   string `json:"principal_id,omitempty"`
	RoleID        string `json:"role_id,omitempty"`

	// PrincipalName is who the principal is, for a screen a person reads.
	//
	// Carried here rather than joined by the caller because the caller cannot.
	// This list is read with app.view, and resolving a name from /users needs
	// install.view — so an app's owner, who is the person this screen exists
	// for, would be refused the second request and left looking at identifiers.
	// The API is the product (R-261); a surface that cannot render a name
	// without a permission it has no business holding is an API gap.
	//
	// Empty for an anonymous grant, which is not a person and is rendered as a
	// sentence rather than a name (R-077).
	PrincipalName string `json:"principal_name,omitempty"`

	// RoleName is what the role is called, for the same reason as above:
	// resolving one from /roles needs install.view, which the app owner reading
	// this screen does not hold. Empty on a data-plane grant, which carries no
	// role at all (R-070).
	RoleName string `json:"role_name,omitempty"`

	// Passcode is whether the anonymous grant asks for one (R-075a). Never the
	// passcode or its digest: nobody reads a passcode back, they set a new one.
	Passcode bool `json:"passcode,omitempty"`
}

Grant is a stored grant.

type Grants

type Grants struct {
	// contains filtered or unexported fields
}

Grants records who may do what with an app.

func NewGrants

func NewGrants(db *DB) *Grants

func (*Grants) AnonymousGrantFor

func (g *Grants) AnonymousGrantFor(ctx context.Context, appID string) (AnonymousGrant, bool, error)

AnonymousGrantFor returns the app's anonymous grant, if it has one.

func (*Grants) Create

func (g *Grants) Create(ctx context.Context, appID, plane, kind, principalID, roleID, createdBy string) (GrantRow, error)

Create adds a grant.

The shapes are enforced by the database — a data-plane grant carries no role (R-070), only an anonymous grant has a null principal (R-074), and the anonymous grant cannot be inserted twice. This method translates those refusals into something a person can act on rather than surfacing a constraint name.

func (*Grants) Delete

func (g *Grants) Delete(ctx context.Context, appID, grantID string) error

func (*Grants) ForGroup

func (g *Grants) ForGroup(ctx context.Context, groupID string, page Page) ([]UserAppGrant, string, error)

ForGroup returns a group's own app grants, what everyone in it gets, for one page of apps by name (the cursor is the last app's), with the cursor for the next page.

func (*Grants) ForUser

func (g *Grants) ForUser(ctx context.Context, userID string, page Page) ([]UserAppGrant, string, error)

ForUser returns every app grant that reaches a person — their own, and their groups' (resolved live, R-079) — for one page of apps by name, with the cursor for the next page. Install grants are not app grants and are not here.

func (*Grants) GrantInstall

func (g *Grants) GrantInstall(ctx context.Context, kind, principalID, roleID, createdBy string) (GrantRow, error)

GrantInstall gives a principal an installation-wide control-plane grant (O-17).

Install grants are deliberately not reachable through Create: an app-scoped grant and an install-scoped one are different powers, and a caller that can pass an empty app ID into the sharing path would be able to make an administrator out of a sharing request. They are separate methods so the callers are separate too — this one is reached from bootstrap, and will be reached by the accounts screen's promote/demote route when that exists; never from `POST /apps/{id}/grants`.

There is no anonymous install grant, and the schema is what says so: an anonymous grant has a NULL principal (R-074) and this refuses an empty one.

A principal already holding an install grant has its role replaced rather than a second row added — the schema allows exactly one control grant per principal install-wide, so "grant administrator to someone who is already a viewer" has only one sensible meaning. That also makes it idempotent, which is what the bootstrap path needs: it runs on every start.

func (*Grants) InstallRoleOf added in v0.8.0

func (g *Grants) InstallRoleOf(ctx context.Context, kind, principalID string) (string, bool, error)

InstallRoleOf returns the role of the installation-wide grant made to this principal itself, if there is one — for a group, what everyone in it holds through it (R-078), which is what changing its membership hands on (R-080a).

func (*Grants) InstallRoles

func (g *Grants) InstallRoles(ctx context.Context) ([]authz.Role, error)

InstallRoles returns the roles that may be granted installation-wide (R-082).

Read from the roles table rather than hardcoded, so a custom install-scoped role appears in the console the moment it exists without a second change here.

func (*Grants) InstallRolesByPrincipal

func (g *Grants) InstallRolesByPrincipal(ctx context.Context) (map[string]string, error)

InstallRolesByPrincipal maps each principal holding an install grant to its role ID — the grant made to that principal itself, for the groups list. An account's roles, through its groups too, are InstallRolesForUsers.

One query rather than one per account: the list is small (R-015 — one install, one organization) but a per-row lookup is the shape that stops being small quietly.

func (*Grants) InstallRolesForUsers added in v0.6.0

func (g *Grants) InstallRolesForUsers(ctx context.Context, userIDs []string) (map[string][]HeldInstallRole, error)

InstallRolesForUsers returns every installation role each of these accounts holds, directly and through groups — the same grants InstallGrantsFor reads for authorization, so the accounts screen and the authorizer give one answer (R-078, issue #221). Group membership is read through effective_group_members, so a provider group linked to a Pando group counts.

One query for a page of accounts rather than one per account. Each account's roles come direct first, then by group name; an account holding nothing is absent from the map.

func (*Grants) ListForApp

func (g *Grants) ListForApp(ctx context.Context, appID string, page Page) ([]GrantRow, string, error)

ListForApp returns one page of an app's grants, with the cursor for the next page (empty after the last).

Keyset-paged by principal (grants_app_list_idx), so one principal's grants on both planes arrive together, and the grant to everyone — principal kind "anonymous", which sorts first — is on the first page. An app shared with every person by name is as many grants as people (issue #72).

func (*Grants) RevokeInstall

func (g *Grants) RevokeInstall(ctx context.Context, kind, principalID string) error

RevokeInstall removes a principal's installation-wide grant.

Refuses to remove the last account that can manage accounts (R-088). Without that check the install becomes unadministrable in one click and the only way back is a psql prompt — which is the same lockout O-17 allowed by accident, now reachable on purpose. The check and the delete share a transaction, so two administrators removing each other at the same moment cannot both succeed.

The rule is stated in terms of `install.users.manage` rather than "the administrator role", because a custom role (R-082) holding that verb is just as much an administrator and a rule naming the built-in role would not see it.

func (*Grants) SetPasscode

func (g *Grants) SetPasscode(ctx context.Context, appID, grantID, passcodeHash string) error

SetPasscode sets, changes or (with an empty digest) removes the passcode on an app's anonymous grant, and ends every unlock made under the old one — in one transaction, so there is no moment when the new passcode is set and the old visitors are still let in.

func (*Grants) SetRole

func (g *Grants) SetRole(ctx context.Context, appID, grantID, roleID string) error

Delete revokes a grant. SetRole changes the role a control-plane grant carries, in one statement — not a revoke and a new grant, which would leave the person with nothing if the second half failed. A data-plane grant carries no role (R-070) and is refused, and so is an installation role: the composite foreign key on (role_id, role_scope) refuses it, and that becomes a sentence here.

func (*Grants) Unlock

func (g *Grants) Unlock(ctx context.Context, appID, grantID string) (string, time.Time, error)

Unlock records that a visitor entered the app's passcode, and returns the token their browser keeps. Only its SHA-256 is stored: a copy of this table lets nobody in.

type Group

type Group struct {
	ID   string `json:"id"`
	Name string `json:"name"`

	// Source is the identity provider that owns the group's membership, and
	// SourceName its name; both empty for a group made in Pando. A synced
	// group's members change at the provider, never here (R-079).
	Source     string `json:"source,omitempty"`
	SourceName string `json:"source_name,omitempty"`

	Members []string `json:"members,omitempty"`

	// MemberCount is how many people are in the group directly. Set by
	// ListPage, which leaves Members out: a page of groups carrying every
	// member of each is a page that grows with the organization (issue #72).
	// GET /groups/{id} has the members.
	MemberCount *int `json:"member_count,omitempty"`

	// LinkedFrom, on a Pando-made group, are the provider groups whose members
	// count as its members (group_links). LinksTo, on a synced group, are the
	// Pando groups it feeds.
	LinkedFrom []string `json:"linked_from,omitempty"`
	LinksTo    []string `json:"links_to,omitempty"`

	CreatedAt time.Time `json:"created_at"`
}

Group is a stored group.

type GroupFilter

type GroupFilter struct {
	// Member keeps only the groups this user is directly in.
	Member string
}

GroupFilter narrows ListPage.

type GroupLimit

type GroupLimit struct {
	GroupID string
	Name    string
	MaxApps int
}

GroupLimit is a group's own limit, as one of a person's groups.

type Groups

type Groups struct {
	// contains filtered or unexported fields
}

Groups are collections of users (R-078).

Pando-native here. An IdP may also push groups (R-048), and when it does the membership arrives the same way — which is why membership is a table rather than a field, and why authorization reads it live (R-079) instead of denormalizing it into grants.

func NewGroups

func NewGroups(db *DB) *Groups

func (*Groups) AddMember

func (g *Groups) AddMember(ctx context.Context, groupID, userID string) error

AddMember puts one person in a group. Adding someone already in it is not an error: the outcome holds. Refused for a group synced from an identity provider, whose membership belongs to the provider (R-079).

func (*Groups) ByID

func (g *Groups) ByID(ctx context.Context, groupID string) (Group, bool, error)

ByID returns one group, with how many people are in it directly and its links, but not who: a group can hold the whole organization, and the members come a page at a time from MembersPage (issue #72).

func (*Groups) Create

func (g *Groups) Create(ctx context.Context, name string) (Group, error)

Create adds a Pando-native group.

func (*Groups) Delete

func (g *Groups) Delete(ctx context.Context, groupID string) error

Delete removes a group. Grants made to it go with it — the people in it lose whatever the group gave them, and keep anything given to them directly.

Refused when it would leave nobody who can manage accounts (R-088): a group can hold an administrator role like anyone else, and deleting it is the same lockout as revoking that grant directly.

func (*Groups) Exists

func (g *Groups) Exists(ctx context.Context, groupID string) (bool, error)

Exists reports whether there is a group with this ID: one primary-key lookup, for a caller that needs nothing else about it.

func (g *Groups) Link(ctx context.Context, syncedGroupID, groupID, by string) error

Link makes everyone in a provider's group count as a member of a Pando-made group, live (R-079).

func (*Groups) List

func (g *Groups) List(ctx context.Context) ([]Group, error)

List returns every group with its members.

func (*Groups) ListPage

func (g *Groups) ListPage(ctx context.Context, page Page, f GroupFilter) ([]Group, string, int, error)

ListPage is one page of groups — Pando's own first, then providers', each by name — with the cursor for the next page and how many match in all. Each carries MemberCount rather than Members, and its links.

Page.Query matches the name.

func (*Groups) MembersPage

func (g *Groups) MembersPage(ctx context.Context, groupID string, page Page) ([]User, string, int, error)

MembersPage is one page of the people directly in a group, oldest account first, with the cursor for the next page and how many match (O-53). In account-ID order because group_members' primary key is in that order, so a page reads a page of rows however large the group. Page.Query matches the username, display name or email; Page.IDs keeps only those accounts, which is how a client asks whether particular people are members.

func (*Groups) RemoveMember

func (g *Groups) RemoveMember(ctx context.Context, groupID, userID string) error

RemoveMember takes one person out of a group, and with them whatever the group gave them. Refused when it would leave nobody who can manage accounts (R-088) — the last member of a group holding the administrator role is as much the last administrator as a direct grant is.

func (*Groups) Rename added in v0.15.0

func (g *Groups) Rename(ctx context.Context, groupID, name string) (string, error)

Rename changes the name of a group made in Pando, and returns the name it had. A group an identity provider syncs keeps the provider's name: the provider names it at every sign-in and push, and matches on it (R-078).

func (*Groups) Search

func (g *Groups) Search(ctx context.Context, q string, limit int) ([]Group, error)

Search finds groups by name, case-insensitively, at most limit of them.

func (*Groups) SetMembers

func (g *Groups) SetMembers(ctx context.Context, groupID string, userIDs []string) error

SetMembers replaces a group's membership.

Replaces rather than merges, in one transaction. A merge cannot express "remove this person", and removing someone from a group is the operation that has to work — it is how access is revoked (R-079).

func (*Groups) SyncMemberships

func (g *Groups) SyncMemberships(ctx context.Context, adapterID, userID string, names []string) error

SyncMemberships sets which of a provider's groups a person is in, from the group names a sign-in carried: groups not seen before are made, and the person leaves any of the provider's groups the sign-in did not name. Groups made in Pando, and other providers' groups, are untouched.

Refused, and nothing changed, when it would leave nobody who can manage accounts (R-088) — the same rule as removing someone from a group by hand.

func (g *Groups) Unlink(ctx context.Context, syncedGroupID, groupID string) error

Unlink removes a link. Refused when it would leave nobody who can manage accounts (R-088).

type HeldAttempt added in v0.14.0

type HeldAttempt struct {
	Key string
	At  time.Time
}

HeldAttempt is one failure Reserve counted ahead of the check: its key and the time it was counted at, which is how Release finds it again.

type HeldInstallRole added in v0.6.0

type HeldInstallRole struct {
	RoleID string `json:"role_id"`
	// RoleName travels with the role so that whoever may read the account
	// can read what it holds — your own account needs no install.view, and
	// the roles list does (issue #221).
	RoleName string         `json:"role_name"`
	Via      InstallRoleVia `json:"via"`
}

HeldInstallRole is one installation role an account holds, and how.

type Hold added in v0.17.0

type Hold struct {
	// Promoting is a promotion of its standby under way.
	Promoting bool
	// Home is the host the app's standby row names as its home, and
	// HomeLost and HomeFenced whether that host is marked lost or says it
	// is fenced.
	Home       string
	HomeLost   bool
	HomeFenced bool
}

Hold is why the reconciler must leave an app alone (R-148, R-153, R-421).

type Host added in v0.17.0

type Host struct {
	ID         string     `json:"id"`
	Name       string     `json:"name"`
	Roles      []string   `json:"roles"`
	Address    string     `json:"address"`
	CertSHA256 string     `json:"cert_sha256"`
	Platform   string     `json:"platform,omitempty"`
	Version    string     `json:"version,omitempty"`
	JoinedWith string     `json:"joined_with,omitempty"`
	JoinedAt   time.Time  `json:"joined_at"`
	LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
	// PrevCertSHA256 is the certificate the host held before its latest
	// renewal, still its own until it presents the new one (R-421).
	PrevCertSHA256 string `json:"-"`
	// Lost is that the host is marked lost (R-421, lost_at).
	Lost bool `json:"-"`

	// PingAddress and PingCertSHA256 are where a control host answers the
	// cluster's liveness checks, its cluster listener, and the fingerprint
	// of the certificate it answers with (R-421, migration 000091). Empty is
	// the agent's Address and CertSHA256, which an app host answers on.
	// Address, CertSHA256 and PrevCertSHA256 stay the agent's: runtime calls
	// go through it (R-416, Pins), so a host with both roles keeps running
	// apps.
	PingAddress    string `json:"-"`
	PingCertSHA256 string `json:"-"`

	// Pending is a host whose join is recorded and that has not yet
	// presented the certificate it was issued (migration 000093): it is not
	// listed (Hosts) and takes no part in the cluster until it does
	// (ConfirmJoin), and its row is removed after PendingJoinLifetime or when
	// its name joins again with the same key (R-415).
	Pending bool `json:"-"`
	// KeySHA256 is the SHA-256 of the public key the host joined with, hex
	// (migration 000095): a pending join is replaced only by a join with the
	// same key, the same joining host trying again (R-415). Empty for hosts
	// recorded before it and for the install's own first host.
	KeySHA256 string `json:"-"`
	// NextCertPEM is the certificate the host's latest renewal answered
	// with, until the host presents it (R-421): a renewal presenting the
	// certificate before it, for the same key, is answered with it again.
	NextCertPEM []byte `json:"-"`
}

Host is one machine in the install's cluster (R-414).

func (Host) HoldsCert added in v0.17.0

func (h Host) HoldsCert(sha string) bool

HoldsCert reports whether the certificate with fingerprint sha is the host's: the one it joined or last renewed with, the one before it, or the one a control host answers pings with.

func (Host) Pins added in v0.17.0

func (h Host) Pins() string

Pins is every certificate fingerprint that is the host's agent's now, comma separated, newest first: what a connection to its agent accepts (hostagent.PinnedClientTLS).

type Icon

type Icon struct {
	ContentType string
	Data        []byte
	UpdatedAt   time.Time
}

Icon is an app's tile image (R-340).

type Idempotency

type Idempotency struct {
	// contains filtered or unexported fields
}

Idempotency replays the result of a request that has already been made.

For MCP above all (R-262): an agent retries on a timeout, and a retried deploy that deploys twice is the failure mode that makes agents dangerous to give infrastructure to. A deploy resolves a ref — which means cloning — and regularly outlasts a client's patience, so the retry is ordinary rather than exceptional.

func NewIdempotency

func NewIdempotency(db *DB) *Idempotency

func (*Idempotency) Lookup

func (i *Idempotency) Lookup(ctx context.Context, key, principalID, endpoint string) (Replayed, bool, error)

Lookup returns a previous response for this key, if there is one.

func (*Idempotency) Remember

func (i *Idempotency) Remember(ctx context.Context, key, principalID, endpoint string, status int, body []byte) error

Remember stores a response for replay.

ON CONFLICT DO NOTHING: two concurrent requests with the same key both do the work — this does not prevent that, and claiming otherwise would be a lie about a race it cannot win without locking the endpoint. What it prevents is the far more common sequential retry, which is the case that actually happens.

type Identities

type Identities struct {
	// contains filtered or unexported fields
}

Identities reads and writes user_identities.

func NewIdentities

func NewIdentities(db *DB) *Identities

func (*Identities) ByVerifiedEmail

func (s *Identities) ByVerifiedEmail(ctx context.Context, email string) (User, bool, error)

ByVerifiedEmail finds the one active account with this email, for linking by an email a provider vouches for. More than one match is no match: a guess between two people is how one signs in as the other. An email the account's holder set themselves is not matched at all: they could have typed anyone's (R-045).

func (*Identities) CreateExternal

func (s *Identities) CreateExternal(ctx context.Context, n NewExternal) (User, error)

CreateExternal makes an account for an external identity, in one transaction with the identity that reaches it. Used by a first sign-in (just-in-time) and by a SCIM push.

func (*Identities) ForUser

func (s *Identities) ForUser(ctx context.Context, userID string) ([]Identity, error)

ForUser lists the identities that reach an account.

func (s *Identities) Link(ctx context.Context, userID, adapterID, externalID, by string, replace, confirm bool) (string, error)

Link attaches an external identity to an account (O-1). An alias is added; nothing merges and no users.id changes.

When the identity already reaches another account, it moves only if replace is set. The account it leaves becomes an alias of this one if that identity was its only way in — suspended, its sessions ended, and never deleted, because its ID may be an assertion subject an app holds data under (R-054).

An alias that is the last active owner of an app leaves the app with none, which host policy may stop (R-408). Unless confirm is set, that is refused, naming the apps (R-409).

Returns the account that became an alias, if any.

func (*Identities) RecordSignIn

func (s *Identities) RecordSignIn(ctx context.Context, adapterID, externalID, userID, email, displayName string) error

RecordSignIn notes a sign-in, and refreshes the account's email and name from the provider when the account came from it. A linked account's profile is its own and is not overwritten by whichever provider signed it in.

func (*Identities) Resolve

func (s *Identities) Resolve(ctx context.Context, adapterID, externalID string) (User, bool, error)

Resolve finds the account an external identity signs in to. Deleted and suspended accounts are returned as they are, for the caller to refuse: treating a deleted account's identity as unknown would let a first sign-in make a new account for someone an administrator removed.

func (s *Identities) Unlink(ctx context.Context, userID, adapterID, externalID string) error

Unlink removes an identity from an account. An account left with no way in can still be reached by linking one again.

type Identity

type Identity struct {
	AdapterID   string     `json:"adapter_id"`
	AdapterName string     `json:"adapter_name"`
	AdapterKind string     `json:"adapter_kind"`
	ExternalID  string     `json:"external_id"`
	UserID      string     `json:"user_id"`
	Origin      bool       `json:"origin"`
	SCIM        bool       `json:"scim"`
	LastSignIn  *time.Time `json:"last_sign_in_at,omitempty"`
	CreatedBy   string     `json:"created_by"`
	CreatedAt   time.Time  `json:"created_at"`
}

Identity is one way into an account: an external provider's subject, bound to a users.id (O-1, design 02 §2.1).

type IdentityProvider

type IdentityProvider struct {
	ID      string          `json:"id"`
	Kind    string          `json:"kind"`
	Name    string          `json:"name"`
	Config  json.RawMessage `json:"config"`
	Enabled bool            `json:"enabled"`

	// JITProvisioning makes an account at a first sign-in. Off unless an
	// administrator turns it on; host policy can refuse it everywhere.
	JITProvisioning bool `json:"jit_provisioning"`

	// LinkByEmail matches a first sign-in to an existing account by an email
	// the provider vouches for (O-1). Off unless turned on.
	LinkByEmail bool `json:"link_by_email"`

	// SCIM is on when a token is set. The token itself is shown once and
	// kept only as a digest.
	SCIMEnabled           bool       `json:"scim_enabled"`
	SCIMTokenCreatedAt    *time.Time `json:"scim_token_created_at,omitempty"`
	SCIMIdentityAttribute string     `json:"scim_identity_attribute"`

	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

IdentityProvider is a configured identity adapter instance (R-040, R-045): the local one seeded on every install, and any an administrator adds.

type IdentityProviders

type IdentityProviders struct {
	// contains filtered or unexported fields
}

IdentityProviders reads and writes identity_adapters.

func NewIdentityProviders

func NewIdentityProviders(db *DB) *IdentityProviders

func (*IdentityProviders) AnyExternalEnabled

func (s *IdentityProviders) AnyExternalEnabled(ctx context.Context) (bool, error)

AnyExternalEnabled reports whether an external provider is on — the condition for turning password sign-in off.

func (*IdentityProviders) ByID

func (s *IdentityProviders) ByID(ctx context.Context, providerID string) (IdentityProvider, bool, error)

ByID returns one provider.

func (*IdentityProviders) BySCIMToken

func (s *IdentityProviders) BySCIMToken(ctx context.Context, digest string) (IdentityProvider, bool, error)

BySCIMToken finds the provider a SCIM bearer token belongs to, by digest.

func (*IdentityProviders) Create

Create adds a provider.

func (*IdentityProviders) Delete

func (s *IdentityProviders) Delete(ctx context.Context, providerID string) error

Delete removes a provider nobody has used. One that has signed anyone in is referenced by their account, their sessions and the audit log, and is disabled instead — deleting it would free its identities for reuse.

func (*IdentityProviders) List

List returns every provider, the local one first.

func (*IdentityProviders) SetSCIMToken

func (s *IdentityProviders) SetSCIMToken(ctx context.Context, providerID, digest string) error

SetSCIMToken turns SCIM on, or rotates its token, storing only the digest. An empty digest turns SCIM off.

func (*IdentityProviders) Update

func (s *IdentityProviders) Update(ctx context.Context, providerID string, set ProviderSettings) (IdentityProvider, error)

Update changes a provider's settings.

type IdleApp

type IdleApp struct {
	AppID          string
	Name           string
	OwnerUserID    string
	State          string
	DesiredState   string
	StoppedForIdle bool

	// StopDays and DeleteDays are in force for this app: its own, or the
	// install's. Zero is off.
	StopDays   int
	DeleteDays int

	LastActivity    time.Time
	StopNoticedAt   *time.Time
	DeleteNoticedAt *time.Time
}

IdleApp is an app the idle pass may have something to do for.

type IdleDefaults

type IdleDefaults struct {
	StopDays   int
	DeleteDays int
	NoticeDays int
}

IdleDefaults is host policy's part of the question: the install's days, and the notice every action waits out.

type IdleNotice

type IdleNotice string

IdleNotice is which action a notice is for.

const (
	IdleNoticeStop   IdleNotice = "stop"
	IdleNoticeDelete IdleNotice = "delete"
)

type IdleSettings

type IdleSettings struct {
	StopDays   *int `json:"stop_days"`
	DeleteDays *int `json:"delete_days"`
}

IdleSettings is an app's own idle settings (R-397). Nil is the install's; zero is off.

type IdleStatus

type IdleStatus struct {
	IdleSettings
	DesiredState    string
	State           string
	StoppedForIdle  bool
	LastActivity    time.Time
	StopNoticedAt   *time.Time
	DeleteNoticedAt *time.Time
}

IdleStatus is an app's idle settings and where its clock stands.

type InstallRoleVia added in v0.6.0

type InstallRoleVia struct {
	Kind       string `json:"kind"`
	GroupID    string `json:"group_id,omitempty"`
	Name       string `json:"name,omitempty"`
	Source     string `json:"source,omitempty"`
	SourceName string `json:"source_name,omitempty"`
}

InstallRoleVia says where a held installation role comes from: a grant to the account itself ("direct"), or one to a group it is in ("group"), with that group's name and, for a group synced from an identity provider, the provider (as on a Group).

type Issued

type Issued struct {
	Token  Token        `json:"token"`
	Secret secret.Value `json:"secret"`
}

Issued is a freshly minted token. Secret is present exactly once, here (R-063) — it is hashed on the way into the database and cannot be recovered.

Secret is a secret.Value, so serializing this struct redacts it. A handler returning the plaintext to its one legitimate caller must reveal it deliberately, which is the point: there is no path where it leaks by forgetting.

type IssuedJoinToken added in v0.17.0

type IssuedJoinToken struct {
	Token  JoinToken    `json:"token"`
	Secret secret.Value `json:"secret"`
}

IssuedJoinToken is a freshly minted join token. Secret is "<id>.<secret>" and is present exactly once, here: only its keyed digest is stored (R-063).

type JoinToken added in v0.17.0

type JoinToken struct {
	ID        string     `json:"id"`
	Roles     []string   `json:"roles"`
	MaxUses   int        `json:"max_uses"`
	Uses      int        `json:"uses"`
	CreatedBy string     `json:"created_by"`
	CreatedAt time.Time  `json:"created_at"`
	ExpiresAt time.Time  `json:"expires_at"`
	RevokedAt *time.Time `json:"revoked_at,omitempty"`
}

JoinToken is a stored join token, without its secret (R-415).

type Leadership

type Leadership struct {
	// contains filtered or unexported fields
}

Leadership is the lock that makes one replica the one that runs the install-wide background jobs (issue #72): garbage collection, auto-deploy, audit retention, and the rest of what must happen once per install rather than once per process.

A session advisory lock on a connection held for as long as the replica leads. If the process dies the connection closes and Postgres releases the lock itself; there is no lease to expire and no clock to trust. A leader that loses its connection stops leading at once (Lost closes), and another replica takes over at its next attempt.

func (*Leadership) Lost

func (l *Leadership) Lost() <-chan struct{}

Lost is closed when this replica stops leading without having resigned.

func (*Leadership) Resign

func (l *Leadership) Resign(ctx context.Context)

Resign gives the lock up. Safe to call after Lost has closed.

type Lease

type Lease struct {
	// contains filtered or unexported fields
}

Lease is one reconciliation pass's hold on the apps it claimed.

Two passes must never work on one app at once, on one replica or across several. That used to be a session advisory lock, which held a pooled connection for the whole reconciliation — Apply included, which can take minutes — while the reconciliation took more connections of its own. A lease is a column, so holding one holds nothing (issue #72).

func (*Lease) Claim

func (l *Lease) Claim(ctx context.Context, now, cutoff time.Time, limit int, lease time.Duration) ([]Reconcilable, error)

Claim leases up to limit apps the loop should reconcile now.

Due means: in a state the loop acts on, out of backoff, and neither held by another pass nor visited since cutoff. Least recently visited first, so every app gets its turn: ordering by updated_at, as this used to, visited the same 200 apps forever because a healthy pass changes nothing on the row. SKIP LOCKED hands each concurrent claimant — another replica, usually — a disjoint set rather than the same rows to contend for.

Apps in backoff are filtered here rather than skipped in the loop, so an app waiting five minutes costs one row in a WHERE clause instead of a goroutine per tick that wakes up and does nothing. Backoff is measured on the loop's clock (now), as RecordFailure sets it; the lease on the database's.

The last successful deployment's facts are read for the claimed rows only, each an index probe on deployments (app_id, started_at DESC), so a claim's cost follows its limit and not the number of apps on the install.

func (*Lease) Extend

func (l *Lease) Extend(ctx context.Context, lease time.Duration) (map[string]bool, error)

Extend pushes every lease this pass still holds lease further out, and returns the apps it still holds.

An app missing from the result is no longer this pass's — its lease ran out before it was extended, and another pass may have it — and its work must stop.

func (*Lease) Holder

func (l *Lease) Holder() string

Holder identifies this pass in the apps it holds.

func (*Lease) Release

func (l *Lease) Release(ctx context.Context, appID string, visited *time.Time) error

Release lets go of one app.

visited is when this pass's visit to it started, which orders the app behind every app visited less recently. Nil for an app the pass claimed and never started, which goes back to the front.

Only if this pass still holds it: a lease that ran out belongs to whoever claimed it next.

func (*Lease) ReleaseSettled

func (l *Lease) ReleaseSettled(ctx context.Context, appID string, visited time.Time, extra time.Duration) error

ReleaseSettled lets go of an app found as it should be, and leaves it until a slow sweep comes round to it again (O-52): it is next due revisit after visited, where an ordinary release is due MinRevisit after it.

The lease column carries this as it carries everything else about when an app is due: set to visited plus the difference, a claim with the ordinary cutoff passes over it until the time comes. Nothing is held — the holder is cleared as in Release — so a nudge (Nudge, dueAgain) brings it forward at any moment.

Unless the app changed while it was being looked at: a state, desired state or secret written during the visit bumps updated_at past the visit's start, and the app is released as due now instead, so what changed is not left for the sweep.

type LimitFacts

type LimitFacts struct {
	// UserMaxApps is the user's own value, nil when not set.
	UserMaxApps *int
	// Groups are the person's groups that set a value. Through
	// effective_group_members, so a provider's group linked to a Pando group
	// counts like a direct member.
	Groups []GroupLimit
	// Owned is how many apps they own now, in any state but deleted.
	Owned int
}

LimitFacts is everything a person's limit is decided from, but host policy.

type NewExternal

type NewExternal struct {
	AdapterID   string
	ExternalID  string
	Email       string
	DisplayName string
	CreatedBy   string

	// SCIM fields, when a SCIM client made it.
	SCIMUserName   string
	SCIMExternalID string
	SCIMResource   []byte
	Suspended      bool
}

NewExternal describes an account made for an external identity.

type NoticeIgnores added in v0.15.0

type NoticeIgnores struct {
	// contains filtered or unexported fields
}

NoticeIgnores is the notices on an app each person has chosen to ignore (R-412).

Every query is keyed on the person: one person's ignores are invisible to everyone else, who still see the notice.

func NewNoticeIgnores added in v0.15.0

func NewNoticeIgnores(db *DB) *NoticeIgnores

NewNoticeIgnores returns the store.

func (*NoticeIgnores) For added in v0.15.0

func (n *NoticeIgnores) For(ctx context.Context, userID, appID string) (map[string]string, error)

For returns a person's ignores on one app: each notice key, with the fingerprint the notice had when it was ignored.

func (*NoticeIgnores) Ignore added in v0.15.0

func (n *NoticeIgnores) Ignore(ctx context.Context, userID, appID, key, fingerprint string) error

Ignore records that a person ignores a notice as it reads now. Ignoring it again replaces the fingerprint.

func (*NoticeIgnores) Restore added in v0.15.0

func (n *NoticeIgnores) Restore(ctx context.Context, userID, appID, key string) error

Restore removes a person's ignore of a notice. Removing one that is not there is not an error.

type Notification

type Notification struct {
	ID        string     `json:"id"`
	UserID    string     `json:"user_id"`
	AppID     string     `json:"app_id,omitempty"`
	AppName   string     `json:"app_name,omitempty"`
	Link      string     `json:"link,omitempty"`
	EventID   string     `json:"event_id,omitempty"`
	Kind      string     `json:"kind"`
	Subject   string     `json:"subject"`
	Body      string     `json:"body,omitempty"`
	ReadAt    *time.Time `json:"read_at,omitempty"`
	CreatedAt time.Time  `json:"created_at"`
}

Notification is one stored message.

type NotificationPreference

type NotificationPreference struct {
	Kind    string `json:"kind"`
	Channel string `json:"channel"`
	Enabled bool   `json:"enabled"`
}

NotificationPreference is one choice a person made: this kind of notification, on this channel, on or off (R-373).

type NotificationPreferences

type NotificationPreferences struct {
	// contains filtered or unexported fields
}

NotificationPreferences stores those choices.

func NewNotificationPreferences

func NewNotificationPreferences(db *DB) *NotificationPreferences

func (*NotificationPreferences) List

List returns one person's choices. Anything absent is the default.

func (*NotificationPreferences) Set

Set records choices, replacing any earlier choice for the same kind and channel.

type Notifications

type Notifications struct {
	// contains filtered or unexported fields
}

Notifications stores console notifications (R-231).

Implements the console notify adapter's Sink. The adapter holds no storage of its own, which is R-027: an adapter never touches state, so core hands it a writer rather than a database.

func NewNotifications

func NewNotifications(db *DB) *Notifications

func (*Notifications) ListForUser

func (n *Notifications) ListForUser(ctx context.Context, userID string, unreadOnly bool, before string, limit int) ([]Notification, error)

ListForUser returns a user's notifications, newest first: up to limit, older than the notification ID before when one is given (a cursor).

func (*Notifications) MarkAllRead

func (n *Notifications) MarkAllRead(ctx context.Context, userID string) error

MarkAllRead marks every one of a user's notifications read.

func (*Notifications) MarkRead

func (n *Notifications) MarkRead(ctx context.Context, userID, notificationID string) error

MarkRead marks one notification read, for its owner only.

The user ID is in the WHERE clause rather than checked first: a check and then an update is two statements with a gap between them, and the gap is where someone marks a notification that is not theirs.

func (*Notifications) Record

func (n *Notifications) Record(ctx context.Context, msg api.Notification, retainFor time.Duration) error

Record writes one row per recipient.

Per recipient rather than one row with a list, so "mark as read" is a write to one row and an unread count is a count. A shared row would make both of those a join against a second table that does not exist yet.

func (*Notifications) Unread

func (n *Notifications) Unread(ctx context.Context, userID string) (int, error)

Unread counts a user's unread notifications, for the inbox's badge.

type Nudged

type Nudged struct {
	// Due: the app will be claimed by the next pass on any replica.
	Due bool
	// Held: a pass holds the app now, and it was left alone. Ask again once
	// that pass lets go, or the change may be one the pass already looked
	// past.
	Held bool
}

Nudged is what Nudge did.

type OAuthClient added in v0.16.0

type OAuthClient struct {
	ID           string
	Name         string
	RedirectURIs []string
	CreatedAt    time.Time

	// UsedAt is when someone first approved it. Nil for a client nobody has,
	// which retention removes after a day.
	UsedAt *time.Time
}

OAuthClient is an MCP client that registered itself (R-413, design 06 §3.6). Public: it has no secret.

type OAuthClients added in v0.16.0

type OAuthClients struct {
	// contains filtered or unexported fields
}

OAuthClients stores registered clients.

func NewOAuthClients added in v0.16.0

func NewOAuthClients(db *DB) *OAuthClients

func (*OAuthClients) ByID added in v0.16.0

func (c *OAuthClients) ByID(ctx context.Context, id string) (OAuthClient, bool, error)

ByID reads a client.

func (*OAuthClients) Create added in v0.16.0

func (c *OAuthClients) Create(ctx context.Context, cl OAuthClient) error

Create stores a client.

type OAuthCode added in v0.16.0

type OAuthCode struct {
	CodeHash      string
	ClientID      string
	UserID        string
	ApprovedBy    string
	CodeChallenge string
	RedirectURI   string
	CreatedAt     time.Time
	ExpiresAt     time.Time
}

OAuthCode is an approval a client has not collected yet.

type OAuthCodes added in v0.16.0

type OAuthCodes struct {
	// contains filtered or unexported fields
}

OAuthCodes stores approvals.

func NewOAuthCodes added in v0.16.0

func NewOAuthCodes(db *DB) *OAuthCodes

func (*OAuthCodes) Create added in v0.16.0

func (c *OAuthCodes) Create(ctx context.Context, code OAuthCode) error

Create stores an approval and marks its client used, so retention keeps it.

func (*OAuthCodes) Take added in v0.16.0

func (c *OAuthCodes) Take(ctx context.Context, codeHash string) (OAuthCode, bool, error)

Take removes the approval a code digest names and returns it. Removed whatever the caller does next, as CLISignIns.Take: a code is tried once.

type OrphanApp added in v0.13.0

type OrphanApp struct {
	AppID string
	Name  string
	// Stopped is whether Pando stopped it for having no owner and nobody has
	// started it since.
	Stopped    bool
	OrphanedAt time.Time
}

OrphanApp is an app with no active owner.

type OrphanedVolume

type OrphanedVolume struct {
	VolumeID   string
	AppID      string
	AdapterRef string
	Handle     string
}

OrphanedVolume is storage whose app is gone and whose data is in a backup.

type Orphans added in v0.13.0

type Orphans struct {
	// contains filtered or unexported fields
}

Orphans is the store half of the orphan pass.

func NewOrphans added in v0.13.0

func NewOrphans(db *DB) *Orphans

func (*Orphans) Administrators added in v0.13.0

func (o *Orphans) Administrators(ctx context.Context) ([]string, error)

Administrators returns the active people holding the built-in Administrator role, directly or through a group (R-409). For telling them something, never for deciding what they may do.

func (*Orphans) ClearOwned added in v0.13.0

func (o *Orphans) ClearOwned(ctx context.Context) ([]string, error)

ClearOwned unmarks every marked app that has an active owner again, so losing that owner is a new orphaning. A stop it made stays until somebody starts the app (R-408). Returns the apps unmarked.

func (*Orphans) MarkAnnounced added in v0.13.0

func (o *Orphans) MarkAnnounced(ctx context.Context, appIDs []string) error

MarkAnnounced records that administrators were told of these apps.

func (*Orphans) MarkOrphaned added in v0.13.0

func (o *Orphans) MarkOrphaned(ctx context.Context, appID string, stop bool) (marked, stopped bool, err error)

MarkOrphaned marks an app orphaned and, with stop set, stops it in the same write, recording why: the visitor page says why in the same moment the app stops (R-408). Only an app still without an active owner and not already marked, so a pass that read it before an owner came back writes nothing. Only a running app that has not failed (R-151) is stopped.

func (*Orphans) NewlyOrphaned added in v0.13.0

func (o *Orphans) NewlyOrphaned(ctx context.Context) ([]OrphanApp, error)

NewlyOrphaned lists the apps with no active owner that are not yet marked.

func (*Orphans) Unannounced added in v0.13.0

func (o *Orphans) Unannounced(ctx context.Context) ([]OrphanApp, error)

Unannounced lists the orphaned apps administrators have not been told of.

type Page

type Page struct {
	// Limit is the most rows to return. Zero means DefaultPageSize; anything
	// over MaxPageSize is read as MaxPageSize.
	Limit int
	// Cursor is the next_cursor of the previous page, or empty for the first.
	Cursor string
	// Query narrows the list by a case-insensitive substring match, on
	// whatever a person would recognize the row by (a name, an email).
	Query string
	// IDs, when set, narrows the list to these rows: how a client refreshes
	// the few rows it is watching change without reading the whole list again.
	// The app and account lists read it.
	IDs []string
}

Page asks for one page of a list.

func (Page) Size

func (p Page) Size() int

Size is the number of rows the page holds at most.

type Passwords

type Passwords struct {
	App      secret.Value
	Archiver secret.Value
}

Passwords are the ones provisionRoles gave Pando's two restricted roles.

func PrepareTemplate

func PrepareTemplate(ctx context.Context, ownerURL string) (Passwords, error)

PrepareTemplate migrates, grants and verifies a database to be copied, and returns the passwords it gave AppRole and ArchiverRole.

Nothing may be connected to the database when it is copied, so the caller makes its copies after this returns and never connects to it again.

type Policy

type Policy struct {
	// contains filtered or unexported fields
}

Policy reads and writes the host policy singleton.

func NewPolicy

func NewPolicy(db *DB) *Policy

func (*Policy) Load

func (p *Policy) Load(ctx context.Context) (policy.Document, error)

Load returns the current policy.

Read per evaluation rather than cached, because R-274 says policy applies to a running install: a cached policy would keep allowing, or keep denying, for however long the cache lived.

func (*Policy) Save

func (p *Policy) Save(ctx context.Context, doc policy.Document, updatedBy string) error

Save replaces the policy. One row, one transaction, one audit event (R-274).

type Ports

type Ports struct {
	// contains filtered or unexported fields
}

Ports allocates the host ports that port-mode routing hands out.

[P] — unspecified in the requirements. R-166 prefers subdomain and falls back to path, and the loopback adapter that ships as the laptop default supports neither: it is port mode only. So an install using it has to get a port from somewhere, and nothing said where. See docs/plan/open-decisions.md O-15.

The rule is the lowest free port in a configured range. Not random, so an app keeps its port across a rebuild and a bookmark keeps working; reused rather than ever-increasing, so a deleted app's port comes back.

It is a row in a table rather than a number derived from existing specs. The derived version raced: detection runs in the background per app, and three apps added together had two of them compute the same "lowest free" port before either had written anything down. A unique constraint cannot be raced — one writer wins and the other is told — which is the same reason every other invariant here lives in the schema.

func NewPorts

func NewPorts(db *DB) *Ports

NewPorts returns a port allocator over db.

func (*Ports) Allocate

func (p *Ports) Allocate(ctx context.Context, adapterRef, appID string, from, to int) (int, error)

Allocate reserves the lowest free port in [from, to] for an app.

Idempotent: an app that already holds a port on this adapter gets the same one back. Re-detection (R-022) must not consume a second port and leave every bookmark pointing at nothing.

func (*Ports) InUse

func (p *Ports) InUse(ctx context.Context) ([]int, error)

InUse lists every port currently allocated to an app.

What the proxy opens listeners on (design 03 §4.2). Allocations rather than running apps, deliberately: a port belongs to an app from the moment it is allocated until the app is gone, and an app that is stopped or mid-deploy should answer "this app isn't running right now" at its own address rather than refuse the connection — the two are very different things to debug.

type Profile

type Profile struct {
	Username    *string
	DisplayName *string
	Email       *string
	// EmailBySelf says the account's holder set Email, rather than an
	// administrator. Linking by email ignores such an address (R-045).
	EmailBySelf bool
}

Profile is the part of an account a person or an administrator edits. A nil field is left as it is.

type ProviderSettings

type ProviderSettings struct {
	// ID is a new provider's ID when the caller chose it, so the addresses
	// built from it could be registered with the provider first. Empty is a
	// fresh one. Not used by Update.
	ID                    string
	Name                  *string
	Config                json.RawMessage
	Enabled               *bool
	JITProvisioning       *bool
	LinkByEmail           *bool
	SCIMIdentityAttribute *string
}

ProviderSettings are the fields an administrator sets. A nil field is left as it is on update.

type Reconcilable

type Reconcilable struct {
	App

	ConsecutiveFailures int
	LastFailureAt       *time.Time
	NextAttemptAt       *time.Time
	UnobservableSince   *time.Time
	AppliedEnvHash      string

	// WorkloadImages is what each part of the app ran, for an app whose parts
	// are built separately. Empty for a single-image app, where ImageRef says
	// everything, and for a deployment from before it was recorded.
	//
	// The reconciler restores a missing workload from the recorded image, and
	// with only ImageRef to go on it restored every workload from one image —
	// which for a compose app meant replacing the application with a second
	// copy of whichever service happened to be primary.
	WorkloadImages map[string]WorkloadImage

	// ImageRef is what the last successful deployment ran. The reconciler
	// restores a missing workload with this rather than rebuilding: correcting
	// drift must not become "ship whatever is on the branch now".
	ImageRef string

	// ImageDigest is what the runtime resolved that reference to. A running
	// container reports a digest, not a reference, so this is the only thing
	// that can be compared against one — and a tag can point somewhere new
	// without the reference changing at all.
	ImageDigest string

	// EgressRules is what the last successful deployment ran with. Rules
	// take effect at a deploy (R-183, O-10), so the reconciler restores these
	// rather than resolving policy afresh and changing a running app.
	EgressRules egress.Rules

	// ClaimedAt is the database's time when this pass claimed the app. The
	// visit is released with it, so it is when the app was last looked at.
	ClaimedAt time.Time
}

Reconcilable is an app the loop should look at, with the bookkeeping it needs.

type Reconciles

type Reconciles struct {
	// contains filtered or unexported fields
}

Reconciles reads and writes the reconciler's view of an app.

func NewReconciles

func NewReconciles(db *DB) *Reconciles

NewReconciles returns a store over db.

func (*Reconciles) CatchUp

func (r *Reconciles) CatchUp(ctx context.Context, runtimeRef string) (int64, error)

CatchUp makes every app on one runtime that is waiting for the slow sweep due now, and returns how many (O-52).

Called when that runtime's event stream opens: whatever happened to those apps while nobody was listening — before Pando started, or while the stream was down — was not reported, so each is looked at once rather than left for the sweep. Apps not waiting are due within a pass anyway and are left in their place in the queue.

func (*Reconciles) ClearFailures

func (r *Reconciles) ClearFailures(ctx context.Context, appID string) error

ClearFailures resets the counter and backoff.

Called only when an app is running with health passing. A flapping app that recovers between failures still accumulates toward the threshold, which is correct: flapping is a failure mode, not a recovery.

func (*Reconciles) ClearUnobservable

func (r *Reconciles) ClearUnobservable(ctx context.Context, appID string) error

ClearUnobservable is called on the first successful Observe.

func (*Reconciles) Cutoff

func (r *Reconciles) Cutoff(ctx context.Context, revisit time.Duration) (time.Time, error)

Cutoff is the database's time minus revisit: an app visited after it is not due again in the pass that asked.

A pass fixes this once, before its first claim. Every app it visits is released with the time its visit started, which is after the cutoff, so the pass cannot claim the same app twice and ends when nothing older is left. revisit spreads the same guarantee across replicas: an app another replica visited less than revisit ago is left for later rather than visited twice.

The database's clock and not the caller's, because replicas' clocks differ and the lease is compared against the database's.

func (*Reconciles) ForApp added in v0.17.0

func (r *Reconciles) ForApp(ctx context.Context, appID string) (Reconcilable, bool, error)

ForApp reads what the reconciler would know of one live app with a pinned spec, without claiming it: for building its plan somewhere other than the loop, such as its warm standby (R-419). False when there is no such app.

func (*Reconciles) Lease

func (r *Reconciles) Lease() *Lease

Lease starts a pass. Nothing is claimed until Claim.

func (*Reconciles) MarkUnobservable

func (r *Reconciles) MarkUnobservable(ctx context.Context, appID, reason string) error

MarkUnobservable records that the adapter could not be reached.

It touches neither state nor the failure counter. An adapter being down is a platform problem, not app failure — otherwise restarting the Docker daemon marks every app on the host as failed (design 05 §2).

func (*Reconciles) Nudge

func (r *Reconciles) Nudge(ctx context.Context, appID string) (Nudged, error)

Nudge makes one app due now, because its runtime reported something happening to it (O-52).

Only an app the loop acts on: a failed app is not in reconcilableStates, so a nudge for one changes nothing — R-151 holds here by the same absence it holds by everywhere else in the loop. Backoff still applies, since Claim filters on next_attempt_at; a nudge moves an app forward in the queue, not past R-149. A lease that has run out is nobody's, and is cleared with it.

func (*Reconciles) RecordFailure

func (r *Reconciles) RecordFailure(ctx context.Context, appID, reason string, window time.Duration, next time.Time) (int, error)

RecordFailure increments the failure counter and returns the new count.

The window is an idle timeout measured from the last failure, not a fixed window from the first. Measured from the first it is unreachable: R-149's backoff caps at five minutes, so ten attempts span 31.3 minutes against a 30-minute window that resets at 30 — the app retries forever and is never given up on, which is what R-150 exists to prevent.

As an idle timeout it means what the requirement means. Consecutive failures always reach the threshold however long backoff stretches them out, and unrelated failures a day apart never accumulate.

func (*Reconciles) RecoverCurrentDeploy added in v0.6.0

func (r *Reconciles) RecoverCurrentDeploy(ctx context.Context, appID string) error

RecoverCurrentDeploy records that an app's current deploy came up after all.

A deploy waits a bounded time for health and finishes with result_state `degraded` when the app has not passed by then. An app that is only slow to start — a migration on boot, a long warm-up — passes later, and the history must not go on saying "Deployed, not healthy" about an app that is serving traffic (R-221, issue #233). Only the current deploy, the latest one that succeeded: an older row describes what happened then, and stays as it was.

func (*Reconciles) SetAppliedEnvFingerprint

func (r *Reconciles) SetAppliedEnvFingerprint(ctx context.Context, appID, fingerprint string) error

SetAppliedEnvFingerprint records the environment an app was last applied with.

R-193's only mechanism. Observe returns no environment and deliberately should not — reading it back would require every runtime adapter to handle secret-bearing data, which is what the adapter interface works to avoid — so a rotated secret is invisible to observation and is detected by comparing this instead (design 02 §2.4).

type Replaced added in v0.6.0

type Replaced struct {
	ID   string
	Name string
}

Replaced is the adapter that stopped being its category's default when another was saved as the default. Zero when none did.

type Replayed

type Replayed struct {
	StatusCode int
	Body       json.RawMessage
}

Replayed is a stored response.

type Replica

type Replica struct {
	ID           string
	Hostname     string
	AdvertiseURL string
	Version      string
	AssertionKID string
	AssertionKey []byte // Ed25519 public key
	StartedAt    time.Time
	HeartbeatAt  time.Time
	StoppedAt    *time.Time
}

Replica is one row.

type Replicas

type Replicas struct {
	// contains filtered or unexported fields
}

Replicas are the Pando processes sharing this database (issue #72).

A replica is Pando's own process and never a place an app runs: nothing that plans or places a workload reads this table (R-010, R-256). It answers which of Pando's processes are alive, which three things need to know — whose in-flight work is abandoned, which signing keys are published, and where a deploy's live log is.

Every time here is the database's, never a replica's own clock: replicas on different machines disagree about the time, and the database is the one clock they share.

func NewReplicas

func NewReplicas(db *DB) *Replicas

func (*Replicas) ByID

func (r *Replicas) ByID(ctx context.Context, replicaID string) (Replica, bool, error)

ByID returns one replica, live or not.

func (*Replicas) Heartbeat

func (r *Replicas) Heartbeat(ctx context.Context, replicaID string) (bool, error)

Heartbeat records that a replica is alive, and reports whether its row still exists and is not marked stopped. A replica that finds itself gone was taken for dead — its work has been abandoned by now — and the honest thing for it to do is restart as a new replica rather than carry on as if not.

func (*Replicas) Live

func (r *Replicas) Live(ctx context.Context) ([]Replica, error)

Live lists the replicas that have heartbeated within ReplicaStale, oldest first.

func (*Replicas) Prune

func (r *Replicas) Prune(ctx context.Context, olderThan time.Duration) (int64, error)

Prune removes replicas gone for longer than olderThan. Their keys have long since stopped verifying anything and their work has long since been abandoned; the row is only history.

func (*Replicas) Register

func (r *Replicas) Register(ctx context.Context, rep Replica) error

Register records this replica as started.

func (*Replicas) RequestRestart

func (r *Replicas) RequestRestart(ctx context.Context) error

RequestRestart asks every replica running now to restart (POST /restart).

func (*Replicas) RestartPending

func (r *Replicas) RestartPending(ctx context.Context, replicaID string) (bool, error)

RestartPending reports whether a restart was asked for after replicaID started.

func (*Replicas) Stop

func (r *Replicas) Stop(ctx context.Context, replicaID string) error

Stop records a replica as stopped, at shutdown. Its key stays published for as long as an assertion it signed can still be valid.

func (*Replicas) StopSilent

func (r *Replicas) StopSilent(ctx context.Context) (int64, error)

StopSilent records as stopped every replica that has not heartbeated for ReplicaStale, before its claimed work is put back in the queue (issue #72, O-32). A replica that was only slow — a long pause, a partition — then finds at its next heartbeat that it was taken for dead, and restarts rather than carrying on with deploys another replica has already claimed again.

func (*Replicas) TryLead

func (r *Replicas) TryLead(ctx context.Context, check time.Duration) (*Leadership, bool, error)

TryLead takes the leader lock if nobody holds it. ok is false, with no error, when another replica leads.

func (*Replicas) VerificationKeys

func (r *Replicas) VerificationKeys(ctx context.Context, validity time.Duration) ([]Replica, error)

VerificationKeys lists the replicas whose signing keys must still be published: every live one, and every one that stopped or went silent less than validity ago, since an assertion it signed may still be in use.

type Reservation added in v0.14.0

type Reservation struct {
	Held []HeldAttempt
	Over int
	Wait time.Duration
}

Reservation is what Reserve decided. Over a limit, Over is that limit's index and Wait how long until it lets another attempt through, never zero, and nothing was counted. Otherwise Over is -1 and Held is the failure counted against each limit, pending until Fail settles it or Release takes it back.

func (Reservation) Refused added in v0.14.0

func (r Reservation) Refused() bool

Refused reports whether a limit was reached and the attempt is not to be checked.

type Retention

type Retention struct {
	// contains filtered or unexported fields
}

Retention removes rows from the tables that otherwise only grow (issue #72, R-224): one batch per call, so the retention job's deletes are short transactions it repeats rather than one long one holding locks a request is waiting on.

What each never removes is the point of each, and is written into the query rather than left to the caller:

  • Spec revisions are never deleted here (R-152). Their pruning is the GC's, under its own rules.
  • The audit log is never touched (R-027); its retention is R-347's.
  • A deploy in flight or waiting for approval is never deleted, nor the newest successful deploy of a revision the app ever pinned: that row is what makes rolling back to the revision free of approval (R-157) and what the reconciler restores its image from.
  • The newest scan of each revision is never deleted, so a rollback still restores its score (R-319).

func NewRetention

func NewRetention(db *DB) *Retention

func (*Retention) DeletedApps

func (r *Retention) DeletedApps(ctx context.Context, before time.Time, limit int) (int64, error)

DeletedApps removes the detection and the backup-attempt record of apps deleted before before. Apps soft-delete, so the cascade that would have removed them never fires; one row per app is still a row per app ever made.

func (*Retention) Deployments

func (r *Retention) Deployments(ctx context.Context, keepPerApp, limit int) (int64, error)

Deployments removes up to limit deployments beyond each app's newest keepPerApp, never one in flight, waiting for approval, or the newest successful deploy of a revision the app ever pinned.

App by app, each read from deployments_app_idx past its newest keepPerApp, and stopping once limit rows are found, rather than numbering every deployment in the install with a window function on every batch (issue #72).

func (*Retention) IdempotencyKeys

func (r *Retention) IdempotencyKeys(ctx context.Context, before time.Time, limit int) (int64, error)

IdempotencyKeys removes up to limit keys recorded before before. A key reused after that is a new request, which is correct: nobody retries something from yesterday (migration 000011).

func (*Retention) Notifications

func (r *Retention) Notifications(ctx context.Context, now time.Time, limit int) (int64, error)

Notifications removes up to limit console notifications past their own retain_until. One with none is kept.

func (*Retention) SSOFlows

func (r *Retention) SSOFlows(ctx context.Context, before, now time.Time, limit int) (int64, error)

SSOFlows removes up to limit sign-in flows that expired before before — kept that long so a person arriving late at a failed sign-in can still be told why — the one-time identifiers whose assertions have expired, and CLI sign-ins approved and never collected (R-406), which have nothing to explain and go as soon as they expire, as do MCP clients' (R-413), with the clients nobody approved. The first two used to be deleted on every sign-in, inside the request.

func (*Retention) Scans

func (r *Retention) Scans(ctx context.Context, before time.Time, limit int) (int64, error)

Scans removes up to limit scans that ran before before, except the newest scan of each revision of each app (and of each app's source, for scans attached to no revision).

func (*Retention) Sessions

func (r *Retention) Sessions(ctx context.Context, before time.Time, limit int) (int64, error)

Sessions removes up to limit sessions that expired or were revoked before before. Neither can be used again; the audit log holds the sign-in and the sign-out.

type Revision

type Revision struct {
	ID        string        `json:"id"`
	AppID     string        `json:"app_id"`
	Revision  int           `json:"revision"`
	Origin    spec.Origin   `json:"origin"`
	Body      *spec.AppSpec `json:"body"`
	CreatedBy string        `json:"created_by"`
	CreatedAt time.Time     `json:"created_at"`

	// EverPinned is derived from spec_pins, not stored on the revision —
	// spec_revisions is append-only and a mutable column on it would be a hole
	// in that guarantee.
	EverPinned bool `json:"ever_pinned"`
}

Revision is a stored spec revision.

type Revoked added in v0.17.0

type Revoked struct {
	Hosts []string `json:"hosts"`
	Certs []string `json:"certs"`
}

Revoked is what the cluster refuses of the hosts removed from it (R-415): their IDs, and the fingerprints of every certificate recorded as theirs.

A probe certificate is never recorded by fingerprint: it is issued at a join and at each renewal, and carries the host's ID as its one URI (hostagent.IssueHostClient). Revoking the ID is what revokes every probe certificate the host was ever issued; IDs are never reused. RemoveHost records at least the host's current certificate, which every host has (cluster_hosts.cert_sha256 is NOT NULL), so every removed host's ID is among Hosts.

type RoleChange added in v0.15.0

type RoleChange struct {
	Name  *string
	Verbs []authz.Verb
	// SetVerbs says Verbs replaces the role's permissions; without it Verbs
	// is ignored, so an empty list cannot be sent by leaving the field out.
	SetVerbs bool
}

RoleChange is an edit to a custom role. A nil field is left as it is.

type RoleHolder added in v0.15.0

type RoleHolder struct {
	Kind string `json:"kind"` // user or group
	ID   string `json:"id"`
	Name string `json:"name"`
	// Username is an account's; Source and SourceName a synced group's
	// provider, as on a Group.
	Username   string `json:"username,omitempty"`
	Source     string `json:"source,omitempty"`
	SourceName string `json:"source_name,omitempty"`
	Via        string `json:"via"`
	// GroupID is, for an account holding the role through groups, the first
	// of them by name: where to go to take it away.
	GroupID string `json:"group_id,omitempty"`
}

RoleHolder is an account or a group holding a role: by a grant made to it ("direct"), or, for an account, through a group it is in ("group").

type RoleRow

type RoleRow struct {
	ID      string   `json:"id"`
	Name    string   `json:"name"`
	Builtin bool     `json:"builtin"`
	Scope   string   `json:"scope"`
	Verbs   []string `json:"verbs"`
}

RoleRow is a role as stored.

type Roles

type Roles struct {
	// contains filtered or unexported fields
}

Roles reads and writes custom roles (R-082).

func NewRoles

func NewRoles(db *DB) *Roles

func (*Roles) AppsGranted added in v0.15.0

func (r *Roles) AppsGranted(ctx context.Context, roleID string) ([]string, error)

AppsGranted returns the apps a role is granted on: whose holders an edit to an app role reaches.

func (*Roles) ByID added in v0.15.0

func (r *Roles) ByID(ctx context.Context, roleID string) (RoleRow, bool, error)

ByID returns one role, of either scope.

func (*Roles) CreateCustom

func (r *Roles) CreateCustom(ctx context.Context, name, scope string, verbs []authz.Verb) (authz.Role, error)

CreateCustom adds a custom role composed from the verb catalog.

Custom roles are arbitrary subsets of the catalog, and validated against it: a role naming a verb Pando does not have would grant nothing and look like it granted something.

func (*Roles) DeleteCustom

func (r *Roles) DeleteCustom(ctx context.Context, roleID string) error

DeleteCustom removes a custom role, and every grant of it with it: anyone who held it — on an app, or across the installation — loses what it allowed, and keeps anything they hold another way. Built-ins are protected by trigger (R-081), so this reports that rather than surfacing a constraint name.

The grants have to go first: they reference the role by foreign key, so a role anyone held could not be deleted at all, and the error said only that something had gone wrong.

Refused when it would leave nobody who can manage accounts (R-088) — a custom role holding install.users.manage is as much an administrator as the built-in one.

func (*Roles) Holders added in v0.15.0

func (r *Roles) Holders(ctx context.Context, roleID string, page Page) ([]RoleHolder, string, int, int, error)

Holders is one page of a role's holders — its groups first, then its accounts, each by name — with the cursor for the next page, how many there are in all (capped, O-53), and how many of them are groups.

func (*Roles) List

func (r *Roles) List(ctx context.Context) ([]RoleRow, error)

List returns every role, of either scope: installation roles first, Pando's before the installation's own, and within those the broadest first — so the built-ins read administrator, creator, then owner, operator, viewer.

func (*Roles) UpdateCustom added in v0.15.0

func (r *Roles) UpdateCustom(ctx context.Context, roleID string, change RoleChange) (RoleRow, RoleRow, error)

UpdateCustom renames a custom role, replaces its permissions, or both, and returns it as it was and as it is. The rules are those of CreateCustom: a name unique ignoring case and spaces, at least one permission, every one a verb of the role's scope (R-080, R-082). The scope itself cannot change — every grant of the role is of that scope (grants_role_scope_fkey).

Built-in roles are refused here and, whatever this code does, by the roles_builtin_immutable trigger (R-081).

Refused when it would leave nobody who can manage accounts (R-088): taking install.users.manage out of a role is the same lockout as revoking it.

type SCIMGroup

type SCIMGroup struct {
	ID          string
	DisplayName string
	ExternalID  string
	Resource    []byte
	Members     []SCIMMember
	CreatedAt   string
	UpdatedAt   string
}

SCIMGroup is a provider's group as a SCIM client sees it.

type SCIMGroups

type SCIMGroups struct {
	// contains filtered or unexported fields
}

SCIMGroups is the SCIM view of one provider's groups.

func NewSCIMGroups

func NewSCIMGroups(db *DB) *SCIMGroups

func (*SCIMGroups) ByID

func (s *SCIMGroups) ByID(ctx context.Context, adapterID, groupID string, members bool) (SCIMGroup, bool, error)

ByID returns one of a provider's groups.

func (*SCIMGroups) ChangeMembers

func (s *SCIMGroups) ChangeMembers(ctx context.Context, adapterID, groupID string, add, remove []string) error

ChangeMembers adds or removes members, leaving the rest.

func (*SCIMGroups) Create

func (s *SCIMGroups) Create(ctx context.Context, adapterID, displayName, externalID string, resource []byte, members []string) (string, error)

Create makes a group for a SCIM client. The external ID it is keyed by is the client's externalId when it sends one; otherwise the display name, which is also what a sign-in's groups claim would carry.

func (*SCIMGroups) List

func (s *SCIMGroups) List(ctx context.Context, adapterID, attr, value string, offset, limit int, members bool) ([]SCIMGroup, int, error)

List returns a provider's groups, optionally filtered by display name or external ID, with the total before paging.

func (*SCIMGroups) Update

func (s *SCIMGroups) Update(ctx context.Context, adapterID, groupID string, displayName *string, resource []byte, members []string, replaceMembers bool) error

Update renames a group, replaces what the client said about it, and — when members is non-nil — replaces its membership.

func (*SCIMGroups) UserGroups

func (s *SCIMGroups) UserGroups(ctx context.Context, adapterID, userID string) ([]SCIMMember, error)

UserGroups lists the provider's groups a user is in, for the user resource.

type SCIMMember

type SCIMMember struct {
	UserID  string
	Display string
}

SCIMMember is one member of a SCIM group.

type SCIMUser

type SCIMUser struct {
	ID          string // users.id, which is the SCIM resource id
	Key         string // the identity's external_id: what sign-in matches on
	UserName    string
	ExternalID  string
	Resource    []byte
	Email       string
	DisplayName string
	Status      string
	SuspendedBy string
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

SCIMUser is an account as one provider's SCIM client sees it (R-048).

func (SCIMUser) Active

func (u SCIMUser) Active() bool

Active is what SCIM's active attribute reports: false for a suspended or deleted account, whoever suspended it.

type SCIMUserUpdate

type SCIMUserUpdate struct {
	Key         string
	UserName    string
	ExternalID  string
	Resource    []byte
	Email       string
	DisplayName string
}

SCIMUserUpdate is a SCIM client's view of an account, written back.

type SCIMUsers

type SCIMUsers struct {
	// contains filtered or unexported fields
}

SCIMUsers is the SCIM view of one provider's accounts. A client sees the accounts its provider has an identity on, and nothing else.

func NewSCIMUsers

func NewSCIMUsers(db *DB) *SCIMUsers

func (*SCIMUsers) ByID

func (s *SCIMUsers) ByID(ctx context.Context, adapterID, userID string) (SCIMUser, bool, error)

ByID returns one of a provider's accounts.

func (*SCIMUsers) ByKey

func (s *SCIMUsers) ByKey(ctx context.Context, adapterID, key string) (SCIMUser, bool, error)

ByKey returns the account an identity reaches, if the identity exists.

func (*SCIMUsers) Forget

func (s *SCIMUsers) Forget(ctx context.Context, adapterID, userID string) error

Forget is a SCIM DELETE: the client's record of the account goes, and so does its membership in the provider's groups. The account and its identity stay — suspended by the caller, not deleted (R-049) — so the identity cannot be reused by someone else, and a later POST for the same person picks the account up again.

func (*SCIMUsers) List

func (s *SCIMUsers) List(ctx context.Context, adapterID, attr, value string, offset, limit int) ([]SCIMUser, int, error)

List returns the accounts a provider's SCIM client provisioned, optionally filtered by one attribute, with the total before paging. Accounts deleted in Pando, and ones the client deleted (Forget), are left out: to the client they no longer exist.

func (*SCIMUsers) Reactivate

func (s *SCIMUsers) Reactivate(ctx context.Context, adapterID, userID string) (bool, error)

Reactivate lifts a suspension this provider's SCIM client made, and only that one. An administrator's suspension stands however often a client sends active=true — Entra resends it on every cycle (R-049).

func (*SCIMUsers) Write

func (s *SCIMUsers) Write(ctx context.Context, adapterID, userID string, w SCIMUserUpdate) error

Write stores what a SCIM client said about an account: the identity's SCIM fields, its key if the client changed it, and — when the account came from this provider — its email and name. A linked local account's profile is its own (see RecordSignIn).

type SSOFlow

type SSOFlow struct {
	ID           string
	AdapterID    string
	Purpose      string // sign_in | test
	BindHash     string
	ReturnOrigin string
	NextPath     string
	CallbackURL  string
	EntityID     string
	Flow         []byte
	InitiatedBy  string
	UserID       string
	Result       json.RawMessage
	Failed       bool
	CreatedAt    time.Time
	ExpiresAt    time.Time
	ConsumedAt   *time.Time
}

SSOFlow is a redirect sign-in in progress (design 06 §3.2).

type SSOFlows

type SSOFlows struct {
	// contains filtered or unexported fields
}

SSOFlows stores flows.

func NewSSOFlows

func NewSSOFlows(db *DB) *SSOFlows

func (*SSOFlows) Authenticated

func (s *SSOFlows) Authenticated(ctx context.Context, flowID, userID, handoffHash string, result json.RawMessage) error

Authenticated records who the callback signed in and the digest of the one-time code that finishes the flow in the browser that started it.

func (*SSOFlows) ByID

func (s *SSOFlows) ByID(ctx context.Context, flowID string) (SSOFlow, bool, error)

ByID returns a flow by its state, whatever became of it.

func (*SSOFlows) Complete

func (s *SSOFlows) Complete(ctx context.Context, handoffHash string) (SSOFlow, bool, error)

Complete consumes the one-time code, once, within its two minutes.

func (*SSOFlows) Create

func (s *SSOFlows) Create(ctx context.Context, f SSOFlow) error

Create stores a new flow. Expired flows and spent one-time identifiers are removed by the retention job (Retention.SSOFlows), not here: two deletes on every sign-in were two full scans on the path everyone waits on (issue #72).

func (*SSOFlows) CreateUnsolicited

func (s *SSOFlows) CreateUnsolicited(ctx context.Context, f SSOFlow) error

CreateUnsolicited stores a flow the provider started (SAML IdP-initiated), already past its callback. It has no browser binding to check.

func (*SSOFlows) Fail

func (s *SSOFlows) Fail(ctx context.Context, flowID string, result json.RawMessage) error

Fail records why a flow did not sign anyone in. Kept for a day so the sign-in page can say so, and a test sign-in can show it.

func (*SSOFlows) TakeForCallback

func (s *SSOFlows) TakeForCallback(ctx context.Context, flowID, adapterID string) (SSOFlow, bool, error)

TakeForCallback claims a live flow for its callback, exactly once: a replayed callback finds the flow already taken. The adapter's data is cleared as it is handed over.

func (*SSOFlows) UseOnce

func (s *SSOFlows) UseOnce(ctx context.Context, adapterID, oneTimeID string, until time.Time) (bool, error)

UseOnce records a provider's one-time identifier, and reports false if it was already used — a replayed SAML assertion.

type Scan

type Scan struct {
	ID     string `json:"id"`
	AppID  string `json:"app_id"`
	SpecID string `json:"spec_id,omitempty"`
	// Commit names the source the scan read, when it read one: a git commit,
	// an image digest, or an upload's archive digest. A deploy of that source
	// uses this scan rather than scanning again (ForSource).
	Commit string `json:"commit,omitempty"`
	// Image names the image the scan read, by digest, when it read one. Empty
	// for a scan of the source alone — detection's, before anything was built —
	// which a deploy that built an image does not reuse (R-311).
	Image      string `json:"image,omitempty"`
	ScannerRef string `json:"scanner_ref"`
	Scanner    string `json:"scanner,omitempty"`
	Score      *int   `json:"score"`

	// ScoreFixable counts only findings with a fix available. Which of the two
	// an installation means is host policy's (R-313); both are stored because
	// policy changes without rescanning.
	ScoreFixable *int          `json:"score_fixable,omitempty"`
	Findings     []api.Finding `json:"findings"`
	Error        string        `json:"error,omitempty"`
	RanAt        time.Time     `json:"ran_at"`

	// ReusedFrom is the scan this one repeats, when a deploy of a new revision
	// of an unchanged source reused it rather than scanning again (Reuse).
	// RanAt is that scan's: the findings are as old as the scan that found
	// them, not as the deploy that reused them.
	ReusedFrom string `json:"reused_from,omitempty"`
}

Scan is one scanner's answer about one revision.

func (Scan) Origin

func (s Scan) Origin() string

Origin is the ID of the scan that ran: this one, or the one it repeats.

type Scans

type Scans struct {
	// contains filtered or unexported fields
}

Scans stores them.

func NewScans

func NewScans(db *DB) *Scans

func (*Scans) ClearInsecure

func (s *Scans) ClearInsecure(ctx context.Context, appID string) error

ClearInsecure records that an app is back above the threshold.

func (*Scans) ForSource

func (s *Scans) ForSource(ctx context.Context, appID, source, image, scannerRef string) (Scan, bool, error)

ForSource returns the newest scan of an app's source and image that ran, by the scanner named — a scanner that failed produced no finding worth reusing (R-318), and another scanner's findings are not this one's.

Both have to match, because a scan covers what it read (R-311): a scan of the source alone does not cover an image built from it, and a scan of one image does not cover another built from the same commit. Image is empty for a deploy that built none, and then only a scan that read none matches. Found is false for an empty source, which names nothing.

func (*Scans) History

func (s *Scans) History(ctx context.Context, appID string, limit int) ([]Scan, error)

History returns an app's scans, newest first.

The scans that ran. A reuse (Reuse) is the same scan attached to another revision, and listing it would show one scan twice at the same time.

func (*Scans) Latest

func (s *Scans) Latest(ctx context.Context, appID, specID string) (Scan, bool, error)

Latest returns the scan that describes what this app is running.

The newest scan of that revision, and failing that the newest scan that belongs to no revision — which is what detection produces, from the source, before there is a revision to attach it to. Never another revision's: a score for a spec the app is not running is not a score of what is deployed.

The fallback is the difference between "scanned at discovery" meaning something and meaning nothing: accepting a proposal pins a revision, and without this the scan taken of that very source a minute earlier disappeared behind "this app has not been scanned yet".

func (*Scans) LiveSecurityState

func (s *Scans) LiveSecurityState(ctx context.Context, t SecurityThreshold) ([]SecurityState, error)

LiveSecurityState returns the apps the security pass has something to decide about under threshold t.

Archived apps are not in it, and neither are drafts: an app that has never been deployed cannot be running below a threshold. Of the rest, an app is in it when it is marked — insecure, or stopped for being insecure, since an app Pando stopped has to be looked at again to be started again — or when its score does not meet t, including when it has none. An unmarked app that meets the threshold is one the pass would look at and leave alone, and over twenty thousand apps that is nearly all of them (issue #72).

One set-based query: the pinned revision's newest scores are kept on the app by trigger (migration 60), so there is no lookup into app_scans per app. The verdict is still security.Evaluate's, in the pass; this only leaves out apps it could not act on.

func (*Scans) MarkInsecure

func (s *Scans) MarkInsecure(ctx context.Context, appID string, at time.Time) error

MarkInsecure records when an app was first found below the threshold.

Idempotent on purpose: the pass runs every tick, and the grace period is measured from the first time it was true, not the most recent (R-316). A clock that restarted on every pass would be a grace period that never expired.

func (*Scans) Record

func (s *Scans) Record(ctx context.Context, scan Scan) (Scan, error)

Record writes a scan.

Every scan, including a failed one. A scanner that could not run is a fact somebody has to see — silence would leave an unscannable app looking clean (R-318).

func (*Scans) Reuse

func (s *Scans) Reuse(ctx context.Context, from Scan, specID string) (Scan, error)

Reuse attaches an earlier scan to another revision, as a new row with the same findings, score and ran_at, naming the scan it repeats.

A revision's score is the newest scan of that revision (Latest). A deploy of a new revision of an unchanged source does not scan again, and without this the revision would have no scan: refused as never scanned where a threshold is set, and shown as "not scanned" everywhere else.

func (*Scans) SecurityStateFor

func (s *Scans) SecurityStateFor(ctx context.Context, appID string) (SecurityState, bool, error)

SecurityStateFor returns one app's standing.

func (*Scans) SetStoppedForSecurity

func (s *Scans) SetStoppedForSecurity(ctx context.Context, appID string, stopped bool) error

SetStoppedForSecurity records whether Pando is the reason this app is stopped.

The difference decides whether a recovered score starts it again: an app its owner stopped stays stopped (design 09 §4.2).

type SecretKey

type SecretKey struct {
	Key       string    `json:"key"`
	Version   int       `json:"version"`
	UpdatedAt time.Time `json:"updated_at"`
}

SecretKey is one of an app's secrets as a listing shows it: its key and version, never its value.

type Secrets

type Secrets struct {
	// contains filtered or unexported fields
}

Secrets stores an app's secret values through a secrets adapter.

The row holds ciphertext or an external reference; there is no plaintext column anywhere (R-190, R-191). Core never encrypts — the adapter does, and core stores what it is handed.

func NewSecrets

func NewSecrets(db *DB, adapter api.SecretsAdapter, adapterRef string) *Secrets

func (*Secrets) Delete

func (s *Secrets) Delete(ctx context.Context, appID, key string) error

Delete removes a secret.

func (*Secrets) Get

func (s *Secrets) Get(ctx context.Context, appID, key string) (secret.Value, error)

Get returns one secret's value, for R-083's separable read.

One key rather than reusing Resolve, which decrypts every secret an app has: reading one value should not require decrypting the rest, and the audit event this backs names a single key.

func (*Secrets) Keys

func (s *Secrets) Keys(ctx context.Context, appID string) ([]SecretKey, error)

Keys lists an app's secret keys and versions. Never values — reading a value is a separate, audited endpoint (R-083).

func (*Secrets) Put

func (s *Secrets) Put(ctx context.Context, appID, key string, v secret.Value) error

Put stores or rotates a secret.

Rotation increments version, which is how the reconciler learns a restart is required (R-193). The value itself never appears in a log or an error, because it is a secret.Value the whole way through.

func (*Secrets) Resolve

func (s *Secrets) Resolve(ctx context.Context, appID string) (map[secretKey]secret.Value, error)

Resolve decrypts an app's secrets for a deploy.

Called once, at step 11 of the pipeline, to materialize WorkloadPlan.Env. It is the only place values leave storage on a deploy path.

func (*Secrets) VerifyKey

func (s *Secrets) VerifyKey(ctx context.Context) error

VerifyKey proves the secrets adapter holds the install's key, and refuses to go on if it does not (R-190, issue #72).

The local adapter's key lives beside the process, not in the database — a key stored with the ciphertext it protects protects nothing. So several replicas must each be given the same key, and one given a different key, or one that generated its own because its volume was empty, used to start normally and then write secrets that no other replica could read. Nothing noticed until a deploy on another replica failed to decrypt them.

The first replica seals a random value and records its digest; every replica after opens it at start. An adapter that seals nothing locally — one that keeps secrets in an external store and hands back a reference — has no local key to differ, and is not checked.

func (*Secrets) Versions

func (s *Secrets) Versions(ctx context.Context, appID string) (map[string]int, error)

Versions returns each secret's version, without decrypting anything.

This is what the environment fingerprint is built from (R-193). Drift detection runs on every tick for every app, and it must never be a reason to decrypt a secret — the version answers "did this change", which is the only question being asked.

type Section

type Section struct {
	ID        string    `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
}

Section is one of a person's launcher groupings.

type SecurityState

type SecurityState struct {
	AppID              string
	InsecureSince      *time.Time
	StoppedForSecurity bool
	DesiredState       string
	State              string
	PinnedSpecID       string
	OwnerUserID        string
	Name               string

	// Score and ScoreFixable are the pinned revision's newest scan's two
	// numbers, as Scans.Latest would find it: what the security pass places
	// against policy, read in the same query so the pass is one query rather
	// than three per app (issue #72).
	Score        *int
	ScoreFixable *int
}

SecurityState is an app's standing against the installation's threshold.

type SecurityThreshold

type SecurityThreshold struct {
	MinScore        int
	IgnoreUnfixable bool
}

SecurityThreshold is what the security pass places apps against: host policy's minimum score, and whether it reads the score of fixable findings.

type ServiceInstance

type ServiceInstance struct {
	ID            string        `json:"id"`
	AppID         string        `json:"app_id"`
	SlotKey       string        `json:"slot_key"`
	SlotType      spec.SlotType `json:"slot_type"`
	AdapterRef    string        `json:"adapter_ref"`
	Handle        string        `json:"handle"`
	ConnectionRef string        `json:"secret_key"`
}

ServiceInstance is one provisioned service.

type Services

type Services struct {
	// contains filtered or unexported fields
}

Services records which provisioned service fills which slot (R-131).

func NewServices

func NewServices(db *DB) *Services

func (*Services) ForApp

func (s *Services) ForApp(ctx context.Context, appID string) ([]ServiceInstance, error)

ForApp lists an app's provisioned services.

func (*Services) Get

func (s *Services) Get(ctx context.Context, appID, slotKey string) (ServiceInstance, bool, error)

Get returns the instance filling one slot, if there is one.

func (*Services) NewID

func (s *Services) NewID() string

NewID returns an ID for a service about to be provisioned.

Minted before the adapter is called and passed into Provision, so a deploy that dies between the adapter answering and the row being written can retry with the same ID and get the same names back. Without that, the retry stands up a second database and the first becomes a volume nobody can name.

func (*Services) Record

func (s *Services) Record(ctx context.Context, v ServiceInstance) error

Record stores a newly provisioned service.

ON CONFLICT DO NOTHING rather than DO UPDATE: if a row already fills this slot, that row names the database holding the app's data and the caller's newer instance is an empty one from a retry. Overwriting would point the app at the empty database and leave the real one unreferenced.

func (*Services) Release

func (s *Services) Release(ctx context.Context, id string) error

Release forgets a provisioned service.

type Session

type Session struct {
	ID        string    `json:"id"`
	UserID    string    `json:"user_id"`
	AdapterID string    `json:"adapter_id"`
	ExpiresAt time.Time `json:"expires_at"`

	// IssuedAt is when its holder signed in. A session younger than a few
	// minutes changes its own second factor without the password again
	// (R-405): its holder has just typed it.
	IssuedAt time.Time `json:"issued_at"`
}

Session is an active login. ID is its public name; it is not a credential.

type SessionRevoker added in v0.14.0

type SessionRevoker struct {
	// contains filtered or unexported fields
}

SessionRevoker ends sessions by account. It needs no key: ending a session reads no secret, so the host commands that reset a credential can end its sessions without the API token key (cmd/pando admin).

func NewSessionRevoker added in v0.14.0

func NewSessionRevoker(db *DB) *SessionRevoker

NewSessionRevoker returns a store that can only end sessions.

func (*SessionRevoker) RevokeAllForUser added in v0.14.0

func (s *SessionRevoker) RevokeAllForUser(ctx context.Context, userID string) error

RevokeAllForUser ends every session a user holds. Used when an adapter pushes a revocation (R-048), which is what makes immediate revocation possible.

type Sessions

type Sessions struct {
	*SessionRevoker
	// contains filtered or unexported fields
}

Sessions are server-side rows (design 02 §2.7). The cookie carries ses_<id>.<secret>: the ID is public — it is what the audit log, logs and the API name a session by — and the secret is what signs a browser in. The table holds only the secret's HMAC under the API token key, as tokens do (R-063), so neither the audit log nor a copy of the table hands anyone a working cookie.

func NewSessions

func NewSessions(db *DB, key []byte) *Sessions

NewSessions returns the session store. key is the install's API token key and must be tokenkey.Size bytes, for the same reason as NewTokens.

func (*Sessions) Active

func (s *Sessions) Active(ctx context.Context, cookie string) (Session, bool, error)

Active returns the session a cookie carries, if its secret is right and the session is unrevoked and unexpired.

The API checks this on every request. The proxy reads it with the account and groups in one query (ActiveWithUser) and keeps the answer until a NOTIFY says something changed, never more than 30 seconds — a bound that is in design 06 §3.1's revocation window, which is a stated number rather than an accident (issue #93).

func (*Sessions) ActiveWithUser

func (s *Sessions) ActiveWithUser(ctx context.Context, cookie string) (Session, User, []string, bool, error)

ActiveWithUser is Sessions.Active, Users.ByID and the user's groups in one query: a session unrevoked and unexpired, the account it belongs to, and the groups that account is in through the effective_group_members view, read live as R-079 requires. cookie is the session cookie's value, checked as Active checks it. found is false where Active would have found no session or ByID no user.

func (*Sessions) Create

func (s *Sessions) Create(ctx context.Context, userID, adapterID string, lifetime time.Duration, userAgent, ip string) (Started, error)

Create issues a session.

func (*Sessions) Revoke

func (s *Sessions) Revoke(ctx context.Context, sessionID string) error

Revoke ends one session, by its public ID. A caller holding only a cookie resolves it with Active first, so that a cookie naming someone else's session with the wrong secret ends nothing.

type Standby added in v0.17.0

type Standby struct {
	AppID      string `json:"app_id"`
	RuntimeRef string `json:"runtime_ref,omitempty"`
	// Host is the runtime's name for where the standby is; empty before the
	// runtime has made one.
	Host         string     `json:"host,omitempty"`
	SpecID       string     `json:"spec_id,omitempty"`
	Volumes      int        `json:"volumes"`
	Copies       int        `json:"copies"`
	ShippedAt    *time.Time `json:"shipped_at,omitempty"`
	Unreplicated []string   `json:"unreplicated,omitempty"`
	LastError    string     `json:"last_error,omitempty"`
	StaleHost    string     `json:"stale_host,omitempty"`
	PromotedAt   *time.Time `json:"promoted_at,omitempty"`

	// Home is the runtime's name for where the app runs, as it last said
	// (api.StandbyHandle.Home, api.PromoteResult.Host): the liveness pass's
	// record of which apps a lost host held (R-421). Empty until said.
	Home string `json:"home,omitempty"`
	// LastErrorCode and LastErrorRemedy are LastError's envelope code and
	// remedy. CAPACITY_WOULD_OVERSUBSCRIBE is the no-room notice (R-422).
	LastErrorCode   string `json:"last_error_code,omitempty"`
	LastErrorRemedy string `json:"last_error_remedy,omitempty"`

	// PromotingAt is when a promotion of the standby began and has not yet
	// ended (migration 000091). It is written ahead with Home set to the
	// standby's host (R-153, R-419): while it is set the standby pass and the
	// reconciler leave the app alone.
	PromotingAt *time.Time `json:"promoting_at,omitempty"`

	// Version is the row's version as read: every write to the row changes
	// it, whoever makes it (migration 000094's trigger), and SaveIf writes
	// only over the version it was given (R-153).
	Version int64 `json:"-"`

	// Priority is the app's availability.priority, read from its pinned
	// spec by HomedOn alone, so a lost host's apps are ordered without
	// building a plan for each (R-422). Not a column: never written.
	Priority int `json:"-"`
}

Standby is one app's standby as Pando last left it.

type StandbyApp added in v0.17.0

type StandbyApp struct {
	AppID        string
	State        string
	DesiredState string
	PinnedSpecID string
	Deleted      bool
	// Standby is nil when the app has none recorded.
	Standby *Standby
	// HomeLost is whether the host the standby row names as the app's home
	// is marked lost (R-421): the pass then leaves the standby for the
	// failover, which starts it.
	HomeLost bool
}

StandbyApp is an app the standby pass looks at: a live app with a pinned spec, or a deleted one that still has a standby row to clear.

type Standbys added in v0.17.0

type Standbys struct {
	// contains filtered or unexported fields
}

Standbys is the state of each app's warm standby (R-419, R-420, migration 000087).

func NewStandbys added in v0.17.0

func NewStandbys(db *DB) *Standbys

NewStandbys returns the standby store.

func (*Standbys) AppState added in v0.17.0

func (s *Standbys) AppState(ctx context.Context, appID string) (string, bool, error)

AppState is a live app's state: false when there is no such app.

func (*Standbys) Candidates added in v0.17.0

func (s *Standbys) Candidates(ctx context.Context) ([]StandbyApp, error)

Candidates lists every live app with a pinned spec, with its standby if it has one, and every deleted app that still has a standby row.

func (*Standbys) Delete added in v0.17.0

func (s *Standbys) Delete(ctx context.Context, appID string) error

Delete forgets one app's standby, unless a promotion of it is under way (R-153): that row is what says where the app now runs.

func (*Standbys) Fate added in v0.17.0

func (s *Standbys) Fate(ctx context.Context, appID string) (AppFate, error)

Fate reads whether the app is deleted, and what its delete said of its storage: what a copy left on a returning host is removed with (R-204, R-419). An app with no row is neither.

func (*Standbys) Get added in v0.17.0

func (s *Standbys) Get(ctx context.Context, appID string) (Standby, bool, error)

Get reads one app's standby.

func (*Standbys) Held added in v0.17.0

func (s *Standbys) Held(ctx context.Context, appID string) (Hold, error)

Held reads what may hold an app: its standby row's promotion and home. An app with no standby row, or whose home is not a cluster host, is held by nothing.

func (*Standbys) HomedOn added in v0.17.0

func (s *Standbys) HomedOn(ctx context.Context, host string) ([]Standby, error)

HomedOn is the standby of every live app whose home is host, the runtime's name for it, by app: the apps a lost host held (R-421). An app whose promotion away from host began and did not end is one of them, so marking the host lost again finishes it (R-153). Each carries its pinned spec's availability.priority (Standby.Priority), 0 when it says none, read here so a lost host's apps are ordered without building their plans (R-422).

func (*Standbys) HostHeld added in v0.17.0

func (s *Standbys) HostHeld(ctx context.Context, host string) (Hold, error)

HostHeld is what holds every app on the cluster host named host, the runtime's name for it: whether it is marked lost or says it is fenced. A name that is no cluster host's holds nothing.

func (*Standbys) LeftOn added in v0.17.0

func (s *Standbys) LeftOn(ctx context.Context, host string) ([]Standby, error)

LeftOn is the standby of every app failed over away from host, which still holds the app's stale copy (R-419), by app. A deleted app is one of them: its copy there is removed when the host returns too.

func (*Standbys) Save added in v0.17.0

func (s *Standbys) Save(ctx context.Context, sb Standby) error

Save writes one app's standby, whatever its row says now. The standby pass and promotions write with SaveIf instead.

func (*Standbys) SaveIf added in v0.17.0

func (s *Standbys) SaveIf(ctx context.Context, sb Standby, prev *Standby) (bool, error)

SaveIf writes one app's standby only if its row is still prev, as it was read: the same version, which every write to the row changes, whoever makes it (R-153). Every column is covered, so two writers working from one reading — the standby pass, a promotion, a returning host's stale copy removed, a host removed from the cluster — never put back what the other changed: the one that read first loses, and reads again on its next pass. A nil prev writes only where the app has no row; a row forgotten since prev was read is not written back. It reports whether it wrote.

func (*Standbys) SaveIfLive added in v0.17.0

func (s *Standbys) SaveIfLive(ctx context.Context, sb Standby, prev *Standby) (bool, error)

SaveIfLive is SaveIf over an existing row that also writes only while the app is live and not failed: a promotion's write-ahead, so an app that fails after the promotion read it is never started on another host (R-151).

type Started added in v0.14.0

type Started struct {
	Session Session      `json:"session"`
	Cookie  secret.Value `json:"cookie"`
}

Started is a session just created. Cookie is the value of the session cookie, present here and nowhere else: only its digest is stored (R-063). It is a secret.Value, so it is redacted wherever this struct is logged or serialized (R-194).

type Subscription

type Subscription struct {
	ID string `json:"id"`

	// OwnerID is the person who owns it; OwnerTokenID the account token, for
	// one an account token made (R-060). Exactly one is set.
	OwnerID      string `json:"owner_id,omitempty"`
	OwnerTokenID string `json:"owner_token_id,omitempty"`
	OwnerName    string `json:"owner_name,omitempty"`

	AppID       string   `json:"app_id,omitempty"`
	AppName     string   `json:"app_name,omitempty"`
	Events      []string `json:"events"`
	Destination string   `json:"destination"`
	URL         string   `json:"url,omitempty"`
	AdapterID   string   `json:"adapter_id,omitempty"`

	// Channel is where an adapter whose capabilities say Channels posts —
	// a Slack channel's ID. Empty for every other destination.
	Channel string `json:"channel,omitempty"`

	// How a webhook is sent (R-375). Header values are sealed in
	// subscription_secrets; only their names are here.
	Method          string   `json:"method,omitempty"`
	ContentType     string   `json:"content_type,omitempty"`
	PayloadTemplate string   `json:"payload_template,omitempty"`
	HeaderNames     []string `json:"header_names,omitempty"`

	Description         string     `json:"description"`
	Enabled             bool       `json:"enabled"`
	DisabledReason      string     `json:"disabled_reason,omitempty"`
	FailingSince        *time.Time `json:"failing_since,omitempty"`
	ConsecutiveFailures int        `json:"consecutive_failures"`

	// LastDeliveryAt is when Pando last tried to deliver to it: the newest
	// delivery's last attempt, or when that delivery was queued if it has not
	// been tried yet. Absent before the first delivery.
	LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"`

	CreatedBy string    `json:"created_by"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

Subscription is which events, about what, sent where (R-367).

type SubscriptionFilter

type SubscriptionFilter struct {
	// OwnerID is a person's ID or an account token's: both columns are
	// matched, and the two kinds of ID cannot collide.
	OwnerID string
	AppID   string

	// InstallOnly keeps only subscriptions with no app.
	InstallOnly bool

	// EnabledOnly keeps only subscriptions that are on.
	EnabledOnly bool
}

SubscriptionFilter narrows a list. Empty fields do not narrow.

type SubscriptionPatch

type SubscriptionPatch struct {
	Events          *[]string
	URL             *string
	AdapterID       *string
	Channel         *string
	Description     *string
	Enabled         *bool
	Method          *string
	ContentType     *string
	PayloadTemplate *string
	HeaderNames     *[]string
}

SubscriptionPatch is a partial update. Nil fields are left alone.

type SubscriptionSecrets

type SubscriptionSecrets struct {
	// contains filtered or unexported fields
}

SubscriptionSecrets stores a webhook's secrets — its signing key and the values of its custom headers — sealed by the secrets adapter (R-371, R-375, R-190). The same arrangement as AdapterCredentials: ciphertext or an external reference, never the value, and no column on the subscription a value could be put in.

func NewSubscriptionSecrets

func NewSubscriptionSecrets(db *DB, adapter api.SecretsAdapter, adapterRef string) *SubscriptionSecrets

func (*SubscriptionSecrets) All

func (k *SubscriptionSecrets) All(ctx context.Context, subscriptionID string) (map[string]secret.Value, error)

All opens every secret a subscription has, keyed by field, for one send.

func (*SubscriptionSecrets) DeleteHeaders

func (k *SubscriptionSecrets) DeleteHeaders(ctx context.Context, subscriptionID string) error

DeleteHeaders removes every stored header value, before a new set is put.

func (*SubscriptionSecrets) Put

func (k *SubscriptionSecrets) Put(ctx context.Context, subscriptionID, field string, v secret.Value) error

Put stores or replaces one secret.

type Subscriptions

type Subscriptions struct {
	// contains filtered or unexported fields
}

Subscriptions stores subscriptions.

func NewSubscriptions

func NewSubscriptions(db *DB) *Subscriptions

func (*Subscriptions) Create

Create stores a subscription. s.ID must be set.

func (*Subscriptions) Delete

func (s *Subscriptions) Delete(ctx context.Context, subscriptionID string) error

Delete removes a subscription, its key and its deliveries.

func (*Subscriptions) Disable

func (s *Subscriptions) Disable(ctx context.Context, subscriptionID, reason string) (bool, error)

Disable turns a subscription off and says why. It reports whether this call is the one that did it, so the owner is told once.

func (*Subscriptions) Enabled

func (s *Subscriptions) Enabled(ctx context.Context) ([]Subscription, time.Time, error)

Enabled returns every enabled subscription, and the database's time just before it read them: a subscription made before asOf is in the list, give or take a transaction still committing (issue #72, the dispatcher's cache).

func (*Subscriptions) Failed

func (s *Subscriptions) Failed(ctx context.Context, subscriptionID string, at time.Time) (time.Time, int, error)

Failed counts one more failed attempt and returns when the run of failures began and how long it is.

func (*Subscriptions) Get

func (s *Subscriptions) Get(ctx context.Context, subscriptionID string) (Subscription, bool, error)

Get reads one subscription.

func (*Subscriptions) List

List returns every subscription the filter matches, newest first.

func (*Subscriptions) ListPage

func (s *Subscriptions) ListPage(ctx context.Context, f SubscriptionFilter, page Page) ([]Subscription, string, error)

ListPage is one page of List, keyset-paged on (created_at, id), with the cursor for the next page (empty after the last).

func (*Subscriptions) Succeeded

func (s *Subscriptions) Succeeded(ctx context.Context, subscriptionID string) error

Succeeded clears a subscription's run of failures.

func (*Subscriptions) Update

func (s *Subscriptions) Update(ctx context.Context, subscriptionID string, p SubscriptionPatch) (Subscription, error)

Update applies a patch. Turning a subscription on clears the record of its failures, so an endpoint somebody fixed starts with a clean slate.

func (*Subscriptions) Watch

func (s *Subscriptions) Watch(ctx context.Context, changed func(), connected func(bool))

Watch calls changed whenever any replica commits such a change, until ctx ends. connected says whether it is listening; while it is not, a change can go untold (DB.Listen).

type TeardownTarget

type TeardownTarget struct {
	AppID      string
	RuntimeRef string
	RoutingRef string

	// BuilderRef is the builder that holds the app's build cache, so the
	// teardown can have it forgotten.
	BuilderRef string

	// DiscardStorage says the delete settled the app's storage — discarded,
	// or backed up first — so its volumes go with the bundle.
	DiscardStorage bool
}

TeardownTarget is a deleted app whose bundle still exists.

type Token

type Token struct {
	ID          string     `json:"id"`
	Kind        string     `json:"kind"`
	Name        string     `json:"name"`
	OwnerUserID string     `json:"owner_user_id,omitempty"`
	ExpiresAt   *time.Time `json:"expires_at,omitempty"`
	LastUsedAt  *time.Time `json:"last_used_at,omitempty"`
	RevokedAt   *time.Time `json:"revoked_at,omitempty"`
}

Token is a stored token, without its secret.

type TokenRevoker added in v0.14.0

type TokenRevoker struct {
	// contains filtered or unexported fields
}

TokenRevoker revokes tokens by owner. It needs no key: revoking reads no digest, so the host commands that reset a credential can revoke the account's tokens without the API token key (cmd/pando admin).

func NewTokenRevoker added in v0.14.0

func NewTokenRevoker(db *DB) *TokenRevoker

NewTokenRevoker returns a store that can only revoke tokens.

func (*TokenRevoker) RevokeAllForUser added in v0.14.0

func (r *TokenRevoker) RevokeAllForUser(ctx context.Context, userID string) (int64, error)

RevokeAllForUser revokes every token userID owns, and says how many.

type Tokens

type Tokens struct {
	// contains filtered or unexported fields
}

Tokens stores API tokens.

func NewTokens

func NewTokens(db *DB, key []byte) *Tokens

NewTokens returns the token store. key is the install's API token key (tokenkey.LoadOrCreate), and must be tokenkey.Size bytes: a store without one could neither issue a token nor check one, and finding that out at the first request is later than at start.

func (*Tokens) Active

func (t *Tokens) Active(ctx context.Context, tokenID string) (Token, bool, error)

Active returns a token that is neither revoked nor expired, by ID alone.

For authorizing work a token set going rather than a request it is making now — an account token's event subscription (R-368) — where there is no secret to present. Not a way to authenticate.

func (*Tokens) Authenticate

func (t *Tokens) Authenticate(ctx context.Context, presented secret.Value) (Token, error)

Authenticate resolves a presented token string.

The string is "<id>.<secret>". Splitting on the ID lets the digest be looked up directly rather than compared against every row.

Every failure returns the same error. Distinguishing "no such token" from "wrong secret" would let a caller enumerate valid token IDs.

func (*Tokens) Create

func (t *Tokens) Create(ctx context.Context, kind, name, ownerUserID, createdBy string, expiresAt *time.Time) (Issued, error)

Create mints a token.

A delegated token requires an owner and holds no grants of its own: it resolves through the owner on every request (R-058, R-059). An account token has no owner and is its own principal in grants (R-060). The database enforces both shapes, so a caller cannot produce a token that is neither.

func (*Tokens) ListAccounts

func (t *Tokens) ListAccounts(ctx context.Context) ([]Token, error)

ListAccounts lists account-level tokens (R-060).

Separate from ListForUser because these have no owner: an account token is its own principal, appears in grants under its own ID, and outlives whoever created it. "Every token in the install" is not a list anyone needs and is not offered — a delegated token belongs to the person who made it, and showing all of them to an administrator would make a screen out of other people's credentials.

func (*Tokens) ListForUser

func (t *Tokens) ListForUser(ctx context.Context, userID string) ([]Token, error)

ListForUser returns a user's own tokens, newest first.

Delegated tokens only — an account token has no owner and belongs to whoever administers the install, not to a person (R-060). Revoked ones are included, because "this token was revoked" is what someone checking after an incident needs to see; hiding them would make the list agree with nothing.

func (*Tokens) Owner

func (t *Tokens) Owner(ctx context.Context, tokenID string) (string, bool, error)

Owner returns a token's owner, for checking who may revoke it.

func (*Tokens) Revoke

func (t *Tokens) Revoke(ctx context.Context, tokenID string) error

Revoke revokes a token.

func (*Tokens) RevokeAllForUser added in v0.14.0

func (t *Tokens) RevokeAllForUser(ctx context.Context, userID string) (int64, error)

RevokeAllForUser revokes every delegated token userID owns, and says how many it revoked. For an administrator's password or two-factor reset: a reset is how an account is taken back from whoever else got into it, and a token they minted while in would otherwise outlast it (R-059).

func (*Tokens) VerifyKey

func (t *Tokens) VerifyKey(ctx context.Context) error

VerifyKey proves this process holds the install's API token key, and refuses to go on if it does not (R-063, issue #72).

The key lives beside the process, not in the database — a key stored with the digests it protects protects nothing. So several replicas must each be given the same key file, and one given a different key, or one that generated its own because its volume was empty, would issue tokens no other replica accepts and reject every token the others issued.

The first replica records HMAC(key, tokenKeyLabel); every replica after compares its own. The same shape as the secrets key check (Secrets.VerifyKey).

type TwoFactor added in v0.9.0

type TwoFactor struct {
	// contains filtered or unexported fields
}

TwoFactor stores local accounts' second factors (R-405): the authenticator secret, sealed by the install's secrets adapter like any credential (R-190), and recovery codes as digests.

func NewTwoFactor added in v0.9.0

func NewTwoFactor(db *DB, adapter api.SecretsAdapter, adapterRef string) *TwoFactor

NewTwoFactor stores second factors with the install's secrets adapter. Its own table and scope, so an authenticator's ciphertext cannot be replayed as any other credential's.

func (*TwoFactor) AcceptStep added in v0.9.0

func (t *TwoFactor) AcceptStep(ctx context.Context, userID string, step int64) (bool, error)

AcceptStep records that a code for step was used, and reports false when a code for it or a later step already was — in one statement, so two sign-ins racing with the same code cannot both win.

func (*TwoFactor) Disable added in v0.9.0

func (t *TwoFactor) Disable(ctx context.Context, userID string, reoffer bool) (bool, error)

Disable removes an account's second factor: the authenticator, a pending one, and the recovery codes. reoffer also forgets that its holder was asked to set one up, which an administrator's reset does so they are asked again. Reports false when the account had nothing to remove.

func (*TwoFactor) Enable added in v0.9.0

func (t *TwoFactor) Enable(ctx context.Context, userID string, v secret.Value, lastStep int64, digests []string) error

Enable puts a confirmed authenticator in use, with a fresh set of recovery codes, and forgets the pending one. lastStep is the step the confirming code was made for, so it cannot sign in as well.

func (*TwoFactor) MarkOffered added in v0.9.0

func (t *TwoFactor) MarkOffered(ctx context.Context, userID string) error

MarkOffered records that the account's holder was asked to set up a second factor and chose not to yet, so they are not asked again (R-405).

func (*TwoFactor) PutPending added in v0.9.0

func (t *TwoFactor) PutPending(ctx context.Context, userID string, v secret.Value) error

PutPending keeps an authenticator secret that has been shown to its holder and not yet confirmed, replacing any other.

func (*TwoFactor) ReplaceRecoveryCodes added in v0.9.0

func (t *TwoFactor) ReplaceRecoveryCodes(ctx context.Context, userID string, digests []string) error

ReplaceRecoveryCodes throws away an account's recovery codes, used or not, and keeps these instead.

func (*TwoFactor) Secrets added in v0.9.0

func (t *TwoFactor) Secrets(ctx context.Context, userID string) (current, pending secret.Value, err error)

Secrets opens an account's authenticator secrets: the one in use and the one waiting to be confirmed, each zero when there is none.

func (*TwoFactor) Status added in v0.9.0

func (t *TwoFactor) Status(ctx context.Context, userID string) (TwoFactorStatus, error)

Status reads an account's second factor. A missing account reads as none.

func (*TwoFactor) UseRecoveryCode added in v0.9.0

func (t *TwoFactor) UseRecoveryCode(ctx context.Context, userID, digest string) (bool, error)

UseRecoveryCode spends the recovery code with this digest, and reports false when the account has no unused one like it.

type TwoFactorStatus added in v0.9.0

type TwoFactorStatus struct {
	EnabledAt *time.Time
	LastStep  int64
	OfferedAt *time.Time

	// RecoveryCodesLeft is how many recovery codes are unused.
	RecoveryCodesLeft int
}

TwoFactorStatus is what is known about an account's second factor, none of it secret.

type Uploads added in v0.17.0

type Uploads struct {
	// contains filtered or unexported fields
}

Uploads keeps uploaded source archives in the database (R-417, R-262), so every control host reads the one uploaded on any other with no directory shared between them. Migration 000086 says why the archive is stored in chunks of bytea.

func NewUploads added in v0.17.0

func NewUploads(db *DB) *Uploads

NewUploads returns the upload store.

func (*Uploads) AddUpload added in v0.17.0

func (u *Uploads) AddUpload(ctx context.Context, appID string, r io.Reader) (bool, error)

AddUpload stores r as appID's uploaded archive only if the app has none, and reports whether it did. It is how an archive file from before R-417 is moved in: an upload stored meanwhile is newer, and is kept rather than overwritten by the file. An app that is deleted, or was never created, is errs.NotFound (lockLiveApp).

func (*Uploads) DeleteUpload added in v0.17.0

func (u *Uploads) DeleteUpload(ctx context.Context, appID string) error

DeleteUpload removes appID's uploaded archive. An app with none is not an error.

func (*Uploads) HasUpload added in v0.17.0

func (u *Uploads) HasUpload(ctx context.Context, appID string) (bool, error)

HasUpload reports whether appID has an uploaded archive in the database.

func (*Uploads) OpenUpload added in v0.17.0

func (u *Uploads) OpenUpload(ctx context.Context, appID string) (io.ReadCloser, bool, error)

OpenUpload returns appID's uploaded archive, read chunk by chunk. ok is false when the app has none. The reader fails at its end if what was read is not what was stored. Close it: it holds a database connection until then.

func (*Uploads) PutUpload added in v0.17.0

func (u *Uploads) PutUpload(ctx context.Context, appID string, r io.Reader) error

PutUpload stores r as appID's uploaded archive, replacing any before it.

One transaction: a deploy reading the app's upload while this runs reads the previous archive whole, never part of each. The app_uploads row is written first, which locks it, so two uploads to one app take turns. The transaction holds a connection until r ends, so r is a file already on disk rather than a request body a client may send slowly (R-417). An app that is deleted is errs.NotFound (lockLiveApp).

type User

type User struct {
	ID          string `json:"id"`
	AdapterID   string `json:"adapter_id"`
	ExternalID  string `json:"external_id"`
	Email       string `json:"email,omitempty"`
	DisplayName string `json:"display_name,omitempty"`
	Status      string `json:"status"`

	MustChangePassword bool `json:"must_change_password"`

	// TwoFactorEnabled says a local account has an authenticator app turned
	// on (R-405). An account from an identity provider never does here: its
	// provider asks for its second factor.
	TwoFactorEnabled bool `json:"two_factor_enabled"`

	// AliasOf is set on an account whose identities an administrator moved to
	// another (O-1). It stays, suspended, because its ID may be an assertion
	// subject some app holds data under (R-054).
	AliasOf string `json:"alias_of,omitempty"`

	CreatedAt time.Time `json:"created_at"`
}

User is a stored principal.

Tagged because this type is serialized directly by the API, and every other type on the wire is lower_snake_case. An untagged struct would put Go field names in the API surface — where they would then be a compatibility promise.

type UserAppGrant

type UserAppGrant struct {
	AppID     string `json:"app_id"`
	AppName   string `json:"app_name"`
	AppOwner  string `json:"-"`
	GrantID   string `json:"grant_id"`
	Plane     string `json:"plane"`
	RoleID    string `json:"role_id,omitempty"`
	RoleName  string `json:"role_name,omitempty"`
	Via       string `json:"via"` // user or group
	GroupID   string `json:"group_id,omitempty"`
	GroupName string `json:"group_name,omitempty"`
}

UserAppGrant is one grant that gives a person something on an app, directly or through a group they are in.

type Users

type Users struct {
	// contains filtered or unexported fields
}

Users reads and writes user records.

func NewUsers

func NewUsers(db *DB) *Users

func (*Users) AdapterNames added in v0.15.0

func (u *Users) AdapterNames(ctx context.Context, adapterIDs []string) (map[string]string, error)

AdapterNames names the identity providers with these IDs, by ID (R-045): what an account list shows as the provider each account signs in with, read once for a page rather than once per account. An ID with no provider is left out.

func (*Users) ByExternalID

func (u *Users) ByExternalID(ctx context.Context, adapterID, externalID string) (User, bool, error)

ByExternalID resolves an adapter's subject to a Pando user.

func (*Users) ByID

func (u *Users) ByID(ctx context.Context, userID string) (User, bool, error)

ByID returns a user by Pando ID.

func (*Users) ByUsername

func (u *Users) ByUsername(ctx context.Context, username string) (local.Record, bool, error)

ByUsername implements local.Users. Username is the local adapter's external_id.

func (*Users) CheckSetupToken added in v0.17.0

func (u *Users) CheckSetupToken(ctx context.Context, token secret.Value) error

CheckSetupToken reports whether token is the installation's setup token, without using it up: POST /setup/join asks for it, as POST /setup does, so that being first to a fresh Pando is not enough to join it to a cluster of one's own (R-046, R-415). A join leaves this database behind when Pando restarts into the cluster, so there is nothing for the token to keep shut afterwards.

func (*Users) ClaimFirst

func (u *Users) ClaimFirst(ctx context.Context, setupToken secret.Value, username, displayName, passwordHash, roleID string) (User, string, error)

ClaimFirst creates the installation's first account and makes it an administrator, if and only if there is no account yet (R-046).

One transaction, serialized by an advisory lock, so two people submitting the setup form at the same moment cannot both become the first administrator: the second waits, then finds an account and is refused.

The claimant must present the setup token Pando printed to its log (issue #130), checked and used up inside the same transaction.

func (*Users) ClearSetupToken

func (u *Users) ClearSetupToken(ctx context.Context) error

ClearSetupToken removes the setup token. The caller holds FirstAccountLock and has found or made an account, so the installation is set up and the token would only be a way in that nothing needs.

func (*Users) Create

func (u *Users) Create(ctx context.Context, adapterID, externalID, email, displayName, passwordHash string, mustChange bool) (User, error)

Create inserts a user and returns it.

func (*Users) Delete

func (u *Users) Delete(ctx context.Context, userID string) error

Delete soft-deletes a user, which is what fires R-282's destruction rules.

Soft, because the audit log references this ID and R-054 makes users.id the assertion subject apps key their own data on. A hard delete would orphan both — the audit trail would name an ID nothing explains, and an app would be holding rows for a person Pando can no longer describe.

func (*Users) EnsureLocalAdapter

func (u *Users) EnsureLocalAdapter(ctx context.Context) error

EnsureLocalAdapter seeds the local identity adapter on a fresh install.

func (*Users) EnsureSetupToken

func (u *Users) EnsureSetupToken(ctx context.Context) (string, time.Time, error)

EnsureSetupToken makes the setup token if there is none, and returns it. When one already exists it returns "" and when that one was made: its value cannot be recovered, only replaced.

The caller holds FirstAccountLock and has found no account (bootstrap.Run), so replicas starting together make one token between them, and a token is never made for an installation that is already set up.

func (*Users) List

func (u *Users) List(ctx context.Context) ([]User, error)

List returns every account, newest first.

Includes suspended accounts and excludes deleted ones: suspension is not deletion (R-049), and an administrator managing accounts needs to see the suspended ones — they are the ones most likely to need attention.

Unpaginated, for the one reader that needs every account at once (the access assistant). The API pages with ListPage: an organization of 100,000 people is not a screenful (issue #72).

func (*Users) ListPage

func (u *Users) ListPage(ctx context.Context, page Page) ([]User, string, int, error)

ListPage is one page of List, newest first, with the cursor for the next page (empty after the last) and how many accounts match in all.

Page.Query matches the username, display name or email, as Search does.

func (*Users) NeedsSetup

func (u *Users) NeedsSetup(ctx context.Context) (bool, error)

NeedsSetup reports whether the installation has no account yet.

func (*Users) ReplaceSetupToken

func (u *Users) ReplaceSetupToken(ctx context.Context) (string, error)

ReplaceSetupToken makes a new setup token in place of any there was, for an operator who no longer has the one Pando printed. Refused with ErrAlreadySetUp once any account exists.

Under FirstAccountLock, as ClaimFirst is, so a claim cannot land between the check for accounts and the write and leave a token behind a set-up install.

func (*Users) ResetPassword

func (u *Users) ResetPassword(ctx context.Context, username, passwordHash string) (string, error)

ResetPassword sets a local account's password from outside any session, and requires the holder to change it again at the next sign-in.

Separate from SetPassword, which clears must_change_password because the person changing it is the person who chose it. Here somebody else chose it: it was handed over out of band, so it is a way back in rather than a credential, and R-046's rule applies for the same reason it applies on first run.

Returns the user ID so the caller can end that account's sessions and record what it did. A reset that leaves a stolen session alive has not reset anything.

func (*Users) Search

func (u *Users) Search(ctx context.Context, q string, limit int) ([]User, error)

Search finds active and suspended accounts by username, name or email, case-insensitively, at most limit of them, ordered by username. An empty query lists the first few.

func (*Users) SetPassword

func (u *Users) SetPassword(ctx context.Context, userID, passwordHash, keepSessionID string) error

SetPassword replaces a local account's password and clears the must-change-on-first-login flag (R-046).

The flag is cleared here rather than by a separate call because there is no state in which a person has chosen their own password and must still change it. Two statements would make that state reachable by forgetting one.

Local accounts only. An external identity provider owns its own credentials (R-044), and a password Pando could change would be a second copy of one.

Every session the account holds ends in the same statement, except keepSessionID when it is this account's ("" keeps none): a password change is what someone does when they think a credential has leaked, and a password that changed while a leaked session lived on would make the act cosmetic (R-048).

func (*Users) SetPasswordFor

func (u *Users) SetPasswordFor(ctx context.Context, userID, passwordHash string, mustChange bool) (bool, error)

SetPasswordFor sets a local account's password on an administrator's behalf, and whether its holder must change it at the next sign-in.

Returns false when there is no such local account: an external identity provider owns its own credentials (R-044).

func (*Users) SetStatus

func (u *Users) SetStatus(ctx context.Context, userID, status string) error

SetStatus changes a user's status.

Suspension is not deletion (R-049): this is the endpoint behind PATCH, and it must never trigger the destruction rules that DELETE does (R-282).

func (*Users) SetStatusBy

func (u *Users) SetStatusBy(ctx context.Context, userID, status, by string) error

SetStatusBy is SetStatus on someone's behalf: an administrator, or a SCIM client. The R-088 lockout rule applies to both.

func (*Users) UpdateProfile

func (u *Users) UpdateProfile(ctx context.Context, userID string, p Profile) error

UpdateProfile changes an account's username, name or email.

Username and email only on a local account: an external identity provider owns those, and a change here would be overwritten at the next sign-in — or worse, would make the account stop matching its subject (R-054 is about users.id, which never changes, but the provider matches on external_id).

type VolumeRecord

type VolumeRecord struct {
	VolumeID string
	Name     string
	Handle   string
}

VolumeRecord is one volume as the runtime reports it.

type Volumes

type Volumes struct {
	// contains filtered or unexported fields
}

Volumes records storage attached to apps.

func NewVolumes

func NewVolumes(db *DB) *Volumes

func (*Volumes) Create

func (v *Volumes) Create(ctx context.Context, appID, name, adapterRef string) (string, error)

Create records a volume.

func (*Volumes) DeleteForApp

func (v *Volumes) DeleteForApp(ctx context.Context, appID string) error

DeleteForApp removes an app's volume rows.

Only reached once the caller has resolved the keep-or-discard decision (R-204). The ON DELETE RESTRICT on volumes.app_id means an app cannot be archived while rows remain, so this is the explicit step that constraint forces — which is exactly why the constraint is there.

func (*Volumes) Handles

func (v *Volumes) Handles(ctx context.Context, appID string) (map[string]string, error)

Handles reports each of an app's recorded volumes and the runtime handle it was last seen under — empty for a volume that was never materialized.

A non-empty handle is the distinction R-203 turns on. A volume row with a handle was created in the runtime and may hold data; recreating it after it disappears produces an empty replacement and an app that comes up healthy having lost everything — the failure that looks exactly like success. A row without a handle has never existed anywhere, so creating it loses nothing.

func (*Volumes) RecordFromRuntime

func (v *Volumes) RecordFromRuntime(ctx context.Context, appID, adapterRef string, observed []VolumeRecord) error

RecordFromRuntime writes the volume rows for an app's spec volumes, using the handles the runtime reports.

Nothing used to do this. The spec declared volumes, the planner planned them, the runtime created them — and Pando's own `volumes` table stayed empty. Three things silently did nothing as a result: `ON DELETE RESTRICT` protected no rows, so R-204's "volumes survive app deletion" was a constraint on an empty table; deleting an app never offered to keep a backup, because it counted zero volumes; and the DR bundle contained no app data at all, because the query that finds volumes to snapshot found none.

The ID is the spec's volume ID, not a generated one. A spec that says `vol_data` and a row that says `vol_01HQ8…` are two names for one thing, and a restore matching them up is a join nobody wrote.

Idempotent: every deploy re-records, and the handle is refreshed in case the runtime's own naming changed under it.

Keyed by app and ID together (migration 000036). The ID is only unique within an app — two apps may both keep a volume called "data" — and keyed by ID alone, the second app's deploy overwrote the first app's row and recorded nothing for its own, so it was never backed up (issue #87).

type WorkloadImage

type WorkloadImage struct {
	Ref    string `json:"ref,omitempty"`
	Digest string `json:"digest,omitempty"`
}

WorkloadImage is what one part of an app ran.

Directories

Path Synopsis
Package statetest gives integration tests a database of their own, quickly.
Package statetest gives integration tests a database of their own, quickly.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL