Skip to content

v0.2.0

Choose a tag to compare

@github-actions github-actions released this 30 Jul 12:19

Mostly about the config file: a stack of a dozen services can now be split
across files, carry per-checkout values, keep its optional parts out of the way
until asked for, and say what "ready" means on each dependency edge.

Added

  • restart = "unless-stopped", for the service you sometimes run yourself:

    [services.api]
    command = ["node", "server.js"]
    restart = "unless-stopped"

    It restarts exactly like always, except that a service stopped with
    servicrab stop stays stopped across servicrab down and the next
    servicrab start. A hand-stopped service was already left alone for as long
    as its daemon lived, whatever its policy; what this policy adds is the memory.
    servicrab start api hands the service back.

    The memory is a list of names in .servicrab/stopped, plain text so that
    deleting it — or a line of it — forgets a stop. It records every hand stop but
    only unless-stopped services act on it, so adopting this changes nothing
    about an existing stack. Dependents of a held-back service start out stopped
    too: a service cannot run without what it declares in depends_on, and
    starting one to wait for something nobody will start is not better than
    leaving it alone. start --wait does not wait for either kind, and
    servicrab up ignores the whole thing — a foreground run has nothing to
    remember.

  • Profiles, for the services you only sometimes want:

    [services.mailhog]
    command = ["mailhog"]
    profiles = ["dev"]

    A service that declares no profiles is always started; one that declares any
    waits for servicrab up --profile dev (or start, watch, and generate,
    which writes the flag into the unit it produces). Several --profile flags
    add up, and a service in several profiles joins when any one of them is
    enabled. servicrab list shows the groups, in the table and in --json.

    Naming a service starts it whatever its profiles say, so a profiled service
    never needs the flag to be targeted directly. Because that makes explicit
    names a second way of selecting, passing both is refused rather than silently
    resolved in favour of one.

    Dependencies come along regardless of their profiles: a service can never run
    without what it declares in depends_on. The daemon keeps the profiles it was
    started with, so servicrab reload re-plans the same stack instead of the
    smaller one a bare start would have produced. A --profile no service
    declares is an error listing the ones that exist.

  • include, so a stack of a dozen services does not have to live in one file:

    version = 1
    include = ["services/db.toml", "services/api.toml"]

    An included file holds [services.<name>] tables and may include further
    files; version and [project] stay in the config every command is pointed
    at, not least because the project name decides where the daemon keeps its
    socket.

    Relative paths in an included file belong to that file — its own include
    entries, and the cwd and env_file of the services it declares — so a
    fragment can be moved together with the code it describes.

    Merging is not overriding: two files declaring the same service, an include
    cycle, the same file included twice, and version or [project] in an
    included file are all configuration errors, reported with the file names
    involved. include paths are not ${VAR}-substituted, because which files
    make up a config should not depend on who ran it.

  • ${VAR} substitution in every value of servicrab.toml, so a committed
    config can serve checkouts that disagree about where things live:

    [services.api]
    command = ["${NODE:-node}", "server.js"]
    cwd = "${WORKSPACE}/api"

    ${VAR:-default} falls back when the variable is unset or empty, ${VAR-default}
    only when it is unset, and $${VAR} is a literal ${VAR}. An unset variable
    with no default is a configuration error naming the variable and the field,
    not an empty string: a cwd that silently became / or a command that
    silently lost an argument is worse than a config that refuses to load.

    Unlike Docker Compose, the braces are required — a bare $ is never special.
    Half the commands in a process manager are shell snippets, and eating the $i
    of while ...; do echo $i; done at load time, against the wrong environment,
    would cost more than it saves.

    Values are read from the environment servicrab runs in, not from
    [project.env], [services.<name>.env] or an env_file, which describe what
    the service will see. Table keys and the project and service names are not
    substituted; the project name decides where the daemon keeps its socket, and a
    control socket that moves with the environment would be a debugging trap.

  • Docker-Compose-style conditions on depends_on, spelled with the table form:

    [services.api.depends_on]
    db = { condition = "service_healthy" }
    migrate = { condition = "service_completed_successfully" }

    service_completed_successfully is the one that was missing: it is the only
    condition that looks at the exit status, so a dependent is no longer started
    against a half-migrated database when the migration exits non-zero. The other
    two conditions keep treating a one-shot that has exited as available whatever
    its status, because a condition a finished process can never meet again would
    deadlock the stack.

    The list form and its behaviour are unchanged, and leaving the condition out
    is deliberately not the same as service_started: it still means "healthy
    if the dependency declares a health check, up otherwise", so adding a health
    check keeps gating everything that depends on that service. Spelling out
    service_started is now the way to opt out of that for one edge.

    Conditions that can never be met are rejected at load time: service_healthy
    on a service with no [health] block, and service_completed_successfully on
    a service with restart = "always", which never stays exited.

  • servicrab start --wait returns only once every service is ready — running,
    and health-checked if it declares a health check — with --timeout to bound
    the wait (60s by default). A one-shot service that has already exited counts
    as ready, which is the same definition the supervisor uses to release a
    dependent. The exit code says whether it worked, so a CI script can stop
    guessing with sleep. The daemon is left running on failure, because a stack
    that came up wrong is easier to diagnose alive.

  • servicrab man prints the man page in roff, or writes one page per command
    into a directory with --output. Release tarballs now ship the generated
    pages under man/. The pages come from the same command definitions as
    --help; files, environment variables and exit codes are documented by hand
    on the main page.

  • A dependency audit (cargo-deny) with the policy in deny.toml: RustSec
    advisories, unmaintained crates, licences and package sources. It runs in its
    own Audit workflow — on dependency changes, and weekly, because advisories
    are published on someone else's schedule.

  • Dependabot for the cargo and github-actions ecosystems, grouped into one
    pull request per week.

  • A CI job that compiles the workspace on Windows. Supervision is still
    Unix-only, but the cfg(not(unix)) stubs are now built by something other
    than hope.

  • Community files a public repository is expected to have: SECURITY.md,
    CODE_OF_CONDUCT.md, issue forms for bugs and features, and a pull request
    template.

  • A CI job checks the workspace on the declared MSRV with --locked, so the
    minimum supported version is verified on every push instead of being an
    unchecked claim in the manifest.

Changed

  • servicrab list --json reports each dependency as an object rather than a
    bare name, so the condition being waited for is machine-readable:
    "depends_on": [{"service": "db", "condition": "service_healthy"}]. The
    condition is the effective one, resolved for entries that omit it. The human
    output names it too.
  • The daemon socket is now created with mode 0600. It was left to the process
    umask, which is 022 on most systems but 002 on distributions that give
    each user a private group — and connecting to the socket is enough to start
    and stop every service in the project.
  • CI runs clippy and the test suite with --locked. Without it a transitive
    dependency could be silently upgraded, and CI would stop testing what
    Cargo.lock ships.
  • The declared minimum supported Rust version is now 1.85, up from the
    previously documented 1.75. The old number was never true: the dependency
    tree in Cargo.lock contains crates that require edition 2024, so a 1.75
    toolchain failed before compiling a single line. Nothing in servicrab itself
    changed — only the promise now matches reality.

Fixed

  • A flaky up test. It asserted the start order by having the dependent sleep
    300ms and then look for a marker file written by the dependency, which
    measures how the two shells happen to be scheduled rather than what the
    supervisor did; on a loaded machine it failed while the supervisor had behaved
    correctly. It now reads the start order out of the event stream.