Building

Building images

This document describes the .oth language images are written in, how an image uses another, and how one is sealed into a single file for deployment, in the way a Docker image is built from a Dockerfile. It builds on the namespace design.

One language

Images and headers are both .oth files, in one language. A file can declare:

Declaration What it is
protocol, trait named shapes (see Objects)
object the interface of a Wasm module
needs <at> <shape> a mount point in the image's view, filled by whoever instantiates it
instance an object, or another image, made part of this one
mount, bind the views
export <path> what an instance of the image serves
startup requests requests the image makes when it starts
require a slot: a value whoever instantiates the image gives it

A module's header is a file that declares an object and no instances. A deployable image is a file with instances and only needs the runtime can fill. A test is a file that instantiates another image with stand-ins.

Imports and exports

import Site, { HttpServer as Http, FileTree } from "./site.oth"
import { Listener, Console } from env

Paths are relative to the importing file.

Instances

instance pages : StaticSite { mount /files files readonly }
instance files : PackedFiles { include "./public/" }
instance web   : Site { bind /net/tcp /net/tcp }

An instance's type is one of:

No file can declare an instance of an environment object. An image gets those through its needs.

Instances can be declared in any order, so an image can start from the instance at the top of the namespace and work down.

Views

needs /net/tcp Listener                 # the image's view: filled from outside

instance http : HttpServer {
  bind  /net/tcp /net/tcp               # http's view: from the image's
  mount /www pages                      # http's view: an instance's tree
}

mount /http http                        # the image's view: an instance's tree

An image as an object

An instance of an image is an object made of objects, as a Plan 9 namespace can itself be served as a tree:

# site.oth: the static site, which touches nothing of its own
import { HttpServer } from "../../sdk/objects/http/HttpServer.oth"
import { StaticSite } from "../../sdk/objects/site/StaticSite.oth"
import { Listener, Console, PackedFiles } from env

needs /net/tcp  Listener
needs /dev/cons Console

# HTTP in, namespace requests out. It reaches the files only through pages.
instance http : HttpServer {
  bind  /net/tcp  /net/tcp
  bind  /dev/cons /dev/cons
  mount /www pages
}

# The pages can read the files and nothing else.
instance pages : StaticSite {
  mount /files files readonly
}

instance files : PackedFiles { include "./public/" }

mount /http http

