new features

This commit is contained in:
Mark Gregory
2026-11-09 08:20:57 +00:00
parent ecfa74a35f
commit 965ae923c2
27 changed files with 1474 additions and 168 deletions
+41
View File
@@ -0,0 +1,41 @@
name: laravel
services:
web:
build:
context: ./docker/php
args:
uid: ${UID:-1000}
gid: ${GID:-1000}
depends_on:
- db
volumes:
- ./:/home/devuser/app
- ./storage/tmp/composer:/home/devuser/.composer/cache
- ./storage/tmp/npm:/home/devuser/.npm
ports:
- ${WORKSPACE_PORT:-8080}:8080
restart: always
db:
image: mysql:5.7
environment:
- MYSQL_ROOT_PASSWORD=root_secret
- MYSQL_DATABASE=zeus
- MYSQL_USER=zeus_user
- MYSQL_PASSWORD=secret
volumes:
- ./storage/tmp/db:/var/lib/mysql
restart: always
adminer:
image: adminer
restart: always
ports:
- 9000:8080
nginx:
build: ./docker/nginx
ports:
- ${NGINX_PORT:-80}:80
volumes:
- ./:/home/devuser/app
depends_on:
- web
restart: always
@@ -0,0 +1,99 @@
import XCTest
@testable import ACDryCore
final class ACDryCoreTests: XCTestCase {
func testCurrentAndLegacySchemas() throws {
let rows = try ContainerService.parse(ContainerService.demoContainers, kind: .containers)
XCTAssertEqual(rows.count, 2); XCTAssertEqual(rows[1].id, "demo-web-1"); XCTAssertEqual(rows[1].state, "running"); XCTAssertEqual(rows[1].project, "demo")
XCTAssertEqual(try ContainerService.parse("[{\"reference\":\"alpine:latest\"}]", kind: .images).first?.id, "alpine:latest")
XCTAssertEqual(try ContainerService.parse("[{\"configuration\":{\"name\":\"data\"}}]", kind: .volumes).first?.id, "data")
XCTAssertEqual(try ContainerService.parse("[{\"id\":\"default\"}]", kind: .networks).first?.id, "default")
for invalid in ["oops", "{}", "[{}]"] { XCTAssertThrowsError(try ContainerService.parse(invalid, kind: .containers)) }
}
func testComposeHintIsExplicitAndExact() throws {
let input = "[{\"configuration\":{\"id\":\"my-app-web-api-1\"}},{\"configuration\":{\"id\":\"other-web-1\"}}]"
XCTAssertEqual(try ContainerService.parse(input, kind: .containers).first?.project, "")
let rows = try ContainerService.parse(input, kind: .projects, projectHint: "my-app")
XCTAssertEqual(rows[0].project, "my-app"); XCTAssertEqual(rows[0].service, "web-api"); XCTAssertEqual(rows[1].project, "")
}
func testNativeArguments() {
XCTAssertEqual(ResourceKind.containers.listArguments, ["list", "--all", "--format", "json"])
XCTAssertEqual(ContainerService.detailArguments(kind: .containers, id: "web", mode: "Logs"), ["logs", "-n", "200", "web"])
XCTAssertEqual(ContainerService.detailArguments(kind: .networks, id: "default", mode: "Inspect"), ["network", "inspect", "default"])
XCTAssertEqual(ContainerService.actionArguments(kind: .projects, action: "prune", id: ""), ["prune"])
XCTAssertEqual(ContainerService.actionArguments(kind: .images, action: "delete", id: "alpine"), ["image", "delete", "alpine"])
}
func testDemoAndCPUUnits() async throws {
let backend = ContainerService(demo: true)
let resources = try await backend.withStats(backend.list(.containers))
let measured = resources.first(where: { $0.id == "demo-web-1" })!
XCTAssertEqual(measured.cpu, String(format: "%.2f%%", measured.cpuFraction! * 100))
XCTAssertGreaterThan(measured.cpuFraction!, 0)
do { _ = try await backend.action(kind: .containers, action: "restart", resource: resources.first); XCTFail("Demo must not claim mutation success") } catch { XCTAssertTrue(error.localizedDescription.contains("read-only")) }
}
func testProcessCaptureFailureAndTimeout() throws {
XCTAssertEqual(try ContainerService(binary: "/bin/echo").execute(["hello", "$(not-a-shell-command)"]).trimmingCharacters(in: .whitespacesAndNewlines), "hello $(not-a-shell-command)")
XCTAssertThrowsError(try ContainerService(binary: "/usr/bin/false").execute([]))
XCTAssertThrowsError(try ContainerService(binary: "/nonexistent/container").execute([]))
let start = Date(); XCTAssertThrowsError(try ContainerService(binary: "/bin/sleep").execute(["10"], timeout: 0.1)); XCTAssertLessThan(Date().timeIntervalSince(start), 4)
}
func testNetworkRatesAndCounterResets() async throws {
var rows = try await ContainerService(demo: true).withStats(ContainerService(demo: true).list(.containers))
var tracker = NetworkRateTracker()
let date = Date(timeIntervalSince1970: 0)
XCTAssertEqual(tracker.sample(rows, at: date).rx, 0)
let n = rows.firstIndex(where: { $0.id == "demo-web-1" })!
rows[n].rxBytes! += 3000; rows[n].txBytes! += 1500
let rates = tracker.sample(rows, at: date.addingTimeInterval(3))
XCTAssertEqual(rates.rx, 1000); XCTAssertEqual(rates.tx, 500)
rows[n].rxBytes = 0; rows[n].txBytes = 0
XCTAssertEqual(tracker.sample(rows, at: date.addingTimeInterval(6)).rx, 0)
_ = tracker.sample([], at: date.addingTimeInterval(9))
rows[n].rxBytes = 1_000_000
XCTAssertEqual(tracker.sample(rows, at: date.addingTimeInterval(12)).rx, 0)
}
func testRestartStopsBeforeStartingAndStopsOnFailure() async throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("acdry-tests-" + UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let executable = dir.appendingPathComponent("container"), log = dir.appendingPathComponent("calls"), failure = dir.appendingPathComponent("fail-stop")
let script = "#!/bin/sh\necho \"$1\" >> \"\(log.path)\"\nif [ \"$1\" = stop ] && [ -f \"\(failure.path)\" ]; then exit 1; fi\n"
try script.write(to: executable, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
let backend = ContainerService(binary: executable.path)
let r = try ContainerService.parse(ContainerService.demoContainers, kind: .containers).first(where: { $0.state == "running" })!
_ = try await backend.action(kind: .containers, action: "restart", resource: r)
XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "stop\nstart\n")
try "".write(to: log, atomically: true, encoding: .utf8); try "".write(to: failure, atomically: true, encoding: .utf8)
do { _ = try await backend.action(kind: .containers, action: "restart", resource: r); XCTFail("Should fail") } catch { }
XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "stop\n")
}
func testStateVerificationAndReadOnlyDemo() async throws {
let backend = ContainerService(demo: true)
let resource = try await backend.waitForState(id: "demo-web-1", expected: "running", timeout: 1)
XCTAssertEqual(resource.state, "running")
do { _ = try await backend.waitForState(id: "demo-web-1", expected: "stopped", timeout: 0.01); XCTFail("Should time out") } catch { XCTAssertTrue(error.localizedDescription.contains("did not reach stopped")) }
do { _ = try await backend.run(["stop", "demo-web-1"]); XCTFail("Demo must be read-only") } catch { XCTAssertTrue(error.localizedDescription.contains("read-only")) }
}
func testVolumeMetadataAndMountReferences() throws {
let volumes = try ContainerService.parse("""
[{"configuration":{"name":"app-data","driver":"local","format":"ext4","source":"/volumes/app-data.img","sizeInBytes":67108864,"creationDate":"2026-11-08T12:00:00Z"}}]
""", kind: .volumes)
XCTAssertEqual(volumes[0].driver, "local"); XCTAssertEqual(volumes[0].format, "ext4"); XCTAssertNotEqual(volumes[0].capacity, "—")
let containers = try ContainerService.parse("""
[{"configuration":{"id":"app","mounts":[{"source":"/volumes/app-data.img","destination":"/data","type":{"volume":{"name":"app-data"}}},{"source":"/Users/example/project","destination":"/workspace","type":{"virtiofs":{}}},{"source":"","destination":"/tmp","type":{"tmpfs":{}}}]},"status":{"state":"stopped"}}]
""", kind: .containers)
let combined = VolumeInventory.combine(volumes: volumes, containers: containers)
XCTAssertEqual(combined.count, 2)
XCTAssertEqual(combined[0].usedBy, "app:/data"); XCTAssertFalse(combined[0].readOnlyStorage)
XCTAssertEqual(combined[1].storageKind, "Bind mount"); XCTAssertTrue(combined[1].readOnlyStorage); XCTAssertEqual(combined[1].usedBy, "app:/workspace")
XCTAssertEqual(VolumeInventory.combine(volumes: volumes, containers: containers, includeHostMounts: false).count, 1)
let legacy = try ContainerService.parse("[{\"name\":\"legacy\",\"driver\":\"local\",\"format\":\"ext4\",\"source\":\"/legacy\",\"createdAt\":\"2025-01-01\"}]", kind: .volumes)
XCTAssertEqual(legacy[0].created, "2025-01-01"); XCTAssertEqual(legacy[0].source, "/legacy")
}
func testConcurrentPipeDrainAndOutputLimit() throws {
// A subprocess that fills both pipes would deadlock a sequential reader.
let out = try ContainerService(binary: "/bin/sh").execute(["-c", "i=0; while [ $i -lt 5000 ]; do echo stdout-line; echo stderr-line >&2; i=$((i+1)); done"])
XCTAssertEqual(out.components(separatedBy: "stdout-line").count - 1, 5000)
}
}
+2 -2
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""Run against a built CLI: python3 Tests/cli_regression.py /absolute/path/accompose.
"""Run against a built CLI: python3 Tests/cli_regression.py /absolute/path/container-compose.
Uses a fake container executable; never touches the real runtime.
"""
import json, os, pathlib, signal, subprocess, sys, tempfile, time
binary = str(pathlib.Path(sys.argv[1]).resolve())
with tempfile.TemporaryDirectory(prefix='accompose-regression-', dir='/private/tmp') as tmp:
with tempfile.TemporaryDirectory(prefix='container-compose-regression-', dir=os.environ.get('CONTAINER_COMPOSE_TEST_TMPDIR', '/Volumes/ChatGPT' if pathlib.Path('/Volumes/ChatGPT').is_dir() else tempfile.gettempdir())) as tmp:
root = pathlib.Path(tmp)
fake = root / 'container'
fake.write_text('#!' + sys.executable + '\n' + r'''
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Unsupported YAML must fail before any runtime call."""
import os, pathlib, subprocess, sys, tempfile
binary = str(pathlib.Path(sys.argv[1]).resolve())
with tempfile.TemporaryDirectory(prefix='container-compose-compat-', dir=os.environ.get('CONTAINER_COMPOSE_TEST_TMPDIR', '/Volumes/ChatGPT' if pathlib.Path('/Volumes/ChatGPT').is_dir() else tempfile.gettempdir())) as d:
root = pathlib.Path(d)
runtime = root/'container'
runtime.write_text('#!/bin/sh\necho called >> "$CALLS"\nexit 99\n')
runtime.chmod(0o755)
calls=root/'calls'; source=root/'compose.yaml'
env=dict(os.environ, PATH=str(root)+':'+os.environ['PATH'], CALLS=str(calls))
fixtures = [
'secrets: {}\nservices:\n web:\n image: nginx\n',
'volumes:\n data:\n external: true\nservices:\n web:\n image: nginx\n',
'networks:\n lan:\n driver: overlay\nservices:\n web:\n image: nginx\n',
'services:\n web:\n image: nginx\n ports: [{target: 80, published: 8080}]\n',
'services:\n web:\n image: nginx\n volumes: [{type: bind, source: ., target: /app}]\n',
'services:\n web:\n image: nginx\n networks:\n lan:\n aliases: [web]\n',
'services:\n web:\n image: nginx\n depends_on:\n db:\n condition: service_healthy\n db:\n image: postgres\n',
'services:\n web:\n build:\n context: .\n target: production\n',
'services:\n web:\n image: nginx\n profiles: [dev]\n',
'services:\n web:\n image: nginx\n deploy: {replicas: 3}\n',
]
for fixture in fixtures:
source.write_text(fixture)
result=subprocess.run([binary,'-f',str(source),'up','-d'],env=env,capture_output=True,timeout=5)
assert result.returncode != 0, result.stdout
assert not calls.exists(), 'Unexpected runtime mutation'
source.write_text('services:\n web:\n image: nginx\n')
for command in [['down','--bogus'],['logs','--follow'],['stop','unknown']]:
result=subprocess.run([binary,'-f',str(source)]+command,env=env,capture_output=True,timeout=5)
assert result.returncode != 0
assert not calls.exists()
result=subprocess.run([binary,'monitor','--demo','start','demo-web-1'],env=env,capture_output=True,timeout=5)
assert result.returncode != 0 and not calls.exists()
print('PASS: 14 compatibility and read-only demo checks')
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Usage: python3 Tests/laravel_regression.py /absolute/path/container-compose"""
import json, os, pathlib, subprocess, sys, tempfile
binary=str(pathlib.Path(sys.argv[1]).resolve())
with tempfile.TemporaryDirectory(prefix='container-compose-laravel-',dir=os.environ.get('CONTAINER_COMPOSE_TEST_TMPDIR', '/Volumes/ChatGPT' if pathlib.Path('/Volumes/ChatGPT').is_dir() else tempfile.gettempdir())) as tmp:
root=pathlib.Path(tmp); calls=root/'calls'
runtime=root/'container'
runtime.write_text('#!'+sys.executable+'\nimport os,json,sys\nwith open(os.environ["CALLS"],"a") as f: f.write(json.dumps(sys.argv[1:])+"\\n")\n')
runtime.chmod(0o755)
env=dict(os.environ,PATH=str(root)+':'+os.environ.get('PATH',''),CALLS=str(calls),UID='1234',GID='2345')
path=root/'compose.yaml'
def run(command='config'):
return subprocess.run([binary,'-f',str(path),command],env=env,text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=10)
cases=[('', 'Compose file is empty'),(' # comment\n','mapping at its root'),('name: wrong\n','Missing top-level services'),('services: []\n','nonempty mapping'),('services: {}\n','nonempty mapping'),('services: [\n','Invalid YAML')]
for content,expected in cases:
path.write_text(content);p=run();assert p.returncode!=0 and expected in p.stdout and path.name in p.stdout,p.stdout
path.write_text((pathlib.Path(__file__).parent/'Fixtures/laravel.yaml').read_text())
p=run();assert p.returncode==0 and 'Validated 4 services' in p.stdout,p.stdout
p=run('build');assert p.returncode==0,p.stdout
records=[json.loads(x) for x in calls.read_text().splitlines()]
assert len(records)==2,records
assert records[0]==['build','-t','laravel-nginx:latest',str((root/'docker/nginx').resolve())],records
assert records[1]==['build','-t','laravel-web:latest','--build-arg','gid=2345','--build-arg','uid=1234',str((root/'docker/php').resolve())],records
calls.write_text('')
path.write_text('services:\n web:\n build:\n context: .\n args: ["UID", "LITERAL=hello world"]\n')
p=run('build');assert p.returncode==0,p.stdout
records=[json.loads(x) for x in calls.read_text().splitlines()]
assert ['--build-arg','UID=1234','--build-arg','LITERAL=hello world']==records[0][3:7],records
print('PASS: 9 Laravel and file diagnostic cases')