import cowl import fio import gleam/dict import gleam/dynamic/decode import gleam/int import gleam/json import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import testcontainer/formula import testcontainer_compose/error pub opaque type ComposeConfig { ComposeConfig( file_path: String, project_name: String, env_overrides: List(#(String, cowl.Secret(String))), ) } pub opaque type ComposeServices { ComposeServices( file_path: String, project_name: String, services: List(Service), ) } pub type Service { Service(name: String, image: String, ports: List(#(Int, Int))) } pub fn from_file(file_path: String) -> ComposeConfig { ComposeConfig( file_path: file_path, project_name: "testcontainer", env_overrides: [], ) } pub fn with_project_name( cfg: ComposeConfig, project_name: String, ) -> ComposeConfig { ComposeConfig(..cfg, project_name: project_name) } pub fn with_env_override( cfg: ComposeConfig, key: String, value: String, ) -> ComposeConfig { let secret = cowl.secret(value) let overrides = list.append(cfg.env_overrides, [#(key, secret)]) ComposeConfig(..cfg, env_overrides: overrides) } pub fn formula( cfg: ComposeConfig, ) -> Result( formula.StandaloneFormula(ComposeServices, error.Error), error.Error, ) { use services <- result.try(load_compose_file(cfg)) let stack = ComposeServices( file_path: cfg.file_path, project_name: cfg.project_name, services: services, ) let env_pairs = reveal_env_overrides(cfg.env_overrides) Ok( formula.new_standalone( fn() { compose_up(cfg.file_path, cfg.project_name, env_pairs) |> result.map(fn(_) { stack }) |> result.map_error(fn(reason) { error.ComposeFailed(path: cfg.file_path, reason: reason) }) }, fn() { compose_down(cfg.file_path, cfg.project_name) |> result.map(fn(_) { Nil }) |> result.map_error(fn(reason) { error.ComposeFailed(path: cfg.file_path, reason: reason) }) }, ), ) } /// Spin up a compose stack, run the body, and always tear it down. pub fn with_compose( cfg: ComposeConfig, body: fn(ComposeServices) -> Result(a, error.Error), ) -> Result(a, error.Error) { use services <- result.try(load_compose_file(cfg)) let env_pairs = reveal_env_overrides(cfg.env_overrides) use _ <- result.try( compose_up(cfg.file_path, cfg.project_name, env_pairs) |> result.map_error(fn(reason) { error.ComposeFailed(path: cfg.file_path, reason: reason) }), ) let stack = ComposeServices( file_path: cfg.file_path, project_name: cfg.project_name, services: services, ) let body_result = body(stack) let down_result = compose_down(cfg.file_path, cfg.project_name) |> result.map(fn(_) { Nil }) |> result.map_error(fn(reason) { error.ComposeFailed(path: cfg.file_path, reason: reason) }) case body_result, down_result { Ok(_), Error(e) -> Error(e) _, _ -> body_result } } @external(erlang, "testcontainer_compose_ffi", "run_compose_up") fn compose_up( file_path: String, project_name: String, env_overrides: List(#(String, String)), ) -> Result(String, String) @external(erlang, "testcontainer_compose_ffi", "run_compose_down") fn compose_down( file_path: String, project_name: String, ) -> Result(String, String) pub fn services(stack: ComposeServices) -> List(Service) { stack.services } pub fn service_by_name( stack: ComposeServices, name: String, ) -> Option(Service) { case list.find(stack.services, fn(svc) { svc.name == name }) { Ok(svc) -> Some(svc) Error(_) -> None } } pub fn service_name(svc: Service) -> String { svc.name } pub fn service_image(svc: Service) -> String { svc.image } pub fn service_ports(svc: Service) -> List(#(Int, Int)) { svc.ports } fn load_compose_file(cfg: ComposeConfig) -> Result(List(Service), error.Error) { case fio.exists(cfg.file_path) { False -> Error(error.ComposeFileNotFound(path: cfg.file_path)) True -> { let env_pairs = reveal_env_overrides(cfg.env_overrides) use json_str <- result.try( compose_config_json(cfg.file_path, env_pairs) |> result.map_error(fn(reason) { error.InvalidYaml(path: cfg.file_path, reason: reason) }), ) parse_services_json(cfg.file_path, json_str) } } } @external(erlang, "testcontainer_compose_ffi", "compose_config_json") fn compose_config_json( file_path: String, env_overrides: List(#(String, String)), ) -> Result(String, String) fn reveal_env_overrides( overrides: List(#(String, cowl.Secret(String))), ) -> List(#(String, String)) { list.map(overrides, fn(pair) { let #(key, secret) = pair #(key, cowl.reveal(secret)) }) } /// Parse a `docker compose config --format json` output string into typed /// `Service` records. Exposed for testability — useful when you have a /// pre-rendered compose JSON and want to validate parsing without Docker. /// /// `path` is only used to enrich error messages. pub fn parse_services_json( path: String, json_str: String, ) -> Result(List(Service), error.Error) { let decoder = { use services_dict <- decode.field( "services", decode.dict(decode.string, decode.dynamic), ) decode.success(services_dict) } case json.parse(from: json_str, using: decoder) { Ok(services_dict) -> case dict.size(services_dict) { 0 -> Error(error.InvalidYaml(path: path, reason: "no services defined")) _ -> Ok(services_from_dict(services_dict)) } Error(_) -> Error(error.InvalidYaml( path: path, reason: "compose config json parse failed", )) } } fn services_from_dict( services_dict: dict.Dict(String, decode.Dynamic), ) -> List(Service) { services_dict |> dict.to_list |> list.map(fn(entry) { let #(name, svc_dyn) = entry let image = decode_service_image(svc_dyn) let ports = decode_service_ports(svc_dyn) Service(name: name, image: image, ports: ports) }) } fn decode_service_image(svc_dyn: decode.Dynamic) -> String { let decoder = decode.field("image", decode.string, decode.success) case decode.run(svc_dyn, decoder) { Ok(image) -> image Error(_) -> "" } } fn decode_service_ports(svc_dyn: decode.Dynamic) -> List(#(Int, Int)) { let port_decoder = { use target <- decode.field("target", decode.int) use published <- decode.optional_field( "published", target, decode.one_of(decode.int, [ decode.string |> decode.then(parse_int_string), ]), ) decode.success(#(published, target)) } let decoder = decode.field("ports", decode.list(port_decoder), decode.success) case decode.run(svc_dyn, decoder) { Ok(ports) -> ports Error(_) -> [] } } fn parse_int_string(s: String) -> decode.Decoder(Int) { case int.parse(s) { Ok(n) -> decode.success(n) Error(_) -> decode.failure(0, "Int") } }