///|
/// Loading a Compose project: find the files, read the environment, merge and
/// interpolate the documents, then expand services into the containers that
/// will actually be handed to `podman`.
///|
/// The default compose file names, in the order Docker Compose probes them.
pub let default_compose_files : Array[String] = [
"compose.yaml", "compose.yml", "compose.override.yaml", "compose.override.yml",
"podman-compose.yaml", "podman-compose.yml", "docker-compose.yml", "docker-compose.yaml",
"docker-compose.override.yml", "docker-compose.override.yaml",
]
///|
/// A loaded project, ready for a command to run against.
pub struct Compose {
mut project_name : String
mut dirname : String
mut files : Array[String]
mut environ : Map[String, String]
mut services : Map[String, @yaml.Yaml]
mut deps : Map[String, Array[String]]
mut container_names_by_service : Map[String, Array[String]]
mut containers : Array[@yaml.Yaml]
mut container_by_name : Map[String, @yaml.Yaml]
mut networks : Map[String, @yaml.Yaml]
mut vols : Map[String, @yaml.Yaml]
mut declared_secrets : Map[String, @yaml.Yaml]
mut default_net : String?
mut pods : Array[String]
mut merged_yaml : @yaml.Yaml
mut x_podman : Map[String, @yaml.Yaml]
podman_version : String?
mut pod_args : Array[String]
// `.env` files are read once per path, not once per service that references
// them.
env_file_cache : Map[String, Map[String, String]]
}
///|
pub fn new_compose() -> Compose {
{
project_name: "",
dirname: "",
files: [],
environ: Map([]),
services: Map([]),
deps: Map([]),
container_names_by_service: Map([]),
containers: [],
container_by_name: Map([]),
networks: Map([]),
vols: Map([]),
declared_secrets: Map([]),
default_net: Some("default"),
pods: [],
merged_yaml: y_map_v(Map([])),
x_podman: Map([]),
podman_version: None,
pod_args: [],
env_file_cache: Map([]),
}
}
///|
/// The project label prefix, matching what the reference implementation writes
/// so `podman ps --filter label=...` finds containers either tool created.
pub fn compose_labels(compose : Compose) -> Array[String] {
[
"io.podman.compose.project=" + compose.project_name,
"io.podman.compose.version=" + version,
"PODMAN_SYSTEMD_UNIT=podman-compose@\{compose.project_name}.service",
"com.docker.compose.project=" + compose.project_name,
"com.docker.compose.project.working_dir=" + compose.dirname,
"com.docker.compose.project.config_files=" + compose.files.join(","),
]
}
///|
/// `true` when the compatibility setting is enabled.
fn x_flag(compose : Compose, key : String) -> Bool {
match compose.x_podman.get(key) {
Some(v) => y_bool(v).unwrap_or(false)
None => false
}
}
///|
/// The separator used between the project name and a service name.
fn name_separator(compose : Compose) -> String {
if x_flag(compose, "name_separator_compat") {
"-"
} else {
"_"
}
}
///|
/// Join name parts with the configured separator.
pub fn join_name_parts(compose : Compose, parts : Array[String]) -> String {
parts.join(name_separator(compose))
}
///|
/// `_`.
pub fn format_name(compose : Compose, parts : Array[String]) -> String {
let all : Array[String] = [compose.project_name]
all.append(parts)
join_name_parts(compose, all)
}
///|
/// A stable, short hash used for the `config-hash` label. Not cryptographic —
/// it only has to change when the service configuration changes.
pub fn simple_hash(s : String) -> String {
let mut h : UInt64 = 14695981039346656037UL
for c in s {
h = h ^ c.to_int().to_uint64()
h = h * 1099511628211UL
}
let digits = "0123456789abcdef".to_array()
let out = StringBuilder()
let mut shift = 60
while shift >= 0 {
let nibble = ((h >> shift) & 15UL).to_int()
out.write_char(digits[nibble])
shift -= 4
}
out.to_string()
}
///|
/// The `config_hash` of a service, used to decide whether a container is stale.
pub fn config_hash(compose : Compose, service : @yaml.Yaml) -> String {
ignore(compose)
simple_hash(y_dump(service))
}
///|
/// Build the environment used for interpolation: `.env` first, then the real
/// process environment on top (so the shell wins), then the `COMPOSE_*`
/// bookkeeping variables.
fn build_environ(dotenv : Map[String, String]) -> Map[String, String] {
let environ : Map[String, String] = Map([])
for entry in dotenv {
environ[entry.0] = entry.1
}
for entry in @env.get_env_vars() {
environ[entry.0] = entry.1
}
environ
}
///|
/// Read and parse an `env_file`, caching by path so a file shared by many
/// services is read and parsed once.
pub fn compose_env_file(
compose : Compose,
path : String,
) -> Map[String, String] {
match compose.env_file_cache.get(path) {
Some(cached) => cached
None => {
let parsed = dotenv_to_dict(path)
compose.env_file_cache[path] = parsed
parsed
}
}
}
///|
/// Normalize a project name to the character set Podman accepts.
fn normalize_project_name(name : String) -> String {
let out = StringBuilder(size_hint=name.length())
for c in name {
let keep = (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '_' ||
c == '.'
if keep {
out.write_char(c)
}
}
out.to_string()
}
///|
/// Split a `COMPOSE_FILE`-style list on the platform separator (`:` here).
fn split_path_list(value : String) -> Array[String] {
let out : Array[String] = []
for part in value.split(":") {
let s = part.to_owned()
if s.length() > 0 {
out.push(s)
}
}
out
}
///|
/// Load `.env` files, later files overriding earlier ones.
fn load_env_files(
paths : Array[String],
base : String,
) -> Map[String, String] raise Error {
let out : Map[String, String] = Map([])
for path in paths {
let full = path_abspath(base, path)
if !@fs.path_exists(full) {
raise fail("couldn't find env file: \{full}")
}
for entry in dotenv_to_dict(full) {
out[entry.0] = entry.1
}
}
out
}
///|
/// Find the compose files for this invocation.
fn resolve_files(
requested : Array[String],
early_dotenv : Map[String, String],
) -> Array[String] raise Error {
if requested.length() > 0 {
return requested
}
match @env.get_env_var("COMPOSE_FILE") {
Some(value) if value.length() > 0 => return split_path_list(value)
_ => ()
}
match early_dotenv.get("COMPOSE_FILE") {
Some(value) if value.length() > 0 => return split_path_list(value)
_ => ()
}
// Probe the working directory, then each parent, for the default names.
let mut dir = current_dir()
let mut depth = 0
while depth < 10 {
let found : Array[String] = []
for name in default_compose_files {
let candidate = path_join(dir, name)
if @fs.path_exists(candidate) {
found.push(candidate)
}
}
if found.length() > 0 {
return found
}
let parent = path_dirname(dir)
if parent == dir {
break
}
dir = parent
depth += 1
}
raise fail(
"no compose.yaml, compose.yml or docker-compose.yml found; pass one with -f",
)
}
///|
/// Read a compose document from a path, or from standard input for `-`.
async fn read_document(path : String) -> String raise Error {
if path == "-" {
let data = @stdio.stdin.read_all() catch {
e => raise fail("cannot read standard input: \{error_text(e)}")
}
return data.text() catch {
e => raise fail("standard input is not valid text: \{error_text(e)}")
}
}
@fs.read_file_to_string(path) catch {
e => raise fail("cannot read \{path}: \{error_text(e)}")
}
}
///|
/// Pre-merge normalization of one service: string shorthands become lists and
/// mappings, so later merges compare like with like.
fn normalize_service(service : Map[String, @yaml.Yaml]) -> Unit {
match service.get("build") {
Some(build) =>
match build {
@yaml.Yaml::String(_) => service["build"] = y_obj([("context", build)])
@yaml.Yaml::Map(bm) =>
match bm.get("args") {
Some(args) =>
match y_map(args) {
Some(_) => bm["args"] = y_arr_v(y_list(args))
None => ()
}
None => ()
}
_ => ()
}
None => ()
}
for key in ["env_file", "security_opt", "volumes"] {
match service.get(key) {
Some(value) =>
match value {
@yaml.Yaml::String(_) => service[key] = y_arr_v([value])
_ => ()
}
None => ()
}
}
for key in ["environment", "labels"] {
match service.get(key) {
Some(value) =>
match value {
@yaml.Yaml::Map(_) => ()
_ => service[key] = y_map_v(y_dict(value))
}
None => ()
}
}
match service.get("depends_on") {
Some(value) => service["depends_on"] = normalize_depends(value)
None => ()
}
match service.get("extends") {
Some(value) =>
match value {
@yaml.Yaml::String(s) =>
service["extends"] = y_obj([("service", y_str_v(s))])
_ => ()
}
None => ()
}
match service.get("secrets") {
Some(value) =>
match value {
@yaml.Yaml::String(_) => service["secrets"] = y_arr_v([value])
_ => ()
}
None => ()
}
}
///|
/// Normalize every service in a document.
fn normalize_document(doc : Map[String, @yaml.Yaml]) -> Unit {
match doc.get("services") {
Some(services) =>
match y_map(services) {
Some(service_map) =>
for entry in service_map {
let (_name, service) = entry
match y_map(service) {
Some(sm) => normalize_service(sm)
None => ()
}
}
None => ()
}
None => ()
}
}
///|
/// Resolve `build.context` to an absolute path, once the project directory is
/// known.
fn normalize_final(doc : Map[String, @yaml.Yaml], dirname : String) -> Unit {
let service_map = match doc.get("services").bind(y_map) {
Some(m) => m
None => return
}
for entry in service_map {
let sm = match y_map(entry.1) {
Some(m) => m
None => continue
}
let bm = match sm.get("build").bind(y_map) {
Some(m) => m
None => continue
}
let context = match bm.get("context") {
Some(c) => y_str_or(c, ".")
None => "."
}
if !is_git_url(context) {
bm["context"] = y_str_v(path_abspath(dirname, context))
}
}
}
///|
/// Whether a build context is a remote git URL rather than a local directory.
pub fn is_git_url(path : String) -> Bool {
for
scheme in ["git://", "http://", "https://", "ssh://", "file://", "rsync://"] {
if path.has_prefix(scheme) {
return true
}
}
false
}
///|
/// Filter services by profile, keeping the ones with no profile or one the
/// caller asked for.
fn resolve_profiles(
services : Map[String, @yaml.Yaml],
requested : Array[String],
) -> Map[String, @yaml.Yaml] {
let wanted : Map[String, Unit] = Map([])
for p in requested {
wanted[p] = ()
}
let out : Map[String, @yaml.Yaml] = Map([])
for entry in services {
let (name, service) = entry
let profiles = match y_get(service, "profiles") {
Some(p) => y_list_str(p)
None => []
}
if profiles.length() == 0 {
out[name] = service
} else {
for p in profiles {
if wanted.contains(p) {
out[name] = service
break
}
}
}
}
out
}
///|
/// Transitive dependencies of every service, following `depends_on`.
fn compute_deps(
services : Map[String, @yaml.Yaml],
) -> Map[String, Array[String]] {
let direct : Map[String, Array[String]] = Map([])
for entry in services {
let (name, service) = entry
let deps : Array[String] = []
match y_get(service, "depends_on") {
Some(dep) =>
match y_map(dep) {
Some(dm) =>
for d in dm {
deps.push(d.0)
}
None => ()
}
None => ()
}
direct[name] = deps
}
let result : Map[String, Array[String]] = Map([])
for entry in services {
let (name, _) = entry
result[name] = transitive_deps(name, direct, Map([]))
}
result
}
///|
/// Depth-first transitive closure with a visited set, so a cycle terminates.
fn transitive_deps(
name : String,
direct : Map[String, Array[String]],
seen : Map[String, Unit],
) -> Array[String] {
let out : Array[String] = []
let stack : Array[String] = [name]
while stack.length() > 0 {
let n = stack.pop().unwrap()
for dep in direct.get(n).unwrap_or([]) {
if !seen.contains(dep) {
seen[dep] = ()
out.push(dep)
stack.push(dep)
}
}
}
out
}
///|
/// Parse the `x-podman` extension block and the `PODMAN_COMPOSE_*` environment
/// variables, with the environment taking precedence.
fn parse_x_podman(
doc : Map[String, @yaml.Yaml],
environ : Map[String, String],
) -> Map[String, @yaml.Yaml] {
let out : Map[String, @yaml.Yaml] = Map([])
match doc.get("x-podman") {
Some(x) =>
match y_map(x) {
Some(xm) =>
for entry in xm {
out[entry.0] = entry.1
}
None => ()
}
None => ()
}
for entry in environ {
let (k, v) = entry
if k.has_prefix("PODMAN_COMPOSE_") {
let key = ascii_lower(k[15:].to_owned())
if key != "provider" && key != "warning_logs" {
out[key] = y_str_v(v)
}
}
}
out
}
///|
/// The pod name, honouring `--in-pod`, `x-podman.in_pod`, and the default of
/// one pod per project.
pub fn resolve_pod_name(compose : Compose, cli_in_pod : String?) -> String? {
let value : String? = match cli_in_pod {
Some(v) => Some(v)
None =>
match compose.x_podman.get("in_pod") {
Some(v) => y_str(v)
None => Some("true")
}
}
match value {
Some(v) =>
match y_bool(y_str_v(v)) {
Some(true) => Some("pod_" + compose.project_name)
Some(false) => None
None => Some(v)
}
None => Some("pod_" + compose.project_name)
}
}
///|
/// Extra arguments passed to `podman pod create`.
pub fn resolve_pod_args(
compose : Compose,
cli_pod_args : String?,
) -> Array[String] {
match cli_pod_args {
Some(args) =>
if args.length() > 0 {
shell_split(args)
} else {
default_pod_args(compose)
}
None => default_pod_args(compose)
}
}
///|
/// The `pod_args` from `x-podman`, or the podman-compose defaults.
fn default_pod_args(compose : Compose) -> Array[String] {
match compose.x_podman.get("pod_args") {
Some(v) =>
match v {
@yaml.Yaml::Array(_) => y_list_str(v)
_ => shell_split(y_str_or(v, ""))
}
None => ["--infra=false", "--share="]
}
}
///|
/// Split a string the way a shell would, honouring single and double quotes.
pub fn shell_split(command : String) -> Array[String] {
let parts : Array[String] = []
let current = StringBuilder()
let chars = command.to_array()
let mut i = 0
let mut in_quote = false
let mut quote_char : Char = '"'
let mut has_content = false
while i < chars.length() {
let c = chars[i]
if in_quote {
if c == quote_char {
in_quote = false
} else {
current.write_char(c)
}
has_content = true
} else if c == '"' || c == '\'' {
in_quote = true
quote_char = c
has_content = true
} else if c == ' ' || c == '\t' || c == '\n' {
if has_content {
parts.push(current.to_string())
current.reset()
has_content = false
}
} else {
current.write_char(c)
has_content = true
}
i += 1
}
if has_content {
parts.push(current.to_string())
}
parts
}
///|
/// Load a project. This reads files (and possibly standard input), so it is
/// async; everything else about the model is synchronous.
pub async fn load_compose(g : GlobalOptions) -> Compose raise Error {
let compose = new_compose()
let cwd = current_dir()
// `.env` is consulted before the file list, so `COMPOSE_FILE` can live there.
let early_dotenv = if g.env_files.length() == 0 {
dotenv_to_dict(path_join(cwd, ".env"))
} else {
load_env_files(g.env_files, cwd)
}
let files = resolve_files(g.files, early_dotenv)
let first = files[0]
let dirname = if first == "-" {
cwd
} else {
path_dirname(path_abspath(cwd, first))
}
compose.dirname = dirname
compose.files = files
let dotenv = if g.env_files.length() == 0 {
dotenv_to_dict(path_join(dirname, ".env"))
} else {
load_env_files(g.env_files, cwd)
}
let environ = build_environ(dotenv)
// Parse every document up front so the project name can be read from the
// first one before interpolation runs.
let documents : Array[(String, @yaml.Yaml)] = []
for file in files {
if file != "-" && !@fs.path_exists(file) && !@fs.path_exists(abspath(file)) {
raise fail("missing file: \{file}")
}
let text = read_document(file)
let docs = @yaml.Yaml::load_from_string(text) catch {
e => raise fail("\{file}: \{error_text(e)}")
}
if docs.length() == 0 {
continue
}
documents.push((file, apply_merge_keys(docs[0])))
}
if documents.length() == 0 {
raise fail("no compose documents found in \{files.join(", ")}")
}
// Project name: -p, then COMPOSE_PROJECT_NAME, then the file's `name`, then
// the directory.
let dir_basename = ascii_lower(path_basename(dirname))
let raw_name : String = match g.project_name {
Some(name) if name.length() > 0 => name
_ =>
match environ.get("COMPOSE_PROJECT_NAME") {
Some(name) if name.length() > 0 => name
_ =>
match y_get(documents[0].1, "name") {
Some(n) =>
match y_str(n) {
Some(s) => s
None => dir_basename
}
None => dir_basename
}
}
}
let project_name = normalize_project_name(
interp_str(raw_name, environ) catch {
_ => raw_name
},
)
if project_name.length() == 0 {
raise fail("project name \"\{raw_name}\" normalizes to empty")
}
compose.project_name = project_name
environ["COMPOSE_PROJECT_NAME"] = project_name
environ["COMPOSE_PROJECT_DIR"] = dirname
environ["COMPOSE_FILE"] = files.join(":")
compose.environ = environ
// Merge the documents.
let merged : Map[String, @yaml.Yaml] = Map([])
for doc in documents {
let (_file, content) = doc
match y_map(content) {
Some(m) => {
normalize_document(m)
let substituted = rec_subs(y_map_v(m), environ)
match y_map(substituted) {
Some(sm) => rec_merge(merged, sm)
None => ()
}
}
None =>
raise fail("compose file does not contain a top-level object: \{doc.0}")
}
}
merged.remove("version")
// Profiles and build-context normalization.
let services = match y_map(merged.get("services").unwrap_or(y_null())) {
Some(s) => s
None => Map([])
}
let services = resolve_profiles(services, g.profiles)
merged["services"] = y_map_v(services)
normalize_final(merged, dirname)
compose.services = services
compose.merged_yaml = y_map_v(merged)
compose.x_podman = parse_x_podman(merged, environ)
compose.pod_args = resolve_pod_args(compose, g.pod_args)
// Networks.
let nets = match y_map(merged.get("networks").unwrap_or(y_null())) {
Some(n) => n
None => Map([])
}
let docker_compat = x_flag(compose, "docker_compose_compat")
if docker_compat {
if !nets.contains("default") {
nets["default"] = y_null()
}
compose.default_net = Some("default")
} else if nets.length() == 0 {
nets["default"] = y_null()
compose.default_net = Some("default")
} else if nets.length() == 1 {
for entry in nets {
compose.default_net = Some(entry.0)
}
} else if nets.contains("default") {
compose.default_net = Some("default")
} else {
compose.default_net = None
}
compose.networks = nets
compose.vols = match y_map(merged.get("volumes").unwrap_or(y_null())) {
Some(v) => v
None => Map([])
}
compose.declared_secrets = match
y_map(merged.get("secrets").unwrap_or(y_null())) {
Some(s) => s
None => Map([])
}
// Containers.
let pod_name = resolve_pod_name(compose, g.in_pod)
compose.pods = match pod_name {
Some(name) => [name]
None => []
}
let deps = compute_deps(services)
compose.deps = deps
let names_by_service : Map[String, Array[String]] = Map([])
let containers : Array[@yaml.Yaml] = []
let base_labels = compose_labels(compose)
for entry in services {
let (service_name, service) = entry
let replicas = service_replicas(service)
// The config hash depends only on the service, not on the replica number,
// so compute it once instead of serializing the service per replica.
let service_hash = config_hash(compose, service)
let names : Array[String] = []
for num in 1..<=replicas {
let name0 = format_name(compose, [service_name, num.to_string()])
let name = if num == 1 {
match y_get(service, "container_name") {
Some(cn) =>
match y_str(cn) {
Some(s) if s.length() > 0 => s
_ => name0
}
None => name0
}
} else {
name0
}
names.push(name)
containers.push(
make_container(
compose, service_name, service, name, num, pod_name, deps, base_labels,
service_hash,
),
)
}
names_by_service[service_name] = names
}
compose.container_names_by_service = names_by_service
// Create dependencies before the services that need them.
containers.sort_by(fn(a, b) {
let da = deps
.get(y_str_or(y_get(a, "_service").unwrap_or(y_null()), ""))
.unwrap_or([])
.length()
let db = deps
.get(y_str_or(y_get(b, "_service").unwrap_or(y_null()), ""))
.unwrap_or([])
.length()
da - db
})
compose.containers = containers
let by_name : Map[String, @yaml.Yaml] = Map([])
for c in containers {
match y_get(c, "name") {
Some(n) =>
match y_str(n) {
Some(s) => by_name[s] = c
None => ()
}
None => ()
}
}
compose.container_by_name = by_name
compose
}
///|
/// How many containers a service wants. `scale` wins over `deploy.replicas`.
fn service_replicas(service : @yaml.Yaml) -> Int32 {
match y_get(service, "scale") {
Some(s) =>
match y_int(s) {
Some(n) if n > 0 => return n
_ => ()
}
None => ()
}
match y_get(service, "deploy") {
Some(deploy) =>
match y_get(deploy, "replicas") {
Some(r) =>
match y_int(r) {
Some(n) if n > 0 => n
_ => 1
}
None => 1
}
None => 1
}
}
///|
/// Expand one service into one container description: the service's own fields
/// plus the name, pod and labels the commands need.
fn make_container(
compose : Compose,
service_name : String,
service : @yaml.Yaml,
name : String,
num : Int32,
pod_name : String?,
deps : Map[String, Array[String]],
base_labels : Array[String],
config_hash_value : String,
) -> @yaml.Yaml {
let out : Map[String, @yaml.Yaml] = Map([])
match y_map(service) {
Some(sm) =>
for entry in sm {
out[entry.0] = y_clone(entry.1)
}
None => ()
}
match pod_name {
Some(pod) => out["pod"] = y_str_v(pod)
None => ()
}
out["name"] = y_str_v(name)
out["num"] = y_int_v(num)
out["service_name"] = y_str_v(service_name)
out["_service"] = y_str_v(service_name)
out["_project"] = y_str_v(compose.project_name)
if !out.contains("image") {
out["image"] = y_str_v(format_name(compose, [service_name]))
}
let labels = y_list(out.get("labels").unwrap_or(y_null()))
for label in base_labels {
labels.push(y_str_v(label))
}
labels.push(y_str_v("io.podman.compose.config-hash=" + config_hash_value))
labels.push(y_str_v("com.docker.compose.container-number=" + num.to_string()))
labels.push(y_str_v("io.podman.compose.service=" + service_name))
labels.push(y_str_v("com.docker.compose.service=" + service_name))
out["labels"] = y_arr_v(labels)
let dep_names = deps.get(service_name).unwrap_or([])
let dep_values : Array[@yaml.Yaml] = []
for d in dep_names {
dep_values.push(y_str_v(d))
}
out["_deps"] = y_arr_v(dep_values)
y_map_v(out)
}