write /http/hosts/* "/www"
write /http/hosts/files.localhost "/www/static"

# Whoever runs the site can add hosts, and nothing else.
export /http/hosts at /hosts

For instance web : Site { ... }:

The checker computes reach through the instances of images as through any others, and --check prints them by their full names.

The root image

The image being run is the root. Its needs are filled by the runtime with environment objects, chosen by the trait each one names (see Environment objects):

# static.oth: deploying the site
import Site from "./site.oth"
import { Listener, Console } from env

# Where to listen. Deploy with e.g. `--set listen=0.0.0.0:80`.
require listen : text/plain = "127.0.0.1:8080"

# Everything this program can do to the host: one listener, and the console.
needs /net/tcp  Listener
needs /dev/cons Console

instance web : Site {
  bind /net/tcp  /net/tcp
  bind /dev/cons /dev/cons
}

write /net/tcp listen

So the root's needs are the whole answer to "what can this program do to the machine it runs on". An image that is imported is never the root, and its needs are always filled by its parent, with real objects passed down or with in-memory ones.

Startup requests

write /http/hosts/* "/www"
write /greeter/greeting Greeting { prefix: "Hello, ", excited: true }
write /net/tcp listen

<verb> <path> [payload] is a request the image makes at startup, resolved in the image's own view like any other request. The image can only send requests to what its view holds, so an image with no mounts or needs sends none. Requests can appear anywhere in the image, and are made in the order they are written. They carry the operator flag. The image ignores answers, except that an error is printed to stderr as a warning.

A payload is written one of these ways:

Slots: values from outside

An image can require values from whoever instantiates it:

require listen : text/plain = "127.0.0.1:8080"     # a default
require admin  : text/plain                        # none: it must be given
require greeting : Greeting = { prefix: "Hello, ", excited: false }

An instantiated image's slots are given in its instance block:

instance web : Site {
  bind /net/tcp /net/tcp
  hosts = "example.com"
}

A slot the block leaves out keeps its default, and one with no default must be given. Only the root's slots are left to whoever runs it.

Giving the root's slots

The host gives values in layers, and a later layer wins, field by field for a message slot:

  1. the image's defaults,
  2. value files, in the order given (--with prod.vals),
  3. --set slot=value or --set slot.field=value.

A value file uses the image's own syntax:

listen = "0.0.0.0:80"
greeting = { prefix: "Hi, " }

Value files are relative to the working directory. --set takes the raw text for a text slot or a string field, and a literal for anything else (--set listen=0.0.0.0:80, --set greeting.excited=true).

The image refuses to start unless every slot has a value, and every field of every message one. It lists everything missing, e.g. admin: required, not provided. A slot or field that the image doesn't declare is an error, and so is a value the slot's format rejects. --check prints each slot's final value and where it came from:

$ othismo --check static.rti --set listen=0.0.0.0:80
slot listen : text/plain = "0.0.0.0:80" (--set)

from the host:
  /net/tcp    listens on 0.0.0.0:80
  /dev/cons   reads stdin, writes stdout
...

Building

Running an image reads several other files: the compiled modules, each with its header and schemas inside, the .oth files it imports, and whatever its PackedFiles include. A built image (.rti) holds everything the image needs except what it takes from the host through its needs, so it can be copied to another machine and run there.

othismo build static.oth -o static.rti     # seal
othismo run static.rti                     # run it, here or elsewhere
othismo static.oth                         # build in memory, then run
othismo unpack static.rti out/             # modules, files and sources

Running a .oth builds it in memory and runs the result, so there is one way to load an image. Packed trees are a snapshot taken when the image is built; for live edits during development, use Files.

What a built image holds

Packed files

PackedFiles serves a tree that was copied into the image when it was built. It is the build-time counterpart of Files, which reads the host at run time:

# The host's files, live, in the root:
needs /files Files
write /files "/var/www"

# A snapshot, taken when the image is built, anywhere:
instance files : PackedFiles {
  include "./public/"
  include "./prod/robots.txt" at /robots.txt
}

PackedFiles touches nothing on the host when the image runs, and --check reports it as built in. It answers read, stat and list exactly as Files does (the same content types, stat records and errors). Every unsafe verb gets read_only.

include

Paths are relative to the image file.

A directory's contents land at the root of the served tree, and a single file lands at /<its name>. at /path puts either somewhere else.

Later includes win. Includes apply in order, and where two put something at the same path, the later one replaces it:

build -v lists every path an include replaced.

Testing

Tests are objects, not part of the language. A test suite is any object whose header declares the runtime's TestSuite trait:

export trait TestSuite {
  /tests          list         # its tests, by name
  /tests/{name}   write        # runs one: ok if it passed, an error saying why if not
}

A test image is a root with no needs. It instantiates the image under test, gives it in-memory objects where a deployment would give it real ones, and puts a suite beside it:

# examples/static/static.test.oth
import Site from "./site.oth"
import { SiteTests } from "./scripts/SiteTests.oth"
import { MemoryListener, MemoryConsole } from env

instance net  : MemoryListener
instance cons : MemoryConsole

instance web : Site {
  mount /net/tcp  net
  mount /dev/cons cons
}

instance tests : SiteTests {
  mount /net net              # the site's listener, to dial
}

SiteTests (in examples/static/scripts/) dials the site with one HTTP request per test, and checks how the response begins.

$ othismo test examples/static/static.test.oth
tests serves_the_index ... ok
tests missing_pages_are_not_found ... ok
...
6 passed, 0 failed

othismo test lists every suite's tests in one run of the image, then runs each test in a fresh one: startup requests and every start(), then write /tests/<name>, then everything that follows from it until nothing is left to deliver. A test image has no needs, so nothing outside is started, and a test can only reach what the image has in memory. A test that never answers fails. What the image prints on its consoles is shown for each test that fails, and the command exits with a failure if any did.

The file format

"RTIMAGE" 0x07        magic and format version
u64 (LE)              length of the manifest
manifest              one BSON document
blobs                 file and module contents, back to back

The manifest being BSON is a detail of the file, unrelated to the BSON payload format. The version changes whenever the manifest's layout does. An image built in another format is refused with a request to rebuild it from its .oth; built images are for deploying, not for keeping across versions.

Every module, packed file and source is a blob named by the SHA-256 of its contents, and stored once however often it appears. The manifest refers to blobs by hash and records each one's offset and length. Building the same inputs twice produces the same bytes: the manifest is ordered, and nothing depends on timestamps or the order a directory is read in.

Not yet