///|
/// The subcommands. Each one is a thin translation from the loaded project to
/// a sequence of `podman` invocations.
///|
/// Services to skip: everything not named, unless it is a dependency (for `up`)
/// or a dependent (for `down`) of a named one.
fn get_excluded(
compose : Compose,
services : Array[String],
with_deps : Bool,
with_dependents : Bool,
) -> Map[String, Unit] {
let excluded : Map[String, Unit] = Map([])
if services.length() == 0 {
return excluded
}
for entry in compose.services {
excluded[entry.0] = ()
}
for s in services {
if compose.services.contains(s) {
if with_deps {
for d in service_deps(compose, s) {
excluded.remove(d)
}
}
if with_dependents {
for d in service_dependents(compose, s) {
excluded.remove(d)
}
}
}
excluded.remove(s)
}
excluded
}
///|
/// The transitive dependencies recorded for a service.
fn service_deps(compose : Compose, name : String) -> Array[String] {
compose.deps.get(name).unwrap_or([])
}
///|
/// Services that (transitively) depend on `name`.
fn service_dependents(compose : Compose, name : String) -> Array[String] {
let out : Array[String] = []
for entry in compose.services {
if service_deps(compose, entry.0).contains(name) {
out.push(entry.0)
}
}
out
}
///|
/// Fail when a named service does not exist.
fn assert_services(
compose : Compose,
services : Array[String],
) -> Unit raise Error {
for s in services {
if !compose.services.contains(s) {
raise fail("no such service: \{s}")
}
}
}
///|
/// The container names for a set of services, in service order.
fn container_names_for(
compose : Compose,
services : Array[String],
) -> Array[String] {
let out : Array[String] = []
for s in services {
match compose.container_names_by_service.get(s) {
Some(names) =>
for n in names {
out.push(n)
}
None => ()
}
}
out
}
///|
/// Reverse a copy of an array.
fn reversed(a : Array[String]) -> Array[String] {
let out : Array[String] = []
let mut i = a.length()
while i > 0 {
i -= 1
out.push(a[i])
}
out
}
///|
/// `podcomp version`
pub async fn cmd_version(podman : Podman, args : CommandArgs) -> Unit {
if args.short {
println(version)
return
}
if args.format is Some("json") {
println("{\"version\":\"" + version + "\"}")
return
}
println("podcomp version " + version)
match podman_version(podman) {
Some(v) => println("podman version " + v)
None => ()
}
}
///|
/// `podcomp config`
pub fn cmd_config(compose : Compose, args : CommandArgs) -> Unit {
if args.services_flag {
if !args.quiet {
for entry in compose.services {
println(entry.0)
}
}
return
}
if !args.quiet {
println(y_dump(compose.merged_yaml))
}
}
///|
/// `podman network ls` filtered to this project.
async fn network_ls(compose : Compose, podman : Podman) -> Array[String] {
let out = podman_output(podman, "network", [
"ls",
"--noheading",
"--filter",
"label=io.podman.compose.project=" + compose.project_name,
"--format",
"{{.Name}}",
]) catch {
_ => return []
}
lines(out)
}
///|
/// `podman volume ls` filtered to this project.
async fn volume_ls(compose : Compose, podman : Podman) -> Array[String] {
let out = podman_output(podman, "volume", [
"ls",
"--noheading",
"--filter",
"label=io.podman.compose.project=" + compose.project_name,
"--format",
"{{.Name}}",
]) catch {
_ => return []
}
lines(out)
}
///|
/// Non-empty lines of some command output.
fn lines(text : String) -> Array[String] {
let out : Array[String] = []
for line in text.split("\n") {
let s = line.trim().to_owned()
if s.length() > 0 {
out.push(s)
}
}
out
}
///|
/// `podman pod exists`, then create the pod if missing.
async fn create_pods(compose : Compose, podman : Podman) -> Unit {
for pod in compose.pods {
match podman_try_output(podman, "pod", ["exists", pod]) {
Some(_) => continue
None => ()
}
let args : Array[String] = ["create", "--name=" + pod]
args.append(compose.pod_args)
ignore(podman_run(podman, "pod", args))
}
}
///|
/// The containers of the named services, or every container when no service
/// is named.
fn containers_for(
compose : Compose,
services : Array[String],
) -> Array[@yaml.Yaml] {
if services.length() == 0 {
return compose.containers
}
let out : Array[@yaml.Yaml] = []
for cnt in compose.containers {
let service = y_str_or(y_get(cnt, "_service").unwrap_or(y_null()), "")
if services.contains(service) {
out.push(cnt)
}
}
out
}
///|
/// The distinct images used by a set of containers, in first-seen order.
fn unique_images(containers : Array[@yaml.Yaml]) -> Array[String] {
let seen : Map[String, Unit] = Map([])
let out : Array[String] = []
for cnt in containers {
match y_get(cnt, "image").bind(y_str) {
Some(img) =>
if img.length() > 0 && !seen.contains(img) {
seen[img] = ()
out.push(img)
}
None => ()
}
}
out
}
///|
/// Pull the images a set of containers uses.
async fn pull_images(
compose : Compose,
podman : Podman,
containers : Array[@yaml.Yaml],
) -> Int32 {
ignore(compose)
let mut status = 0
for img in unique_images(containers) {
let code = podman_run(podman, "pull", [img])
if code != 0 {
status = code
}
}
status
}
///|
/// The `podman build` argument list for a container.
fn build_args_for(
compose : Compose,
cnt : @yaml.Yaml,
args : CommandArgs,
) -> Array[String] {
let out : Array[String] = []
let build = y_get(cnt, "build").unwrap_or(y_null())
let context = match y_get(build, "context") {
Some(c) => y_str_or(c, ".")
None => "."
}
let image = y_str_or(y_get(cnt, "image").unwrap_or(y_null()), "")
let dockerfile : String? = match y_get(build, "dockerfile") {
Some(d) => y_str(d)
None => None
}
match dockerfile {
Some(name) => {
out.push("-f")
out.push(path_join(context, name))
}
None =>
for alt in ["Containerfile", "Dockerfile", "dockerfile"] {
let candidate = path_join(context, alt)
if @fs.path_exists(candidate) {
out.push("-f")
out.push(candidate)
break
}
}
}
out.push("-t")
out.push(image)
match y_get(cnt, "platform") {
Some(p) =>
match y_str(p) {
Some(s) => {
out.push("--platform")
out.push(s)
}
None => ()
}
None => ()
}
match y_get(build, "target") {
Some(t) =>
match y_str(t) {
Some(s) => {
out.push("--target")
out.push(s)
}
None => ()
}
None => ()
}
for build_arg in y_list_str(y_get(build, "args").unwrap_or(y_null())) {
out.push("--build-arg")
out.push(build_arg)
}
for build_arg in args.build_arg {
out.push("--build-arg")
out.push(build_arg)
}
match y_get(build, "labels") {
Some(labels) =>
for label in y_env_list(labels) {
out.push("--label")
out.push(label)
}
None => ()
}
for cache in y_list_str(y_get(build, "cache_from").unwrap_or(y_null())) {
out.push("--cache-from")
out.push(cache)
}
for cache in y_list_str(y_get(build, "cache_to").unwrap_or(y_null())) {
out.push("--cache-to")
out.push(cache)
}
if args.no_cache {
out.push("--no-cache")
}
match args.pull {
Some(policy) => out.push("--pull=" + policy)
None => ()
}
out.push(context)
ignore(compose)
out
}
///|
/// `podcomp build`
pub async fn cmd_build(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
assert_services(compose, args.services)
do_build(compose, podman, args, args.services, false)
}
///|
/// Build the images of the named services (or all of them). `if_not_exists`
/// skips a service whose image is already present, which is what `up` wants
/// unless it was passed `--build`.
async fn do_build(
compose : Compose,
podman : Podman,
args : CommandArgs,
services : Array[String],
if_not_exists : Bool,
) -> Int32 raise Error {
let wanted : Map[String, Unit] = Map([])
for s in services {
wanted[s] = ()
}
let mut status = 0
for cnt in compose.containers {
let service = y_str_or(y_get(cnt, "_service").unwrap_or(y_null()), "")
if wanted.length() > 0 && !wanted.contains(service) {
continue
}
match y_get(cnt, "build") {
Some(build) =>
match build {
@yaml.Yaml::Null | @yaml.Yaml::BadValue => ()
_ => {
let image = y_str_or(y_get(cnt, "image").unwrap_or(y_null()), "")
let mut skip = false
if if_not_exists && image.length() > 0 {
match
podman_try_output(podman, "inspect", [
"-t", "image", "-f", "{{.Id}}", image,
]) {
Some(_) => skip = true
None => ()
}
}
if !skip {
let cargs = build_args_for(compose, cnt, args)
let code = podman_run(podman, "build", cargs)
if code != 0 {
status = code
}
}
}
}
None => ()
}
}
status
}
///|
/// `podcomp up`
pub async fn cmd_up(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
assert_services(compose, args.services)
let excluded = get_excluded(compose, args.services, true, false)
if !args.no_build {
let build_services : Array[String] = []
for cnt in compose.containers {
let service = y_str_or(y_get(cnt, "_service").unwrap_or(y_null()), "")
if !excluded.contains(service) {
match y_get(cnt, "build") {
Some(build) =>
match build {
@yaml.Yaml::Null | @yaml.Yaml::BadValue => ()
_ =>
if !build_services.contains(service) {
build_services.push(service)
}
}
None => ()
}
}
}
if build_services.length() > 0 {
ignore(do_build(compose, podman, args, build_services, !args.build))
}
}
// Pull before creating, but only on podman >= 5.6, which supports the pull
// policy flag. Older podman pulls implicitly inside `create`.
let can_prepull = match podman_version(podman) {
Some(v) => version_at_least(v, "5.6.0")
None => false
}
if can_prepull {
let to_pull : Array[@yaml.Yaml] = []
for cnt in compose.containers {
let service = y_str_or(y_get(cnt, "_service").unwrap_or(y_null()), "")
if !excluded.contains(service) {
to_pull.push(cnt)
}
}
let pull_code = pull_images(compose, podman, to_pull)
if pull_code != 0 {
return pull_code
}
}
create_pods(compose, podman)
// Resolve the containers that take part once, then ensure the networks and
// named volumes they share exist with one probe per distinct name.
let targets : Array[@yaml.Yaml] = []
for cnt in compose.containers {
let service = y_str_or(y_get(cnt, "_service").unwrap_or(y_null()), "")
if !excluded.contains(service) {
targets.push(cnt)
}
}
assert_networks_and_volumes(compose, podman, targets)
let mut status = 0
for cnt in targets {
let cargs = container_to_args(compose, cnt, false, args.no_deps)
let code = podman_run(podman, "create", cargs)
if code != 0 {
status = code
}
}
if args.no_start {
return status
}
if args.detach || args.wait {
for cnt in targets {
let name = y_str_or(y_get(cnt, "name").unwrap_or(y_null()), "")
let code = podman_run(podman, "start", [name])
if code != 0 {
status = code
}
}
if args.wait {
wait_for_running(compose, podman)
}
return status
}
// Attached: start every container with `-a` concurrently.
let codes : Array[Int32] = []
let names : Array[String] = []
for cnt in targets {
names.push(y_str_or(y_get(cnt, "name").unwrap_or(y_null()), ""))
}
@async.with_task_group(group => {
for name in names {
group.spawn_bg(no_wait=true, () => {
let code = podman_run(podman, "start", ["-a", name])
codes.push(code)
})
}
})
for code in codes {
if code != 0 {
status = code
}
}
status
}
///|
/// `podman wait --condition=running` for every container that has no
/// healthcheck, then `healthy` for the rest.
async fn wait_for_running(compose : Compose, podman : Podman) -> Unit {
let plain : Array[String] = []
let healthy : Array[String] = []
for cnt in compose.containers {
let name = y_str_or(y_get(cnt, "name").unwrap_or(y_null()), "")
match y_get(cnt, "healthcheck") {
Some(_) => healthy.push(name)
None => plain.push(name)
}
}
if plain.length() > 0 {
let cargs : Array[String] = ["--condition=running", "--ignore"]
cargs.append(plain)
ignore(podman_run(podman, "wait", cargs))
}
if healthy.length() > 0 {
let cargs : Array[String] = ["--condition=healthy", "--ignore"]
cargs.append(healthy)
ignore(podman_run(podman, "wait", cargs))
}
}
///|
/// `podcomp down`
pub async fn cmd_down(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 {
let excluded = get_excluded(compose, args.services, false, true)
let mut status = 0
let mut names : Array[String] = []
for cnt in compose.containers {
let service = y_str_or(y_get(cnt, "_service").unwrap_or(y_null()), "")
if !excluded.contains(service) {
names.push(y_str_or(y_get(cnt, "name").unwrap_or(y_null()), ""))
}
}
names = reversed(names)
for name in names {
let cargs : Array[String] = []
match args.timeout {
Some(t) => {
cargs.push("-t")
cargs.push(t.to_string())
}
None => ()
}
cargs.push(name)
let code = podman_run(podman, "stop", cargs)
if code != 0 {
status = code
}
}
for name in names {
ignore(podman_run(podman, "rm", [name]))
}
if args.volumes {
for volume in volume_ls(compose, podman) {
ignore(podman_run(podman, "volume", ["rm", volume]))
}
}
if excluded.is_empty() {
for pod in compose.pods {
ignore(podman_run(podman, "pod", ["rm", pod]))
}
for network in network_ls(compose, podman) {
ignore(podman_run(podman, "network", ["rm", network]))
}
}
status
}
///|
/// `podcomp ps`
pub async fn cmd_ps(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 {
let cargs : Array[String] = [
"-a",
"--filter",
"label=io.podman.compose.project=" + compose.project_name,
]
if args.quiet {
cargs.push("--format")
cargs.push("{{.ID}}")
} else {
match args.format {
Some(f) => {
cargs.push("--format")
cargs.push(f)
}
None => ()
}
}
podman_run(podman, "ps", cargs)
}
///|
/// `podcomp logs`
pub async fn cmd_logs(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
let services = if args.services.length() == 0 {
keys_of(compose.container_names_by_service)
} else {
args.services
}
assert_services(compose, services)
let cargs : Array[String] = []
if args.follow {
cargs.push("-f")
}
if args.names {
cargs.push("-n")
}
if !args.no_color {
cargs.push("--color")
}
match args.since {
Some(s) => {
cargs.push("--since")
cargs.push(s)
}
None => ()
}
match args.tail {
Some(t) =>
if t != "all" {
cargs.push("--tail")
cargs.push(t)
}
None => ()
}
if args.timestamps {
cargs.push("-t")
}
match args.until {
Some(u) => {
cargs.push("--until")
cargs.push(u)
}
None => ()
}
for name in container_names_for(compose, services) {
cargs.push(name)
}
podman_run(podman, "logs", cargs)
}
///|
fn keys_of(m : Map[String, Array[String]]) -> Array[String] {
let out : Array[String] = []
for entry in m {
out.push(entry.0)
}
out
}
///|
/// Start, stop or restart the named services (or all of them).
async fn transfer_service_status(
compose : Compose,
podman : Podman,
args : CommandArgs,
action : String,
) -> Int32 raise Error {
let services = if args.services.length() == 0 {
keys_of(compose.container_names_by_service)
} else {
args.services
}
assert_services(compose, services)
let mut targets = container_names_for(compose, services)
if action == "stop" || action == "restart" {
targets = reversed(targets)
}
let mut status = 0
for target in targets {
let cargs : Array[String] = []
if action != "start" {
match args.timeout {
Some(t) => {
cargs.push("-t")
cargs.push(t.to_string())
}
None => ()
}
}
cargs.push(target)
let code = podman_run(podman, action, cargs)
if code != 0 {
status = code
}
}
status
}
///|
/// `podcomp start`
pub async fn cmd_start(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
let status = transfer_service_status(compose, podman, args, "start")
if args.wait {
wait_for_running(compose, podman)
}
status
}
///|
/// `podcomp stop`
pub async fn cmd_stop(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
transfer_service_status(compose, podman, args, "stop")
}
///|
/// `podcomp restart`
pub async fn cmd_restart(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
transfer_service_status(compose, podman, args, "restart")
}
///|
/// `podcomp kill`
pub async fn cmd_kill(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
if args.services.length() == 0 && !args.all {
raise fail(
"you must provide at least one service name or use --all to kill all services",
)
}
let services = if args.all {
keys_of(compose.container_names_by_service)
} else {
args.services
}
assert_services(compose, services)
let cargs : Array[String] = []
match args.signal {
Some(s) => {
cargs.push("--signal")
cargs.push(s)
}
None => ()
}
for name in container_names_for(compose, services) {
cargs.push(name)
}
podman_run(podman, "kill", cargs)
}
///|
/// `podcomp pause` / `podcomp unpause`
pub async fn cmd_pause_unpause(
compose : Compose,
podman : Podman,
args : CommandArgs,
action : String,
) -> Int32 raise Error {
let services = if args.services.length() == 0 {
keys_of(compose.container_names_by_service)
} else {
args.services
}
assert_services(compose, services)
podman_run(podman, action, container_names_for(compose, services))
}
///|
/// `podcomp pull`
pub async fn cmd_pull(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
assert_services(compose, args.services)
pull_images(compose, podman, containers_for(compose, args.services))
}
///|
/// `podcomp rm`
pub async fn cmd_rm(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
let services = if args.services.length() == 0 {
keys_of(compose.container_names_by_service)
} else {
args.services
}
assert_services(compose, services)
let mut status = 0
for name in container_names_for(compose, services) {
let code = podman_run(podman, "rm", [name])
if code != 0 {
status = code
}
}
status
}
///|
/// `podcomp exec`
pub async fn cmd_exec(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
let service = match first_of(args.services) {
Some(s) => s
None => raise fail("exec requires a service name")
}
assert_services(compose, [service])
let names = compose.container_names_by_service.get(service).unwrap_or([])
if args.index < 1 || args.index > names.length() {
raise fail("no container for service \{service} at index \{args.index}")
}
let name = names[args.index - 1]
let cargs : Array[String] = if args.detach {
["--detach"]
} else {
["--interactive"]
}
if args.privileged {
cargs.push("--privileged")
}
match args.user {
Some(u) => {
cargs.push("--user")
cargs.push(u)
}
None => ()
}
match args.workdir {
Some(w) => {
cargs.push("--workdir")
cargs.push(w)
}
None => ()
}
if !args.detach && !args.no_tty {
cargs.push("--tty")
}
for e in args.env {
cargs.push("--env")
cargs.push(e)
}
cargs.push(name)
cargs.append(args.cnt_command)
podman_run(podman, "exec", cargs)
}
///|
/// `podcomp run`
pub async fn cmd_run(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
let service = match first_of(args.services) {
Some(s) => s
None => raise fail("run requires a service name")
}
assert_services(compose, [service])
create_pods(compose, podman)
let names = compose.container_names_by_service.get(service).unwrap_or([])
if names.length() == 0 {
raise fail("no container for service \{service}")
}
let base = compose.container_by_name.get(names[0]).unwrap_or(y_null())
// One-off containers get a fresh name and command.
let sm = y_map(y_clone(base)).unwrap()
let suffix = simple_hash(current_dir() + args.services.join(","))
sm["name"] = y_str_v(format_name(compose, [service, "tmp" + suffix]))
match args.entrypoint {
Some(e) => sm["entrypoint"] = y_str_v(e)
None => ()
}
match args.user {
Some(u) => sm["user"] = y_str_v(u)
None => ()
}
match args.workdir {
Some(w) => sm["working_dir"] = y_str_v(w)
None => ()
}
if args.cnt_command.length() > 0 {
let values : Array[@yaml.Yaml] = []
for part in args.cnt_command {
values.push(y_str_v(part))
}
sm["command"] = y_arr_v(values)
}
sm["tty"] = y_bool_v(!args.no_tty)
let cnt = y_map_v(sm)
let cargs = container_to_args(compose, cnt, args.detach, args.no_deps)
if !args.detach {
// Interactive by default.
let with_stdin : Array[String] = []
with_stdin.push("-i")
with_stdin.append(cargs)
cargs.clear()
cargs.append(with_stdin)
}
// NOTE: the reference does not create networks/volumes here either, so a
// `run` against a project that was never brought up will fail in podman.
podman_run(podman, "run", cargs)
}
///|
/// `podcomp images`
pub async fn cmd_images(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
assert_services(compose, args.services)
let mut status = 0
for img in unique_images(containers_for(compose, args.services)) {
let cargs : Array[String] = []
if args.quiet {
cargs.push("--quiet")
}
cargs.push(img)
let code = podman_run(podman, "images", cargs)
if code != 0 {
status = code
}
}
status
}
///|
/// `podcomp port`
pub async fn cmd_port(
compose : Compose,
podman : Podman,
args : CommandArgs,
) -> Int32 raise Error {
let service = match first_of(args.services) {
Some(s) => s
None => raise fail("port requires a service name")
}
assert_services(compose, [service])
let names = compose.container_names_by_service.get(service).unwrap_or([])
if args.index < 1 || args.index > names.length() {
raise fail("no container for service \{service} at index \{args.index}")
}
let name = names[args.index - 1]
let output = podman_output(podman, "inspect", [name])
let needle = args.private_port.to_string() + "/" + args.protocol
// The inspect output is JSON; the port mapping is found by locating the
// private port key and reading the HostPort that follows.
match find_host_port(output, needle) {
Some(port) => {
println(port)
0
}
None => raise fail("no published port \{needle} for \{service}")
}
}
///|
/// A very small JSON walk: find `"": [ ... "HostPort": "NNN" ... ]`.
fn find_host_port(text : String, needle : String) -> String? {
let key = "\"" + needle + "\""
match text.find(key) {
Some(index) => {
let rest = text[index:].to_owned()
match rest.find("HostPort") {
Some(hp) => {
let tail = rest[hp:].to_owned()
match tail.find("\"") {
Some(_) => {
let after = tail[9:].to_owned()
match after.find("\"") {
Some(open) => {
let value = after[open + 1:].to_owned()
match value.find("\"") {
Some(close) => Some(value[:close].to_owned())
None => None
}
}
None => None
}
}
None => None
}
}
None => None
}
}
None => None
}
}
///|
/// The first element of an array, if any.
fn first_of(a : Array[String]) -> String? {
if a.length() == 0 {
None
} else {
Some(a[0])
}
}