new features
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'''
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user