The config¶
One ordered list of steps, in lely.yml — or under [tool.lely] in pyproject.toml. lely
looks for either from the current directory upwards; -c names one.
steps:
- name: model # feeds the bundle: it is above it …
uses: ./ops/steps.py:LatestModel
with: {model: main.ml.churn, alias: candidate}
- name: app
uses: bundle
with:
path: .
vars:
model_version: ${steps.model.version} # … and the bundle says what it takes
- name: backfill # needs something from the bundle: it is below it …
uses: ./ops/steps.py:Backfill
with: {job: "${steps.app.resources.jobs.backfill.id}"} # … and says what
- name: warm
uses: ./ops/warm.py:Warm
targets: [prod] # only for this target; skipped, visibly, for any other
LatestModel and Backfill are classes in the repo (Your first plan
shows Backfill); Warm is a program as a step, and the whole of its file:
# ops/warm.py
from lely.step import Program
class Warm(Program):
"""Warms the cache after a production deploy."""
command = ["./ops/warm.sh"]
A step¶
| Key | |
|---|---|
uses |
The plugin that does the step: a built-in (bundle, bundle.run), a class in the repo (./path/file.py:Class), or one in an installed package. Required. |
name |
What other steps call it. A plain plugin name doubles as one (uses: bundle is named bundle); a class needs a name of its own. |
with |
The step's options: whatever its plugin declares. lely steps lists them. |
targets |
The targets the step runs for, compared with -t as written. Without it, every target. |
The order is the order they run. A destroy runs it from the bottom up.
What a step takes from another¶
One way only: ${steps.<name>.<output>}, in the step's own with:, and only from a step
above it. Reading a step's with: is enough to know everything it depends on.
lely validate checks every reference offline: the step exists, stands above, runs for the
same targets, and gives that output. It prints the wiring — what each step takes and gives,
and when each output is known:
- at plan — known as soon as the step is planned;
- once it exists — known after the first deploy that creates it, like a job's id;
- after every run — known only when the step has run, every time.
A step that takes something not known yet is waiting: the plan says so, and the step is planned when the run gets there.
A step can also name a whole step, where its plugin asks for one — bundle.run runs a
resource of a bundle step:
Values from the environment¶
${env.NAME} is the other reference. It is always a secret: shown as ***, never
written to a plan file, a comment or a page, and fetched again when the step is planned at
apply. It may go only into an option of type Secret — never into a program's arguments,
which every process on the machine can see — and lely validate says so offline.
A secret written out in the config is kept out of the plan file too: what the file
records of a step's options does not depend on its text. So lely does not see that such
a secret changed between plan and apply, nor that text around a reference did, as in
Bearer ${env.SEED_TOKEN}. It sees where a secret comes from: ${env.SEED_TOKEN}
changed to ${env.OTHER_TOKEN} takes a new plan.
A list or a mapping can be given whole by one reference, tags: ${steps.model.tags}.
Each element is then read as the option declares it, as if it had been written out.
The entries of a mapping count in the order they are written: a plugin is handed them in that order. Moving one takes a new plan.
# ops/seed.py
from dataclasses import dataclass
from lely.model import Secret
from lely.step import Step
class Seed(Step):
@dataclass(frozen=True, slots=True)
class Options:
#: Shown as `***`, written to no file; `token.reveal()` is its value.
token: Secret
... # plan and apply: see Writing a step
A literal ${…}¶
${ always starts a reference. To hand a step the characters themselves — a shell's own
variable in a command — double the dollar: $${…} is written out as ${…} and is no
reference.
- name: report
uses: ./ops/report.py:Report
with:
script: 'echo "$${HOME}" > where.txt' # the shell reads ${HOME}
# ops/report.py
from dataclasses import dataclass
from lely.step import Program
class Report(Program):
@dataclass(frozen=True, slots=True)
class Options:
script: str
def command(self, ctx): # built from the options, never through a shell of lely's
return ["sh", "-c", ctx.options.script]
A ${…} that is no reference is an error, and the error says how to write it either way: as
a literal, or — where a step above gives something of that name, as a bundle's ${var.x}
would be — as ${steps.<that step>.var.x}.
In pyproject.toml¶
The same list, as TOML:
[[tool.lely.steps]]
name = "model"
uses = "./ops/steps.py:LatestModel"
with = { model = "main.ml.churn" }
[[tool.lely.steps]]
name = "app"
uses = "bundle"
with = { vars = { model_version = "${steps.model.version}" } }
The same project written either way gives the same plan, and a plan made from one file
is accepted with the other. One difference to know: an option that wants text is handed
a number as it was written in a lely.yml, so version: 1.10 is the text 1.10. In
TOML a number is a number, and version = 1.10 is 1.1. Write version = "1.10".
What lely does not have¶
- No default target.
-tis always given, and each plugin reads it its own way — for the bundle it is the bundle's target. - No state. Remove a step from the config and what it deployed stays; destroy first, then remove the step.
- No variables of its own, no includes, no conditions beyond
targets:. What differs per target belongs in the bundle, or in a step's own options.