Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Container-Compose brings (limited) Docker Compose support to [Apple Container](h
- **Environment configuration:** Support for environment variable files (`.env`) to customize deployments.
- **Service dependencies:** Specify service dependencies and startup order.
- **Volume and network mapping:** Map data and networking as specified in Compose files to Apple Container equivalents.
- **Resource limits:** Map the official `deploy.resources` limits (and the legacy `cpus`/`mem_limit`/`mem_reservation` shorthands, with `deploy` winning on conflict) plus `shm_size`, `tmpfs`, and `ulimits` onto Apple Container flags. Fractional CPU requests are rounded up (Apple Container takes whole CPUs), and unsupported keys (soft reservations, device/GPU reservations, replicas) are ignored with a warning.
- **Extensible:** Designed for future extension and customization.

## Getting Started
Expand Down Expand Up @@ -63,6 +64,15 @@ container-compose up

You may need to provide a path to your `docker-compose.yml` and `.env` file as arguments.

Run a one-off command on a service (similar to `docker compose run`):

```sh
container-compose run --rm web sh
```

The service's ports are not mapped unless `--service-ports` is given, and its `depends_on`
services are started first unless `--no-deps` is given.

## Contributing

Contributions are welcome! Please open issues or submit pull requests to help improve this project.
Expand Down
17 changes: 17 additions & 0 deletions Sample Compose Files/Healthchecked Redis/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
# Explicit project name: the sample's directory name contains a space, which
# Apple Container does not allow in generated container/volume names.
name: healthchecked-redis

services:
redis:
restart: always
image: redis:7-alpine
deploy:
resources:
limits:
cpus: "2"
memory: 512m
shm_size: 256m
tmpfs:
- /tmp
ulimits:
nofile: 65535
volumes:
- redis-data:/data
healthcheck:
Expand All @@ -10,3 +24,6 @@ services:
retries: 20
ports:
- "6379:6379"

volumes:
redis-data:
1 change: 1 addition & 0 deletions Sources/Container-Compose/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public struct Main: AsyncParsableCommand {
version: Self.versionString,
subcommands: [
ComposeUp.self,
ComposeRun.self,
ComposeDown.self,
ComposeBuild.self,
Version.self
Expand Down
82 changes: 75 additions & 7 deletions Sources/Container-Compose/Codable Structs/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,34 @@ public struct Service: Codable, Hashable {
public let tty: Bool?

/// Memory limit shorthand (e.g., "512m", "1g") — top-level alternative to
/// `deploy.resources.limits.memory`. Takes precedence when both are set.
/// `deploy.resources.limits.memory`. The `deploy` form wins when both are set.
public let mem_limit: String?

/// CPU limit shorthand (e.g., "1.5", "2") — top-level alternative to
/// `deploy.resources.limits.cpus`. The `deploy` form wins when both are set.
public let cpus: String?

/// Memory reservation shorthand (e.g., "256m") — top-level alternative to
/// `deploy.resources.reservations.memory`. Apple Container has no soft
/// memory limit, so `ResourceArguments` approximates it as the hard
/// `--memory` limit when no limit is otherwise configured.
public let mem_reservation: String?

/// Size of `/dev/shm` (e.g., "64m", "1g"). Passed through as `--shm-size`.
public let shm_size: String?

/// tmpfs mounts (e.g., `["/run", "/tmp"]`). Each entry is passed through
/// as `--tmpfs <path>`.
public let tmpfs: [String]?

/// Resource limits keyed by type (e.g., `nofile: 65535`). Passed through as
/// `--ulimit <type>=<soft>[:<hard>]`.
public let ulimits: [String: Ulimit]?

/// Device mappings (e.g., `["/dev/kvm:/dev/kvm"]`). Parsed only so callers
/// can warn that Apple Container has no `--device` equivalent.
public let devices: [String]?

/// Additional `/etc/hosts` entries injected into the container. Each entry is a
/// `"hostname:IP"` string. The special token `host-gateway` resolves to the host
/// machine's IP as seen from inside the container.
Expand All @@ -133,7 +158,7 @@ public struct Service: Codable, Hashable {
enum CodingKeys: String, CodingKey {
case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user,
container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform,
mem_limit, extra_hosts, profiles
mem_limit, cpus, mem_reservation, shm_size, tmpfs, ulimits, devices, extra_hosts, profiles
}

/// Public memberwise initializer for testing
Expand Down Expand Up @@ -166,6 +191,12 @@ public struct Service: Codable, Hashable {
stdin_open: Bool? = nil,
tty: Bool? = nil,
mem_limit: String? = nil,
cpus: String? = nil,
mem_reservation: String? = nil,
shm_size: String? = nil,
tmpfs: [String]? = nil,
ulimits: [String: Ulimit]? = nil,
devices: [String]? = nil,
extra_hosts: [String]? = nil,
profiles: [String]? = nil,
dependedBy: [String] = []
Expand Down Expand Up @@ -198,6 +229,12 @@ public struct Service: Codable, Hashable {
self.stdin_open = stdin_open
self.tty = tty
self.mem_limit = mem_limit
self.cpus = cpus
self.mem_reservation = mem_reservation
self.shm_size = shm_size
self.tmpfs = tmpfs
self.ulimits = ulimits
self.devices = devices
self.extra_hosts = extra_hosts
self.profiles = profiles
self.dependedBy = dependedBy
Expand Down Expand Up @@ -321,14 +358,26 @@ public struct Service: Codable, Hashable {
stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open)
tty = try container.decodeIfPresent(Bool.self, forKey: .tty)
platform = try container.decodeIfPresent(String.self, forKey: .platform)
if let s = try? container.decodeIfPresent(String.self, forKey: .mem_limit) {
mem_limit = s
} else if let i = try? container.decodeIfPresent(Int.self, forKey: .mem_limit) {
mem_limit = "\(i)"
mem_limit = Self.decodeScalarString(container, forKey: .mem_limit)
cpus = Self.decodeScalarString(container, forKey: .cpus)
mem_reservation = Self.decodeScalarString(container, forKey: .mem_reservation)
shm_size = Self.decodeScalarString(container, forKey: .shm_size)

// `tmpfs` accepts either a single path or a list of paths per the
// Compose spec (`tmpfs: /run` vs `tmpfs: [/run, /tmp]`). Apple's
// `container run --tmpfs` takes only a path, so the long form's
// `size`/`mode` options (when given as a mapping) are ignored.
if let list = try? container.decodeIfPresent([String].self, forKey: .tmpfs) {
tmpfs = list
} else if let single = try? container.decodeIfPresent(String.self, forKey: .tmpfs) {
tmpfs = [single]
} else {
mem_limit = nil
tmpfs = nil
}

ulimits = try container.decodeIfPresent([String: Ulimit].self, forKey: .ulimits)
devices = try container.decodeIfPresent([String].self, forKey: .devices)

// `extra_hosts` accepts two forms per the Compose spec:
// extra_hosts: extra_hosts:
// - "hostname:IP" or hostname: IP
Expand All @@ -347,6 +396,25 @@ public struct Service: Codable, Hashable {
profiles = try container.decodeIfPresent([String].self, forKey: .profiles)
}

/// Decodes a scalar that Compose permits as either a string or a number
/// (e.g. `mem_limit: 512m`, `mem_limit: 536870912`, `cpus: 1.5`) into its
/// string form so the value can be passed through to `container run`.
static func decodeScalarString(
_ container: KeyedDecodingContainer<CodingKeys>,
forKey key: CodingKeys
) -> String? {
if let string = try? container.decodeIfPresent(String.self, forKey: key) {
return string
}
if let int = try? container.decodeIfPresent(Int.self, forKey: key) {
return "\(int)"
}
if let double = try? container.decodeIfPresent(Double.self, forKey: key) {
return double.truncatingRemainder(dividingBy: 1) == 0 ? "\(Int(double))" : "\(double)"
}
return nil
}

/// True when this service should be included by default given the currently
/// active profiles. Per the Compose spec: a service with no `profiles` is
/// always eligible; a service with `profiles` is eligible only when at least
Expand Down
112 changes: 112 additions & 0 deletions Sources/Container-Compose/Codable Structs/Ulimit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

import Foundation

/// A single `ulimits:` entry from a Compose service, e.g.
///
/// ulimits:
/// nofile: 65535
/// nproc:
/// soft: 1024
/// hard: 2048
///
/// Values may be bare integers, numeric strings, or a `{soft, hard}` mapping.
/// Stored as strings so unresolved environment-variable expressions survive
/// decoding and can be resolved (and passed through to `--ulimit`) later.
public struct Ulimit: Codable, Hashable {
public let soft: String?
public let hard: String?

enum CodingKeys: String, CodingKey {
case soft, hard
}

public init(soft: String? = nil, hard: String? = nil) {
self.soft = soft
self.hard = hard
}

public init(from decoder: Decoder) throws {
// Short form: `nofile: 65535` (or "65535") — soft and hard are equal.
let single = try decoder.singleValueContainer()
if let value = Self.decodeScalar(from: single) {
soft = value
hard = value
return
}

// Long form: `nofile: { soft: 1024, hard: 2048 }`.
let container = try decoder.container(keyedBy: CodingKeys.self)
soft = Self.decodeScalar(container, forKey: .soft)
hard = Self.decodeScalar(container, forKey: .hard)
guard soft != nil || hard != nil else {
throw DecodingError.dataCorruptedError(
forKey: .soft,
in: container,
debugDescription: "ulimit entry must be a number, a string, or a {soft, hard} mapping."
)
}
}

public func encode(to encoder: Encoder) throws {
if soft == hard {
var single = encoder.singleValueContainer()
try single.encode(soft)
return
}
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(soft, forKey: .soft)
try container.encodeIfPresent(hard, forKey: .hard)
}

/// Decodes a scalar rlimit value, accepting numbers and numeric strings.
private static func decodeScalar(from single: SingleValueDecodingContainer) -> String? {
if let string = try? single.decode(String.self) {
return string
}
if let int = try? single.decode(Int.self) {
return "\(int)"
}
if let double = try? single.decode(Double.self) {
return normalizedNumber(double)
}
return nil
}

private static func decodeScalar(
_ container: KeyedDecodingContainer<CodingKeys>,
forKey key: CodingKeys
) -> String? {
if let string = try? container.decodeIfPresent(String.self, forKey: key) {
return string
}
if let int = try? container.decodeIfPresent(Int.self, forKey: key) {
return "\(int)"
}
if let double = try? container.decodeIfPresent(Double.self, forKey: key) {
return normalizedNumber(double)
}
return nil
}

private static func normalizedNumber(_ value: Double) -> String {
if value.truncatingRemainder(dividingBy: 1) == 0 {
return "\(Int(value))"
}
return "\(value)"
}
}
2 changes: 1 addition & 1 deletion Sources/Container-Compose/Commands/ComposeBuild.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable {
let arch = String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64")
commands.append(contentsOf: ["--os", os, "--arch", arch])

let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2
let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? service.cpus ?? "2") ?? 2
let memoryLimit = service.deploy?.resources?.limits?.memory ?? "2048MB"
commands.append(contentsOf: ["--cpus", "\(cpuCount)", "--memory", memoryLimit])

Expand Down
18 changes: 18 additions & 0 deletions Sources/Container-Compose/Commands/ComposeDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,23 @@ public struct ComposeDown: AsyncParsableCommand {
// to remove once the container that had it mounted is stopped.
try? fileManager.removeItem(atPath: ComposeUp.extraHostsFilePath(projectName: project.projectName, serviceName: target.serviceName))
}

await removeOrphanedOneOffHostsFiles(projectName: project.projectName)
}

/// Sweeps the generated /etc/hosts files for one-off `run` containers whose
/// container no longer exists. Files still backing a running or stopped
/// container are kept, since that container may be started again.
private func removeOrphanedOneOffHostsFiles(projectName: String) async {
let client = ContainerClient()
let tmpDirectory = NSTemporaryDirectory()
let entries = (try? fileManager.contentsOfDirectory(atPath: tmpDirectory)) ?? []
for entry in entries {
guard let containerName = ComposeUp.runContainerName(fromHostsFilename: entry, projectName: projectName) else {
continue
}
guard (try? await client.get(id: containerName)) == nil else { continue }
try? fileManager.removeItem(atPath: tmpDirectory + entry)
}
}
}
Loading