Documentation
¶
Overview ¶
Package spec parses and validates the app.yaml file: the one declarative file a user writes in their repo, per the app spec design. Validation happens in two layers. Structural shape (required fields, enums, string patterns, unknown-key rejection) is checked against the embedded JSON Schema (schema/app.schema.json), kept as the single source of truth so CI and the deploy-time check share one definition, not two hand-maintained copies that drift. Rules the schema can't express (a field's validity depending on another field's value) are checked by Validate after parsing.
Index ¶
- Constants
- func DiscoverPath(dir string, brandedName string) (string, error)
- func IsPendingImage(image string) bool
- func SchemaJSON() ([]byte, error)
- func ValidateDocument(data []byte) (*Spec, []Issue)
- func ValidateLabels(labels map[string]string) error
- type Backup
- type Build
- type Database
- type Egress
- type EgressAllow
- type EnvVar
- type ExecCommand
- type GPU
- type Health
- type Hooks
- type Issue
- type Probe
- type Resources
- type Service
- type Spec
- type StatusCodes
- type VaultRef
- type Volume
Constants ¶
const ( MaxLabels = 32 MaxLabelKeyLength = 255 MaxLabelValueLength = 4096 )
Sanity limits on custom labels: a genuine safety bound against unbounded container metadata growth, not a business threshold, so a fixed constant is appropriate here (the project's "no hardcoded thresholds" rule targets values a deployment might reasonably need to tune, like a retention window or a rate limit; a handful of operator hand-written monitoring/tooling labels per service isn't that). Docker itself doesn't publish an official label count/size limit, so these are chosen generously enough for real use (a monitoring agent or log shipper typically wants a handful of keys) while still bounding how much metadata one service can accumulate.
const ( BuildDockerfile = "dockerfile" BuildCompose = "compose" BuildRailpack = "railpack" BuildStatic = "static" BuildImage = "image" )
Build input types: the app spec's build.type values, matching the BuildKit-based build detection (Dockerfile, Compose, Railpack, static) plus one non-build type: a prebuilt image already sitting in a registry, deployed as-is with no build step at all (see Build.Image and internal/deploy's deployImage).
const ( StrategyRolling = "rolling" StrategyRecreate = "recreate" StrategyBlueGreen = "blue-green" )
Deploy strategies, part of the app spec. Blue-green is the effective default, since it's easier to get right than rolling with a single replica, applied by DefaultStrategy when Strategy is empty, not baked into the schema itself.
const ( EngineFake = "" // zero value only, never valid; see Validate EnginePostgres = "postgres" EngineRedis = "redis" EngineMySQL = "mysql" EngineMongoDB = "mongodb" EngineMariaDB = "mariadb" EngineKeyDB = "keydb" EngineClickHouse = "clickhouse" EngineDragonfly = "dragonfly" )
Supported managed database engines: Postgres and Redis shipped as first-class resources in the initial release, MySQL, MongoDB, MariaDB, KeyDB, ClickHouse, and Dragonfly joined once internal/reconcile/database's controller grew matching engine cases.
const ( // TriggerModePush deploys on every push to the git source's // configured branch. The default, matching pre-trigger-mode // behavior. TriggerModePush = "push" // TriggerModeRelease deploys only on a tag ref push, or (GitHub // only) a "release" webhook event with action "published". TriggerModeRelease = "release" )
Git source deploy trigger modes (store.GitSource.TriggerMode): which pushes to a connected repo actually trigger a deploy.
const DefaultReplicas = 1
DefaultReplicas is used when a service doesn't set replicas.
const EgressModeAllowlist = "allowlist"
EgressModeAllowlist is the only meaningful Egress.Mode value today; see Egress's own doc comment for why the block is opt-in rather than defaulting to deny-all.
const GPUAll = -1
GPUAll is GPU.Count's "every GPU on the node" value.
const InstanceLabelKey = ReservedLabelPrefix + "instance"
InstanceLabelKey is the Docker label key every container, network, and volume this control-plane instance creates gets stamped with, set to its own persistent instance ID (store.GetOrCreateInstanceID). Two separate control-plane instances (two installs, or a dev and a prod instance) can end up sharing one Docker daemon; without this, a brand-labeled or brand-namespaced resource alone doesn't say which instance created it, so one instance's stale-resource cleanup could mistake the other's live resource for its own garbage and remove it. Under ReservedLabelPrefix so no operator-supplied label can collide with or spoof it (ValidateLabels below already rejects that prefix).
const PendingImageTag = ":pending"
PendingImageTag is the tag an app is created with when its first image has not been built yet (the CLI's own git-build path, which must send some image for an app POST that requires one). It never names a real registry image, so the application reconciler treats it as "not built yet" rather than something to pull.
const ReservedLabelPrefix = "platform-reserved."
ReservedLabelPrefix is the Docker label key namespace no operator- supplied custom label may use, keeping it open for this platform's own bookkeeping labels (none exist yet: today a service's containers are found purely by name pattern, internal/reconcile/application's ContainerName/ownsContainer, not by label). It's a fixed, brand- independent string on purpose, not derived from brand.ShortName: brand.ShortName is runtime-configurable (APP_BRAND_SHORT_NAME), and a validation boundary keyed to a value that can change between deploys would make a previously-valid app.yaml unpredictably valid or invalid depending on unrelated brand config. It also has to hold for cmd/levelrail-cli, which validates a local app.yaml with no server connection and therefore no brand config to consult at all. This package stays decoupled from internal/brand for the same reason DiscoverPath (discover.go) does.
Variables ¶
This section is empty.
Functions ¶
func DiscoverPath ¶
DiscoverPath finds the app spec file in dir, checking candidate filenames in order and returning the first one that exists. The spec file is discovered by a list of candidate filenames (app.yaml, deploy.yaml, plus the branded one) so a product rename does not break existing users later. The generic names are checked first for exactly that reason: if the product is ever renamed, brandedName changes, but a repo with an app.yaml already committed keeps working without the user touching anything.
brandedName is the caller-supplied branded filename stem (derived from brand.Brand, not imported here, so this package stays decoupled from internal/brand); pass "" to skip it, e.g. in tests.
func IsPendingImage ¶ added in v0.2.0
IsPendingImage reports whether image is a PendingImageTag placeholder.
func SchemaJSON ¶ added in v0.2.0
SchemaJSON returns the embedded app.yaml JSON Schema, for tooling that composes it into larger documents.
func ValidateDocument ¶ added in v0.2.0
ValidateDocument runs the same two layers Parse does (schema, then Validate's semantic rules) but never stops at the first problem: every schema violation comes back at once, located by path and line, for a live editor to annotate while the operator is still typing. The returned Spec is non-nil only when there are no issues. Deploy time still calls Parse directly; this exists only to give that same check friendlier, earlier feedback, not a second source of truth.
func ValidateLabels ¶
ValidateLabels checks labels against the rules above: reserved prefix, empty keys, and the sanity limits. Exported so internal/api's validateAppResource can run the identical check on API-sourced labels (which bypass Parse/Validate entirely, they're never described in app.yaml), the same "define once here, both layers call it" shape StrategyRolling/StrategyRecreate/StrategyBlueGreen already establish for the API layer to reference rather than re-derive.
Types ¶
type Build ¶
type Build struct {
Type string `yaml:"type"`
Path string `yaml:"path,omitempty"`
// BaseDirectory scopes the build context to a subdirectory of the
// repo, e.g. "apps/web" in a monorepo. Empty means the repo root.
// Meaningful for dockerfile, railpack, and static; not meaningful
// for image (nothing gets built) or compose (the compose file's own
// context: field already scopes each service).
BaseDirectory string `yaml:"baseDirectory,omitempty"`
// Image is a full registry reference (e.g.
// "ghcr.io/org/app:v1.2.3"), only meaningful for build.type: image:
// a CI pipeline (or anything else) already built and pushed this
// exact image, so there is nothing for this control plane to build,
// only to deploy as-is. See internal/deploy's deployImage.
Image string `yaml:"image,omitempty"`
// RegistryCredential names a store.RegistryCredential (by its Name,
// not ID: app.yaml is hand-written, an opaque ID would be hostile
// to author) to authenticate with when pulling Image from a private
// registry. Empty means an unauthenticated (public) pull.
RegistryCredential string `yaml:"registryCredential,omitempty"`
// Args are Dockerfile build-time ARG values (e.g. a base image
// version, a build-time feature flag), passed through as
// --build-arg equivalents to BuildKit. Only meaningful for
// build.type: dockerfile.
Args map[string]string `yaml:"args,omitempty"`
}
Build describes how a service's image gets built.
type Database ¶
type Database struct {
Engine string `yaml:"engine"`
Version string `yaml:"version,omitempty"`
Backup *Backup `yaml:"backup,omitempty"`
// EphemeralInPreviews opts this database into a disposable,
// preview-scoped instance of its own (a full container, its own
// volume, its own credentials) rather than sharing whatever this
// database resolves to in production: one per pull request, created
// alongside the preview and destroyed with it, with no restore path
// once torn down. Off by default, like every other opt-in toggle in
// this codebase; only meaningful for a database attached to an app
// with preview environments enabled (store.GitSource.PreviewEnabled).
EphemeralInPreviews bool `yaml:"ephemeralInPreviews,omitempty"`
// IsolatedInPreviews opts this database into an isolated credential
// per preview, created on this same existing database (no new
// container, no new volume) rather than the whole disposable
// instance EphemeralInPreviews creates: a Postgres role, or a Redis
// ACL user scoped to its own key prefix. Ignored when
// EphemeralInPreviews is also set, since an ephemeral instance
// already has its own credentials. Postgres and Redis only today.
// Off by default.
IsolatedInPreviews bool `yaml:"isolatedInPreviews,omitempty"`
}
Database is one entry under databases:.
type Egress ¶ added in v0.2.0
type Egress struct {
Mode string `yaml:"mode"`
Allow []EgressAllow `yaml:"allow,omitempty"`
}
Egress is a service's outbound network policy.
type EgressAllow ¶ added in v0.2.0
EgressAllow is one host+port pair a service's outbound traffic may reach when Egress.Mode is allowlist. Host is a bare hostname, resolved (and re-resolved on an interval, DNS records aren't static) by the egress sidecar rather than pinned to an IP at deploy time.
type EnvVar ¶
type EnvVar struct {
// Value is a literal value, set when the YAML node was a plain
// string scalar rather than a mapping.
Value string
// From references another resource's computed value, e.g.
// "postgres.main.url". Mutually exclusive with Value, Secret, and
// Vault.
From string
// Secret means the operator provides this at deploy time via
// the platform's own envelope-encrypted secret storage, never
// written to app.yaml or the git repo. Mutually exclusive with
// Vault: a given env var resolves its value from exactly one of
// the two secret sources, never both.
Secret bool
// Required, only meaningful alongside Secret: fail the deploy if no
// value has been provided, rather than starting the container with
// the variable unset.
Required bool
// Vault means this value is resolved live from an external
// HashiCorp Vault instance at container-create time
// (internal/reconcile/application's resolveEnv), never stored by
// this platform at all: the alternative to Secret above, not a
// variant of it. nil means not vault-backed.
Vault *VaultRef
}
EnvVar is one entry in a service's env block. app.yaml allows two shapes for the same field, per the app spec's own example, which shows both:
DATABASE_URL: { from: postgres.main.url }
API_KEY: { secret: true, required: true }
A plain string scalar is also accepted as shorthand for a literal value, since forcing every simple env var into object form would make the common case more verbose than the shape it's modeled on (Docker Compose's own env shorthand).
func (*EnvVar) UnmarshalYAML ¶
UnmarshalYAML implements the string-or-object union described on EnvVar, using yaml.v3's node-based Unmarshaler interface: the node's Kind tells a scalar apart from a mapping before deciding which shape to decode into, rather than trying one and falling back on error.
type ExecCommand ¶ added in v0.2.0
type ExecCommand []string
ExecCommand is health.*.exec: a list is run as argv, a string through /bin/sh -c.
func (*ExecCommand) UnmarshalJSON ¶ added in v0.2.0
func (e *ExecCommand) UnmarshalJSON(data []byte) error
UnmarshalJSON accepts a shell string or an argv array.
func (*ExecCommand) UnmarshalYAML ¶ added in v0.2.0
func (e *ExecCommand) UnmarshalYAML(node *yaml.Node) error
UnmarshalYAML accepts a shell string or an argv list.
type GPU ¶ added in v0.2.0
type GPU struct {
Count int `yaml:"count,omitempty" json:"count,omitempty"`
Devices []string `yaml:"devices,omitempty" json:"devices,omitempty"`
}
GPU is resources.gpu: `all`, a count, or {count, devices} where devices are NVIDIA indexes or UUIDs (devices wins over count).
func (*GPU) UnmarshalJSON ¶ added in v0.2.0
UnmarshalJSON accepts "all", a number, or an object.
type Health ¶
type Health struct {
Readiness *Probe `yaml:"readiness,omitempty"`
Liveness *Probe `yaml:"liveness,omitempty"`
// ReadyTimeout overrides how long a fresh deploy waits for Readiness
// to pass before the deploy is marked ReadinessFailed (internal/
// reconcile/application.defaultReadyBudget, 60s, when unset). A slow
// cold start (a JVM app, a big migration, a slow external connection)
// can legitimately take longer than 60s without ever being unhealthy.
ReadyTimeout string `yaml:"readyTimeout,omitempty"`
}
Health holds a service's readiness and liveness probe configuration.
type Hooks ¶
type Hooks struct {
PreDeploy string `yaml:"preDeploy,omitempty"`
PostDeploy string `yaml:"postDeploy,omitempty"`
}
Hooks are the two deploy-lifecycle commands a service can declare. Both run via "sh -c" inside the newly created container (see internal/reconcile/application.Controller's own doc comment for the full timing and failure-handling contract): PreDeploy before the container's readiness probe and before any old container is retired, PostDeploy after the whole replica set has cut over. Either field may be set alone.
type Issue ¶ added in v0.2.0
type Issue struct {
Path string `json:"path"`
Line int `json:"line"`
Message string `json:"message"`
}
Issue is one problem found in an app.yaml document, located by dotted path and source line when known. Mirrors internal/pipeline.Issue's shape, the live-validation convention this codebase already follows for its own declarative YAML format.
type Probe ¶
type Probe struct {
Path string `yaml:"path,omitempty"`
Scheme string `yaml:"scheme,omitempty" json:"scheme,omitempty"`
Host string `yaml:"host,omitempty" json:"host,omitempty"`
TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"`
FollowRedirects *bool `yaml:"follow_redirects,omitempty" json:"follow_redirects,omitempty"`
ExpectedStatus StatusCodes `yaml:"expected_status,omitempty" json:"expected_status,omitempty"`
Exec ExecCommand `yaml:"exec,omitempty" json:"exec,omitempty"`
Interval string `yaml:"interval,omitempty"`
Timeout string `yaml:"timeout,omitempty"`
Failures int `yaml:"failures,omitempty"`
}
Probe is a single health check: an HTTP(S) request to Path, or a command run inside the container when Exec is set.
type Resources ¶
type Resources struct {
Memory string `yaml:"memory,omitempty"`
CPU float64 `yaml:"cpu,omitempty"`
// SwapMemory caps memory plus swap combined, the same "512Mi"/"1Gi"
// shape as Memory (Docker's own MemorySwap semantics); only
// meaningful alongside Memory, see Validate.
SwapMemory string `yaml:"swapMemory,omitempty"`
// CPUSet pins the container to specific host CPUs, Docker's own
// cpuset-cpus format (e.g. "0-3" or "0,2").
CPUSet string `yaml:"cpuSet,omitempty"`
// GPU requests NVIDIA GPUs; the service is placed only on a node
// with a usable GPU. nil means no GPU.
GPU *GPU `yaml:"gpu,omitempty"`
}
Resources holds a service's resource limits.
type Service ¶
type Service struct {
Build Build `yaml:"build"`
// Image is shorthand for build: {type: image, image: ...}; Parse folds it
// into Build, so consumers only ever read Build.
Image string `yaml:"image,omitempty"`
Domains []string `yaml:"domains,omitempty"`
Port int `yaml:"port,omitempty"`
// HostPort pins the host-side port Docker binds Port to, 0 meaning
// auto-assign (the same zero-value-means-unset convention Port
// itself uses). Its storage home is store.DesiredService.HostPort
// (migrations/0056_service_host_port.sql).
HostPort int `yaml:"host_port,omitempty"`
// BindAddress picks which network interface Port (and HostPort, if
// pinned) binds to on the host: "private" (loopback only, the
// default when empty), "public" (every interface, an explicit opt-
// in), or a literal IP. See internal/bindaddr.Resolve for the exact
// rules; EffectiveBindAddress applies the default.
BindAddress string `yaml:"bind_address,omitempty"`
Health *Health `yaml:"health,omitempty"`
Resources *Resources `yaml:"resources,omitempty"`
Env map[string]EnvVar `yaml:"env,omitempty"`
Replicas int `yaml:"replicas,omitempty"`
Strategy string `yaml:"strategy,omitempty"`
// Labels are arbitrary operator-supplied Docker labels applied to the
// service's container at create time, an escape hatch for tooling
// this platform doesn't know about (a monitoring agent or log
// shipper that keys off container labels, a homegrown script, and
// so on). See ValidateLabels (labels.go) for what's rejected:
// notably, any key under ReservedLabelPrefix, kept open for this
// platform's own bookkeeping labels.
Labels map[string]string `yaml:"labels,omitempty"`
// Volumes are named Docker volumes or host-directory bind mounts this
// service's container mounts (see Volume's own doc comment for how
// the two are distinguished): named volumes were previously a
// database-only capability, bind mounts a compose-import-only one. A
// named volume's Name is a logical name scoped to this service, not a
// global Docker volume name (see internal/deploy's translation into
// store.ServiceVolume for the actual, platform-prefixed name); two
// services can each declare a volume named "data" without colliding.
Volumes []Volume `yaml:"volumes,omitempty"`
// Hooks are shell commands the reconciler runs inside this service's
// own container at defined points in a deploy (internal/reconcile/
// application's controller). Nil means neither is configured, the
// same "declarative, resolved before storing" shape Health/Resources
// already follow.
Hooks *Hooks `yaml:"hooks,omitempty"`
// Command overrides the image's own default CMD
// (store.DesiredService.Command), nil meaning the image's own
// default: the app.yaml equivalent of
// internal/compose.Service.Command, which this mirrors. A plain argv
// list, never shell-interpreted.
Command []string `yaml:"command,omitempty"`
// Egress restricts this service's outbound network traffic to an
// explicit host+port allowlist, enforced by
// internal/reconcile/application's egress sidecar. Nil (the default,
// and the only state every service had before this field existed)
// means today's open-egress behavior, unchanged: this is deliberately
// opt-in, not deny-by-default, since Levelrail runs arbitrary
// third-party apps for existing users rather than AX's own
// tightly-scoped agent sandboxes, and defaulting every existing app
// to deny-all egress on upgrade would be a breaking change.
Egress *Egress `yaml:"egress,omitempty"`
// LoadBalancer configures balancing across this service's replicas
// (internal/loadbalancer). Nil keeps the plain single-upstream route.
LoadBalancer *loadbalancer.Config `yaml:"loadbalancer,omitempty"`
// DependsOn names sibling services: keys (within this same Spec, or
// within the same compose file for a compose-expanded service, see
// internal/compose.ExpandBuildService) this service waits on before
// the reconciler starts its own containers: internal/reconcile/
// application.Controller does not create a container for this
// service until every named dependency has at least one running
// container. This matches real Docker Compose's own default
// depends_on: semantic (service_started, not service_healthy): a
// start-order guarantee, not a health-based wait. See Validate for
// the reference and cycle checks.
DependsOn []string `yaml:"dependsOn,omitempty"`
}
Service is one entry under services:.
func (*Service) EffectiveBindAddress ¶ added in v0.2.0
EffectiveBindAddress returns svc.BindAddress, or bindaddr.Default (private, loopback-only) if unset. Mirrors EffectiveStrategy/ EffectiveReplicas' own "resolve the default here, once" shape.
func (*Service) EffectiveReplicas ¶
EffectiveReplicas returns svc.Replicas, or DefaultReplicas if unset. Schema validation guarantees Replicas is never negative when set; zero means "not specified in app.yaml", not "zero replicas".
func (*Service) EffectiveStrategy ¶
EffectiveStrategy returns svc.Strategy, or the default (blue-green, since it's easier to get right than rolling with a single replica) if unset.
type Spec ¶
type Spec struct {
Version int `yaml:"version"`
Services map[string]Service `yaml:"services"`
Databases map[string]Database `yaml:"databases,omitempty"`
}
Spec is a fully parsed, schema-valid app.yaml.
func Parse ¶
Parse validates raw app.yaml bytes against the embedded JSON Schema, then unmarshals into a Spec and runs the semantic checks the schema can't express. Both layers must pass for Parse to succeed; a caller never sees a Spec that's schema-valid but semantically broken, or vice versa.
func (*Spec) LoadBalancerFor ¶ added in v0.2.0
func (s *Spec) LoadBalancerFor(service string) (*loadbalancer.Config, error)
LoadBalancerFor returns a service's loadbalancer: block, or nil when the service has none.
func (*Spec) Validate ¶
Validate checks rules the JSON Schema can't express: values whose validity depends on another field, or on the rest of the document, not just their own shape. Parse always runs this after schema validation succeeds, callers building a Spec by hand (tests, future tooling) should call it too before trusting a Spec.
type StatusCodes ¶ added in v0.2.0
type StatusCodes string
StatusCodes is health.*.expected_status: "200-399", 204, or a list mixing codes and ranges. It is kept in its normalized string form.
func (*StatusCodes) UnmarshalJSON ¶ added in v0.2.0
func (s *StatusCodes) UnmarshalJSON(data []byte) error
UnmarshalJSON accepts a string, a number, or an array of either.
func (*StatusCodes) UnmarshalYAML ¶ added in v0.2.0
func (s *StatusCodes) UnmarshalYAML(node *yaml.Node) error
UnmarshalYAML accepts a scalar or a sequence of scalars.
type VaultRef ¶ added in v0.2.0
VaultRef is app.yaml's { vault: { path, key } } env var shape: Path is the Vault KV secret's path, Key is the field name within that secret's data. Both required together, mirroring internal/store's own VaultEnvRef which this parses into (see internal/deploy's vaultEnvRefs).
type Volume ¶
type Volume struct {
Name string `yaml:"name,omitempty"`
HostPath string `yaml:"hostPath,omitempty"`
Path string `yaml:"path"`
ReadOnly bool `yaml:"readOnly,omitempty"`
}
Volume is one entry under a service's volumes:. Exactly one of Name (a named Docker volume) or HostPath (a bind mount of a real host directory, gated the same as internal/compose's own bind-mount support: see validateBindMountHostPath) is set; see Validate. ReadOnly is only meaningful alongside HostPath, matching store.ServiceBindMount.