Killing YAML

Share

Config is where most people actually meet an orchestrator, and Kubernetes greets them with YAML. Then, because YAML can't express anything dynamic, it greets them with templated YAML, which is how the world got Helm and Kustomize and a working knowledge of where whitespace is significant. Borg had the opposite problem from the opposite direction: BCL was powerful enough to need a manual, which is its own way of failing the person who just wants to run a web server.

The post-zero thesis was that you don't fix this with a nicer markup language. You fix it by making config a typed program that compiles, so the errors land at build time, in your editor, instead of at apply time, in production.

What it looks like

Here's a real Orbit config, the example that ships in the repo:

mission "web" {
  owner  = default
  intent = slo {
    p99_latency_ms = 100
    availability   = 0.99
  }

  module "api" {
    payload   = image("docker.io/library/nginx:alpine")
    appclass  = latency_sensitive
    replicas  = managed
    cpu_millis = 100
    memory_bytes = 134217728
    ports     = [named("http", 80)]
  }

  ingress "public" {
    host   = "ottawa.canoozie.net"
    tls    = disabled
    routes = [route("/", "api", "http")]
  }
}

It reads like config, not like a program, which is the point, the casual user shouldn't feel like they're writing code. But everything in it is typed. slo { ... } is an intent block, not a string. image(...), named(...), route(...) are typed constructors. latency_sensitive is an app class the compiler knows, not a label it'll happily accept misspelled. There's no templating layer, because the things people reach for templating to do, composition, reuse, conditionals, are the things I'd rather solve in the type system than in a string-substitution preprocessor.

Three stages, three kinds of error

A .orbit file becomes a runnable mission through a pipeline, and each stage has its own error type, so a failure tells you which kind of wrong you are:

pub fn compile_orbit_config(input: &str) -> Result<Mission, FrontendError> {
    let config = parse_orbit_config(input)?;   // text  -> TypedConfig
    ConfigCompiler.compile(&config)            // TypedConfig -> Mission
        .map_err(FrontendError::Compile)
}

pub enum FrontendError {
    Parse(ParseError),     // it isn't valid syntax
    Compile(CompileError), // it parses but doesn't make sense
}

The first stage is the parser, and it tracks position so a syntax error points at the character that broke:

pub struct ParseError {
    pub message: String,
    pub line: usize,
    pub column: usize,
}
// Display: "unexpected character `%` at 7:14"

The lexer carries a line and column through every token, so the moment it hits something it can't read, it can tell you where. This is table stakes for a language and exactly the kind of thing YAML-plus-templating fumbles, because by the time a Helm template has been rendered, the line number in the error refers to generated output you never wrote.

The second stage is the compiler, which turns the parsed TypedConfig into the Mission IR and catches the errors that are about meaning rather than syntax:

pub enum CompileError {
    DuplicateModule(String),
    EmptyImage(String),
    UnknownSpreadDomain { module: String, domain: String },
    DuplicatePortName { module: String, port: String },
    InvalidTargetPort { module: String, port: String },
    AcceleratorCountZero { module: String },
    InvalidMission(#[from] ModelValidationError), // <-- the deep checks
}

Two modules with the same name. A spread domain that isn't host, rack, power, or zone. A gang with zero accelerators. These are caught while compiling, before anything is sent anywhere.

And notice that last variant. CompileError::InvalidMission wraps ModelValidationError, which is the validation from the object-model post, the deep, cross-referencing checks like "this ingress route points at a port that doesn't exist." The config frontend doesn't reimplement validation. It compiles to the Mission type and then runs the same validate the API and the airlock run, so a config error and an API error for the same mistake are the same error. There's one definition of "valid mission," and every door uses it.

Intent in, numbers optional

Look back at the example and notice what the compiler does with the easy-to-miss parts:

let replicas = match m.replicas {
    Some(n) if n > 0 => ReplicaPolicy::Fixed(n),
    _                => ReplicaPolicy::Managed,
};
// ...
needs: ResourceVec {
    cpu_millis:   m.cpu_millis.unwrap_or(0),
    memory_bytes: m.memory_bytes.unwrap_or(0),
    // ...
},

The resource numbers are Option. replicas = managed compiles to the policy that hands the replica count to the Stationkeeper. A module that omits cpu_millis and memory_bytes entirely isn't an error; it's a module whose sizing the autoscaler owns, which is exactly the default path the stationkeeping post argued for. The example pins numbers because it's a demo, but the language doesn't make you. You can write a module that declares an image, an app class, and an intent, and nothing else, and the system fills in the rest. Intent in, numbers out, expressed as which fields you're allowed to leave blank.

One schema, every consumer

The reason this compiles to the Mission IR specifically, rather than to some config-only representation, is that Mission is the single currency the whole system trades in. The CLI compiles to it. The gRPC API carries it. The airlock validates it. The scheduler reads it. And the wire schema is required to be lossless against the IR: a valid mission converted to the API representation and back has to come out semantically identical, with no placement, identity, gang, checkpoint, tag, or intent field quietly dropped along the way.

That losslessness is a real constraint I have to keep honoring as the model grows, and it's what lets orbitctl and the config compiler and the control plane all share one definition instead of three subtly-different ones that drift apart. A field added to Mission has to survive the round trip or the tests fail.

The CLI surface around it is small:

pub enum Command {
    Deploy { mission: Mission },
    List,
    Cancel { mission_id: MissionId },
    Scale  { mission_id: MissionId, module_name: ModuleName, replicas: u32 },
    Status,
    Validate { config: TypedConfig },
}

Validate is the one I want to point at. It compiles a config all the way to a validated Mission and reports any error, parse, compile, or model, without deploying anything. That's a config check you can run in CI, on every pull request, and have it catch the ingress-route-points-at-a-missing-port bug before it merges, not after it's live. In a YAML world that class of bug is found by kubectl apply in an environment you hoped was staging.

The honest part

I said in the first post that a typed config language lives or dies on its error messages, and that getting them wrong just means I've built a second thing for people to hate. I stand by that, and I'm not going to claim I've gotten there.

What exists is the right structure: three staged error types, position tracking in the lexer, and one shared definition of validity. What's missing is the polish that separates "better than YAML" from "actually pleasant", did-you-mean suggestions for a misspelled app class, reporting all the errors in a file instead of dying on the first, underlining the offending span instead of just naming a line and column. That work is a product in itself, and it's not done. A parser is a weekend; a good error story is a quarter.

There's also a deliberate gap I want to be upfront about. The language has no variables, no imports, no conditionals, none of the composition machinery people use Helm for. That's on purpose, because that machinery is most of what makes templated YAML miserable. But "we don't do that" isn't a complete answer to "how do I run the same mission across five environments with different sizes," and the honest version is that the answer should come from typed composition, modules and parameters in the language itself, rather than from string substitution bolted on top. Designing that without reinventing the thing I'm trying to kill is open work, and it's the kind of open work I'd rather show you the edges of than pretend is finished.

Next: the simulator, and the genuinely fun consequence of having built the core as a pure, replayable thing, the same binary that runs the cluster can rehearse it.