Compare commits

..

18 Commits

Author SHA1 Message Date
f4c3ba74d1 v5.1.2 2026-03-28 05:39:48 +00:00
645e1fd4a9 fix(deps): upgrade core tooling dependencies and adapt Docker client internals for compatibility 2026-03-28 05:39:48 +00:00
1923837225 v5.1.1 2026-03-16 00:49:37 +00:00
97b89efd84 fix(paths): use the system temp directory for nogit storage and add release metadata 2026-03-16 00:49:37 +00:00
e28a35791a v5.1.0 2025-11-25 05:18:48 +00:00
b86601c939 feat(host): Add DockerHost version & image-prune APIs, extend network creation options, return exec inspect info, and improve image import/store and streaming 2025-11-25 05:18:48 +00:00
889b017d4f v5.0.2 2025-11-24 16:03:04 +00:00
35e8eff092 fix(DockerContainer): Fix getContainerById to return undefined for non-existent containers 2025-11-24 16:03:04 +00:00
2ecd4e9d7c v5.0.0 2025-11-24 13:27:10 +00:00
08dbad47bc BREAKING CHANGE(DockerHost): Rename array-returning get* methods to list* on DockerHost and related resource classes; update docs, tests and changelog 2025-11-24 13:27:10 +00:00
15e5dedae4 v3.0.2 2025-11-24 13:09:00 +00:00
5834721da8 fix(readme): Update README to document 3.0.0+ changes: architecture refactor, streaming improvements, health check and circular dependency fixes 2025-11-24 13:09:00 +00:00
2f31e14cbe v3.0.1 2025-11-24 13:06:44 +00:00
5691e5fb78 fix(classes.base): Use type-only import for DockerHost in classes.base to avoid runtime side-effects 2025-11-24 13:06:44 +00:00
8d043d20a8 v3.0.0 2025-11-24 12:20:30 +00:00
6fe70e0a1d BREAKING CHANGE(DockerHost): Refactor public API to DockerHost facade; introduce DockerResource base; make resource static methods internal; support flexible descriptors and stream compatibility 2025-11-24 12:20:30 +00:00
cc9c20882e v2.1.0 2025-11-18 17:30:04 +00:00
08af9fec14 feat(DockerHost): Add DockerHost.ping() to check Docker daemon availability and document health-check usage 2025-11-18 17:30:04 +00:00
25 changed files with 7289 additions and 6668 deletions

View File

@@ -1,15 +1,17 @@
{
"npmdocker": {
"baseImage": "host.today/ht-docker-node:npmci",
"command": "(ls -a && rm -r node_modules && yarn global add npmts && yarn install && npmts)",
"dockerSock": true
},
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "public",
"npmRegistryUrl": "registry.npmjs.org"
},
"gitzone": {
"@git.zone/cli": {
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
},
"projectType": "npm",
"module": {
"githost": "code.foss.global",
@@ -17,19 +19,12 @@
"gitrepo": "docker",
"description": "Provides easy communication with Docker remote API from Node.js, with TypeScript support.",
"npmPackagename": "@apiclient.xyz/docker",
"license": "MIT",
"keywords": [
"Docker",
"API",
"Node.js",
"TypeScript",
"Containers",
"Images",
"Networks",
"Services",
"Secrets"
]
}
"license": "MIT"
},
"services": [
"mongodb",
"minio"
]
},
"tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"

View File

@@ -1,7 +1,7 @@
{
"json.schemas": [
{
"fileMatch": ["/npmextra.json"],
"fileMatch": ["/.smartconfig.json"],
"schema": {
"type": "object",
"properties": {

View File

@@ -1,5 +1,123 @@
# Changelog
## 2026-03-28 - 5.1.2 - fix(deps)
upgrade core tooling dependencies and adapt Docker client internals for compatibility
- replace removed smartfile filesystem APIs with node:fs and SmartFileFactory usage
- update imagestore archive handling for smartarchive v5 and smartbucket v4 overwrite behavior
- improve Docker resource creation and stream handling with stricter null checks, cleanup, and timeout safeguards
- adjust tests and runtime behavior for Deno and newer dependency constraints
## 2026-03-16 - 5.1.1 - fix(paths)
use the system temp directory for nogit storage and add release metadata
- Changes nogitDir to resolve under the OS temporary directory instead of a local .nogit folder
- Adds @git.zone/cli release and module metadata to npmextra.json for npm publishing configuration
## 2025-11-25 - 5.1.0 - feat(host)
Add DockerHost version & image-prune APIs, extend network creation options, return exec inspect info, and improve image import/store and streaming
- Add DockerHost.getVersion() to retrieve Docker daemon version and build info
- Add DockerHost.pruneImages() with dangling and filters support (calls /images/prune)
- Extend INetworkCreationDescriptor and DockerNetwork._create() to accept Driver, IPAM, EnableIPv6, Attachable and Labels
- Enhance DockerContainer.exec() to return an inspect() helper and introduce IExecInspectInfo to expose exec state and exit code
- Improve DockerImage._createFromTarStream() parsing of docker-load output and error messages when loaded image cannot be determined
- Implement DockerImageStore.storeImage() to persist, repackage and upload images (local processing and s3 support)
- Various streaming/request improvements for compatibility with Node streams and better handling of streaming endpoints
- Update tests to cover new features (network creation, exec inspect, etc.)
## 2025-11-24 - 5.0.2 - fix(DockerContainer)
Fix getContainerById to return undefined for non-existent containers
- Prevented creation of an invalid DockerContainer from Docker API error responses when a container does not exist.
- Changed DockerContainer._fromId to use the list+find pattern and return Promise<DockerContainer | undefined>.
- Updated DockerHost.getContainerById to return Promise<DockerContainer | undefined> for type safety and consistent behavior.
- Added tests to verify undefined is returned for non-existent container IDs and that valid IDs return DockerContainer instances.
- Bumped package version to 5.0.1 and updated changelog and readme hints to document the fix.
## 2025-11-24 - 5.0.0 - BREAKING CHANGE(DockerHost)
Rename array-returning get* methods to list* on DockerHost and related resource classes; update docs, tests and changelog
- Renamed public DockerHost methods: getContainers → listContainers, getNetworks → listNetworks, getServices → listServices, getImages → listImages, getSecrets → listSecrets.
- Renamed DockerNetwork.getContainersOnNetwork → DockerNetwork.listContainersOnNetwork and updated usages (e.g. getContainersOnNetworkForService).
- Updated internal/static method docs/comments to recommend dockerHost.list*() usage and adjusted implementations accordingly.
- Updated README, readme.hints.md, tests (test.nonci.node+deno.ts) and changelog to reflect the new list* method names.
- Bumped package version to 4.0.0.
- Migration note: replace calls to get*() with list*() for methods that return multiple items (arrays). Single-item getters such as getContainerById or getNetworkByName remain unchanged.
## 2025-11-24 - 5.0.1 - fix(DockerContainer)
Fix getContainerById() to return undefined instead of invalid container object when container doesn't exist
**Bug Fixed:**
- `getContainerById()` was creating a DockerContainer object from error responses when a container didn't exist
- The error object `{ message: "No such container: ..." }` was being passed to the constructor
- Calling `.logs()` on this invalid container returned "[object Object]" instead of logs
**Solution:**
- Changed `DockerContainer._fromId()` to use the list+filter pattern (consistent with all other resource getters)
- Now returns `undefined` when container is not found (matches DockerImage, DockerNetwork, DockerService, DockerSecret behavior)
- Updated return type to `Promise<DockerContainer | undefined>` for type safety
- Added tests to verify undefined is returned for non-existent containers
**Migration:**
No breaking changes - users should already be checking for undefined/null based on TypeScript types and documentation.
## 2025-11-24 - 4.0.0 - BREAKING CHANGE: Rename list methods for consistency
**Breaking Changes:**
- Renamed all "get*" methods that return arrays to "list*" methods for better clarity:
- `getContainers()``listContainers()`
- `getNetworks()``listNetworks()`
- `getServices()``listServices()`
- `getImages()``listImages()`
- `getSecrets()``listSecrets()`
- `getContainersOnNetwork()``listContainersOnNetwork()` (on DockerNetwork class)
**Migration Guide:**
Update all method calls from `get*()` to `list*()` where the method returns an array of resources. Single-item getters like `getContainerById()`, `getNetworkByName()`, etc. remain unchanged.
**Rationale:**
The `list*` naming convention more clearly indicates that these methods return multiple items (arrays), while `get*` methods are reserved for retrieving single items by ID or name. This follows standard API design patterns and improves code readability.
## 2025-11-24 - 3.0.2 - fix(readme)
Update README to document 3.0.0+ changes: architecture refactor, streaming improvements, health check and circular dependency fixes
- Documented major refactor to a Clean OOP / Facade pattern with DockerHost as the single entry point
- Added/clarified real-time container streaming APIs: streamLogs(), attach(), exec()
- Clarified support for flexible descriptors (accept both string references and class instances)
- Documented complete container lifecycle API (start, stop, remove, logs, inspect, stats)
- Documented new ping() health check method to verify Docker daemon availability
- Noted fix for circular dependency issues in Node.js by using type-only imports
- Mentioned improved TypeScript definitions and expanded examples, migration guides, and real-world use cases
## 2025-11-24 - 3.0.1 - fix(classes.base)
Use type-only import for DockerHost in classes.base to avoid runtime side-effects
- Changed the import in ts/classes.base.ts to a type-only import: import type { DockerHost } from './classes.host.js';
- Prevents a runtime import of classes.host when only the type is needed, reducing risk of circular dependencies and unintended side-effects during module initialization.
- No behavior changes to the public API — TypeScript-only change; intended to improve bundling and runtime stability.
## 2025-11-24 - 3.0.0 - BREAKING CHANGE(DockerHost)
Refactor public API to DockerHost facade; introduce DockerResource base; make resource static methods internal; support flexible descriptors and stream compatibility
- Refactored architecture: DockerHost is now the single public entry point (Facade) for all operations; direct static calls like DockerImage.createFromRegistry(...) are now internal and replaced by DockerHost.createImageFromRegistry(...) and similar factory methods.
- Introduced DockerResource abstract base class used by all resource classes (DockerContainer, DockerImage, DockerNetwork, DockerSecret, DockerService) with a required refresh() method and standardized dockerHost property.
- Static methods on resource classes were renamed / scoped as internal (prefixed with _): _list, _fromName/_fromId, _create, _createFromRegistry, _createFromTarStream, _build, etc. Consumers should call DockerHost methods instead.
- Creation descriptor interfaces (container, service, etc.) now accept either string identifiers or resource instances (e.g. image: string | DockerImage, networks: (string | DockerNetwork)[], secrets: (string | DockerSecret)[]). DockerHost resolves instances internally.
- DockerImageStore imageStore has been made private on DockerHost; new public methods DockerHost.storeImage(name, stream) and DockerHost.retrieveImage(name) provide access to the image store.
- Streaming compatibility: updated requestStreaming to convert web ReadableStreams (smartrequest v5+) to Node.js streams via smartstream.nodewebhelpers, preserving backward compatibility for existing streaming APIs (container logs, attach, exec, image import/export, events).
- Container enhancements: added full lifecycle and streaming/interactive APIs on DockerContainer: refresh(), inspect(), start(), stop(), remove(), logs(), stats(), streamLogs(), attach(), exec().
- Service creation updated: resolves image/network/secret descriptors (strings or instances); adds labels.version from image; improved resource handling and port/secret/network resolution.
- Network and Secret classes updated to extend DockerResource and to expose refresh(), remove() and lookup methods via DockerHost (createNetwork/listNetworks/getNetworkByName, createSecret/listSecrets/getSecretByName/getSecretById).
- Tests and docs updated: migration guide and examples added (readme.hints.md, README); test timeout reduced from 600s to 300s in package.json.
- BREAKING: Public API changes require consumers to migrate away from direct resource static calls and direct imageStore access to the new DockerHost-based factory methods and storeImage/retrieveImage APIs.
## 2025-11-18 - 2.1.0 - feat(DockerHost)
Add DockerHost.ping() to check Docker daemon availability and document health-check usage
- Add DockerHost.ping() method that issues a GET to /_ping and throws an error if the response status is not 200
- Update README: show ping() in Quick Start, add health check examples (isDockerHealthy, waitForDocker) and mention Health Checks in Key Concepts
## 2025-11-18 - 2.0.0 - BREAKING CHANGE(DockerHost)
Rename DockerHost constructor option 'dockerSockPath' to 'socketPath' and update internal socket path handling

4754
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@apiclient.xyz/docker",
"version": "2.0.0",
"version": "5.1.2",
"description": "Provides easy communication with Docker remote API from Node.js, with TypeScript support.",
"private": false,
"main": "dist_ts/index.js",
@@ -33,29 +33,29 @@
},
"homepage": "https://code.foss.global/apiclient.xyz/docker#readme",
"dependencies": {
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smartarchive": "^4.2.2",
"@push.rocks/smartbucket": "^3.3.10",
"@push.rocks/smartfile": "^11.2.7",
"@push.rocks/smartjson": "^5.2.0",
"@push.rocks/smartlog": "^3.1.10",
"@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/smartarchive": "^5.2.1",
"@push.rocks/smartbucket": "^4.5.1",
"@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartjson": "^6.0.0",
"@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartstream": "^3.2.5",
"@push.rocks/smartstream": "^3.4.0",
"@push.rocks/smartstring": "^4.1.0",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartversion": "^3.0.5",
"@tsclass/tsclass": "^9.3.0",
"@tsclass/tsclass": "^9.5.0",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@git.zone/tsbuild": "^3.1.0",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^2.8.2",
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3",
"@push.rocks/qenv": "^6.1.3",
"@types/node": "22.7.5"
"@types/node": "^25.5.0"
},
"files": [
"ts/**/*",
@@ -66,7 +66,7 @@
"dist_ts_web/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
".smartconfig.json",
"readme.md"
],
"browserslist": [

5771
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,74 @@
# Docker Module - Development Hints
## smartrequest v5+ Migration (2025-11-17)
## Dependency Upgrade Notes (2026-03-28 - v5.2.0)
### Breaking Change
smartrequest v5.0.0+ returns web `ReadableStream` objects (Web Streams API) instead of Node.js streams.
### Major Upgrades Completed
### Solution Implemented
All streaming methods now convert web ReadableStreams to Node.js streams using:
| Package | From | To | Notes |
|---------|------|-----|-------|
| @push.rocks/smartfile | ^11.2.7 | ^13.1.2 | `fs.*`, `fsStream.*` removed; use `node:fs` directly or `SmartFileFactory.nodeFs()` |
| @push.rocks/smartarchive | ^4.2.2 | ^5.2.1 | `SmartArchive.fromArchiveFile()` removed; use `SmartArchive.create().file(path).extract(dir)` |
| @push.rocks/smartbucket | ^3.3.10 | ^4.5.1 | Strict-by-default: `fastPutStream` throws on existing objects instead of overwriting |
| @push.rocks/smartjson | ^5.2.0 | ^6.0.0 | No code changes needed |
| @push.rocks/smartnetwork | ^4.4.0 | ^4.5.2 | v4.5.2 uses Rust bridge for getDefaultGateway; breaks in Deno without --allow-run |
| @tsclass/tsclass | ^9.3.0 | ^9.5.0 | No code changes needed |
| @git.zone/tsbuild | ^3.1.0 | ^4.4.0 | v4.4.0 enforces strict TS checks (strictPropertyInitialization) |
| @git.zone/tstest | ^2.8.2 | ^3.6.3 | No code changes needed |
| @types/node | ^22.15.0 | ^25.5.0 | Major version bump |
### Migration Details
**smartfile v13**: All `smartfile.fs.*` and `smartfile.fsStream.*` APIs were removed. Replaced with:
- `plugins.fs.createReadStream()` / `plugins.fs.createWriteStream()` (from `node:fs`)
- `plugins.fs.promises.rm()` (for file/dir removal)
- `plugins.fs.existsSync()` (for file existence checks)
- `plugins.smartfile.SmartFileFactory.nodeFs().fromFilePath()` (for reading files into SmartFile objects)
**smartarchive v5**: Uses fluent API now:
```typescript
plugins.smartstream.nodewebhelpers.convertWebReadableToNodeReadable(webStream)
// Old: SmartArchive.fromArchiveFile(path) -> archive.exportToFs(dir)
// New: SmartArchive.create().file(path).extract(dir)
// TarTools: packDirectory() now returns Uint8Array, use getDirectoryPackStream() for streams
```
### Files Modified
- `ts/classes.host.ts`:
- `requestStreaming()` - Converts web stream to Node.js stream before returning
- `getEventObservable()` - Works with converted Node.js stream
**smartbucket v4**: `fastPutStream` now throws if object already exists. Must delete first:
```typescript
try { await dir.fastRemove({ path }); } catch (e) { /* may not exist */ }
await dir.fastPutStream({ stream, path });
```
- `ts/classes.image.ts`:
- `createFromTarStream()` - Uses converted Node.js stream for event handling
- `exportToTarStream()` - Uses converted Node.js stream for backpressure management
**tsbuild v4.4.0**: Enforces `strictPropertyInitialization`. All class properties populated via `Object.assign()` from Docker API responses need `!` definite assignment assertions.
### Testing
- Build:  All 11 type errors resolved
- Tests:  Node.js tests pass (DockerHost, DockerContainer, DockerImage, DockerImageStore)
**smartnetwork v4.5.2**: `getDefaultGateway()` now uses a Rust binary bridge. Fails in Deno without `--allow-run` permission. Code wraps the call in try/catch with fallback to empty string (Docker auto-detects advertise address).
### Notes
- The conversion maintains backward compatibility with existing code expecting Node.js stream methods (`.on()`, `.emit()`, `.pause()`, `.resume()`)
- smartstream's `nodewebhelpers` module provides bidirectional conversion utilities between web and Node.js streams
### Config Migration
- `npmextra.json` renamed to `.smartconfig.json`
- Removed stale `npmdocker` and duplicate `gitzone` sections
- `@push.rocks/smartfs` removed (was imported but never used)
## OCI Image Format Handling
The `DockerImageStore.storeImage()` method handles optional `repositories` file gracefully. OCI-format image tars may not include this file, so it's checked with `fs.existsSync()` before attempting to read.
## Architecture
- **DockerHost** is the single entry point (Facade pattern)
- All resource classes extend abstract `DockerResource` base class
- Static methods prefixed with `_` indicate internal use
- Public API exclusively through DockerHost methods
### Key Patterns
- Factory pattern: All resource creation/retrieval goes through DockerHost
- Stream handling: Web ReadableStreams from smartrequest are converted to Node.js streams via `smartstream.nodewebhelpers`
- Container getter: Uses list+filter pattern (not direct API call) to avoid creating invalid objects from error responses
## Test Notes
- Tests are `nonci` (require Docker daemon)
- S3 imagestore test can take 2-3 minutes depending on network
- Exec tests use 5s safety timeout due to buildkit container not always emitting stream 'end' events
- Test timeout is 600s to accommodate slow S3 uploads
- Deno tests crash with smartnetwork v4.5.2 due to Rust binary spawn permissions (not a code bug)

1415
readme.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
// tstest:deno:allowAll
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { Qenv } from '@push.rocks/qenv';
@@ -22,18 +23,18 @@ tap.test('should create a docker swarm', async () => {
// Containers
tap.test('should list containers', async () => {
const containers = await testDockerHost.getContainers();
const containers = await testDockerHost.listContainers();
console.log(containers);
});
// Networks
tap.test('should list networks', async () => {
const networks = await testDockerHost.getNetworks();
const networks = await testDockerHost.listNetworks();
console.log(networks);
});
tap.test('should create a network', async () => {
const newNetwork = await docker.DockerNetwork.createNetwork(testDockerHost, {
const newNetwork = await testDockerHost.createNetwork({
Name: 'webgateway',
});
expect(newNetwork).toBeInstanceOf(docker.DockerNetwork);
@@ -41,20 +42,15 @@ tap.test('should create a network', async () => {
});
tap.test('should remove a network', async () => {
const webgateway = await docker.DockerNetwork.getNetworkByName(
testDockerHost,
'webgateway',
);
const webgateway = await testDockerHost.getNetworkByName('webgateway');
await webgateway.remove();
});
// Images
tap.test('should pull an image from imagetag', async () => {
const image = await docker.DockerImage.createFromRegistry(testDockerHost, {
creationObject: {
imageUrl: 'hosttoday/ht-docker-node',
imageTag: 'alpine',
},
const image = await testDockerHost.createImageFromRegistry({
imageUrl: 'hosttoday/ht-docker-node',
imageTag: 'alpine',
});
expect(image).toBeInstanceOf(docker.DockerImage);
console.log(image);
@@ -71,7 +67,7 @@ tap.test('should return a change Observable', async (tools) => {
// SECRETS
tap.test('should create a secret', async () => {
const mySecret = await docker.DockerSecret.createSecret(testDockerHost, {
const mySecret = await testDockerHost.createSecret({
name: 'testSecret',
version: '1.0.3',
contentArg: `{ "hi": "wow"}`,
@@ -81,10 +77,7 @@ tap.test('should create a secret', async () => {
});
tap.test('should remove a secret by name', async () => {
const mySecret = await docker.DockerSecret.getSecretByName(
testDockerHost,
'testSecret',
);
const mySecret = await testDockerHost.getSecretByName('testSecret');
await mySecret.remove();
});
@@ -94,29 +87,24 @@ tap.test('should activate swarm mode', async () => {
});
tap.test('should list all services', async (tools) => {
const services = await testDockerHost.getServices();
const services = await testDockerHost.listServices();
console.log(services);
});
tap.test('should create a service', async () => {
const testNetwork = await docker.DockerNetwork.createNetwork(testDockerHost, {
const testNetwork = await testDockerHost.createNetwork({
Name: 'testNetwork',
});
const testSecret = await docker.DockerSecret.createSecret(testDockerHost, {
const testSecret = await testDockerHost.createSecret({
name: 'testSecret',
version: '0.0.1',
labels: {},
contentArg: '{"hi": "wow"}',
});
const testImage = await docker.DockerImage.createFromRegistry(
testDockerHost,
{
creationObject: {
imageUrl: 'code.foss.global/host.today/ht-docker-node:latest',
},
},
);
const testService = await docker.DockerService.createService(testDockerHost, {
const testImage = await testDockerHost.createImageFromRegistry({
imageUrl: 'code.foss.global/host.today/ht-docker-node:latest',
});
const testService = await testDockerHost.createService({
image: testImage,
labels: {},
name: 'testService',
@@ -127,21 +115,16 @@ tap.test('should create a service', async () => {
});
await testService.remove();
await testNetwork.remove();
await testSecret.remove();
if (testNetwork) await testNetwork.remove();
if (testSecret) await testSecret.remove();
});
tap.test('should export images', async (toolsArg) => {
const done = toolsArg.defer();
const testImage = await docker.DockerImage.createFromRegistry(
testDockerHost,
{
creationObject: {
imageUrl: 'code.foss.global/host.today/ht-docker-node:latest',
},
},
);
const fsWriteStream = plugins.smartfile.fsStream.createWriteStream(
const testImage = await testDockerHost.createImageFromRegistry({
imageUrl: 'code.foss.global/host.today/ht-docker-node:latest',
});
const fsWriteStream = plugins.fs.createWriteStream(
plugins.path.join(paths.nogitDir, 'testimage.tar'),
);
const exportStream = await testImage.exportToTarStream();
@@ -152,16 +135,13 @@ tap.test('should export images', async (toolsArg) => {
});
tap.test('should import images', async () => {
const fsReadStream = plugins.smartfile.fsStream.createReadStream(
const fsReadStream = plugins.fs.createReadStream(
plugins.path.join(paths.nogitDir, 'testimage.tar'),
);
const importedImage = await docker.DockerImage.createFromTarStream(
testDockerHost,
const importedImage = await testDockerHost.createImageFromTarStream(
fsReadStream,
{
tarStream: fsReadStream,
creationObject: {
imageUrl: 'code.foss.global/host.today/ht-docker-node:latest',
},
imageUrl: 'code.foss.global/host.today/ht-docker-node:latest',
},
);
expect(importedImage).toBeInstanceOf(docker.DockerImage);
@@ -169,25 +149,344 @@ tap.test('should import images', async () => {
tap.test('should expose a working DockerImageStore', async () => {
// lets first add am s3 target
const s3Descriptor = {
const s3Descriptor: plugins.tsclass.storage.IS3Descriptor = {
endpoint: await testQenv.getEnvVarOnDemand('S3_ENDPOINT'),
port: parseInt(await testQenv.getEnvVarOnDemand('S3_PORT'), 10),
useSsl: false,
accessKey: await testQenv.getEnvVarOnDemand('S3_ACCESSKEY'),
accessSecret: await testQenv.getEnvVarOnDemand('S3_ACCESSSECRET'),
bucketName: await testQenv.getEnvVarOnDemand('S3_BUCKET'),
};
await testDockerHost.addS3Storage(s3Descriptor);
//
await testDockerHost.imageStore.storeImage(
// Use the new public API instead of direct imageStore access
await testDockerHost.storeImage(
'hello2',
plugins.smartfile.fsStream.createReadStream(
plugins.fs.createReadStream(
plugins.path.join(paths.nogitDir, 'testimage.tar'),
),
);
});
// CONTAINER GETTERS
tap.test('should return undefined for non-existent container', async () => {
const container = await testDockerHost.getContainerById('invalid-container-id-12345');
expect(container).toEqual(undefined);
});
tap.test('should return container for valid container ID', async () => {
const containers = await testDockerHost.listContainers();
if (containers.length > 0) {
const validId = containers[0].Id;
const container = await testDockerHost.getContainerById(validId);
expect(container).toBeInstanceOf(docker.DockerContainer);
expect(container?.Id).toEqual(validId);
}
});
// CONTAINER STREAMING FEATURES
let testContainer: docker.DockerContainer;
tap.test('should get an existing container for streaming tests', async () => {
const containers = await testDockerHost.listContainers();
// Use the first running container we find
testContainer = containers.find((c) => c.State === 'running');
if (!testContainer) {
throw new Error('No running containers found for streaming tests');
}
expect(testContainer).toBeInstanceOf(docker.DockerContainer);
console.log('Using existing container for tests:', testContainer.Names[0], testContainer.Id);
});
tap.test('should stream container logs', async (tools) => {
const done = tools.defer();
const logStream = await testContainer.streamLogs({
stdout: true,
stderr: true,
timestamps: true,
});
let receivedData = false;
logStream.on('data', (chunk) => {
console.log('Received log chunk:', chunk.toString().slice(0, 100));
receivedData = true;
});
logStream.on('error', (error) => {
console.error('Stream error:', error);
done.resolve();
});
// Wait for 2 seconds to collect logs, then close
await tools.delayFor(2000);
logStream.destroy();
done.resolve();
await done.promise;
console.log('Log streaming test completed. Received data:', receivedData);
});
tap.test('should get container logs (one-shot)', async () => {
const logs = await testContainer.logs({
stdout: true,
stderr: true,
tail: 10,
});
expect(typeof logs).toEqual('string');
console.log('Container logs (last 10 lines):', logs.slice(0, 200));
});
tap.test('should execute command in container', async (tools) => {
const done = tools.defer();
const { stream, close } = await testContainer.exec('echo "Hello from exec"', {
tty: false,
attachStdout: true,
attachStderr: true,
});
let output = '';
stream.on('data', (chunk) => {
output += chunk.toString();
console.log('Exec output:', chunk.toString());
});
stream.on('end', async () => {
await close();
console.log('Exec completed. Full output:', output);
done.resolve();
});
stream.on('error', async (error) => {
console.error('Exec error:', error);
await close();
done.resolve();
});
await done.promise;
expect(output.length).toBeGreaterThan(0);
});
tap.test('should attach to container', async (tools) => {
const done = tools.defer();
const { stream, close } = await testContainer.attach({
stream: true,
stdout: true,
stderr: true,
stdin: false,
});
let receivedData = false;
stream.on('data', (chunk) => {
console.log('Attach received:', chunk.toString().slice(0, 100));
receivedData = true;
});
stream.on('error', async (error) => {
console.error('Attach error:', error);
await close();
done.resolve();
});
// Monitor for 2 seconds then detach
await tools.delayFor(2000);
await close();
done.resolve();
await done.promise;
console.log('Attach test completed. Received data:', receivedData);
});
tap.test('should get container stats', async () => {
const stats = await testContainer.stats({ stream: false });
expect(stats).toBeInstanceOf(Object);
console.log('Container stats keys:', Object.keys(stats));
});
tap.test('should inspect container', async () => {
const inspection = await testContainer.inspect();
expect(inspection).toBeInstanceOf(Object);
expect(inspection.Id).toEqual(testContainer.Id);
console.log('Container state:', inspection.State?.Status);
});
tap.test('should complete container tests', async () => {
// Using existing container, no cleanup needed
console.log('Container streaming tests completed');
});
// NEW FEATURES TESTS (v5.1.0)
// Test 1: Network creation with custom driver and IPAM
tap.test('should create bridge network with custom IPAM config', async () => {
const network = await testDockerHost.createNetwork({
Name: 'test-bridge-network',
Driver: 'bridge',
IPAM: {
Config: [{
Subnet: '172.20.0.0/16',
Gateway: '172.20.0.1',
}]
},
Labels: { testLabel: 'v5.1.0' },
});
expect(network).toBeInstanceOf(docker.DockerNetwork);
expect(network.Name).toEqual('test-bridge-network');
expect(network.Driver).toEqual('bridge');
console.log('Created bridge network:', network.Name, 'with driver:', network.Driver);
await network.remove();
});
// Test 2: getVersion() returns proper Docker daemon info
tap.test('should get Docker daemon version information', async () => {
const version = await testDockerHost.getVersion();
expect(version).toBeInstanceOf(Object);
expect(typeof version.Version).toEqual('string');
expect(typeof version.ApiVersion).toEqual('string');
expect(typeof version.Os).toEqual('string');
expect(typeof version.Arch).toEqual('string');
console.log('Docker version:', version.Version, 'API version:', version.ApiVersion);
});
// Test 3: pruneImages() functionality
tap.test('should prune dangling images', async () => {
const result = await testDockerHost.pruneImages({ dangling: true });
expect(result).toBeInstanceOf(Object);
expect(result).toHaveProperty('ImagesDeleted');
expect(result).toHaveProperty('SpaceReclaimed');
expect(Array.isArray(result.ImagesDeleted)).toEqual(true);
expect(typeof result.SpaceReclaimed).toEqual('number');
console.log('Pruned images. Space reclaimed:', result.SpaceReclaimed, 'bytes');
});
// Test 4: exec() inspect() returns exit codes
tap.test('should get exit code from exec command', async (tools) => {
const done = tools.defer();
// Execute a successful command (exit code 0)
const { stream, close, inspect } = await testContainer.exec('echo "test successful"', {
tty: false,
attachStdout: true,
attachStderr: true,
});
let resolved = false;
const resolve = async () => {
if (resolved) return;
resolved = true;
// Give Docker a moment to finalize the exec state
await tools.delayFor(500);
const info = await inspect();
expect(info).toBeInstanceOf(Object);
expect(typeof info.ExitCode).toEqual('number');
expect(info.ExitCode).toEqual(0); // Success
expect(typeof info.Running).toEqual('boolean');
expect(info.Running).toEqual(false); // Should be done
expect(typeof info.ContainerID).toEqual('string');
console.log('Exec inspect - ExitCode:', info.ExitCode, 'Running:', info.Running);
await close();
done.resolve();
};
stream.on('end', resolve);
stream.on('error', async (error) => {
if (resolved) return;
resolved = true;
console.error('Exec error:', error);
await close();
done.resolve();
});
// Safety timeout to prevent hanging
setTimeout(async () => {
if (!resolved) {
resolved = true;
console.log('Exec test timed out, checking inspect...');
try {
const info = await inspect();
console.log('Exec inspect (timeout) - ExitCode:', info.ExitCode, 'Running:', info.Running);
expect(typeof info.ExitCode).toEqual('number');
} catch (e) {
console.error('Inspect after timeout failed:', e);
}
await close();
done.resolve();
}
}, 5000);
await done.promise;
});
tap.test('should get non-zero exit code from failed exec command', async (tools) => {
const done = tools.defer();
// Execute a command that fails (exit code 1)
const { stream, close, inspect } = await testContainer.exec('sh -c "exit 1"', {
tty: false,
attachStdout: true,
attachStderr: true,
});
let resolved = false;
const resolve = async () => {
if (resolved) return;
resolved = true;
// Give Docker a moment to finalize the exec state
await tools.delayFor(500);
const info = await inspect();
expect(info.ExitCode).toEqual(1); // Failure
expect(info.Running).toEqual(false);
console.log('Exec inspect (failed command) - ExitCode:', info.ExitCode);
await close();
done.resolve();
};
stream.on('end', resolve);
stream.on('error', async (error) => {
if (resolved) return;
resolved = true;
console.error('Exec error:', error);
await close();
done.resolve();
});
// Safety timeout to prevent hanging
setTimeout(async () => {
if (!resolved) {
resolved = true;
console.log('Exec failed-command test timed out, checking inspect...');
try {
const info = await inspect();
console.log('Exec inspect (timeout) - ExitCode:', info.ExitCode);
expect(typeof info.ExitCode).toEqual('number');
} catch (e) {
console.error('Inspect after timeout failed:', e);
}
await close();
done.resolve();
}
}, 5000);
await done.promise;
});
tap.test('cleanup', async () => {
await testDockerHost.stop();
// Force exit after a short delay to clean up lingering HTTP connections
// (Deno's node:http compat layer may keep Docker socket connections open)
setTimeout(() => process.exit(0), 500);
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@apiclient.xyz/docker',
version: '2.0.0',
version: '5.1.2',
description: 'Provides easy communication with Docker remote API from Node.js, with TypeScript support.'
}

27
ts/classes.base.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { DockerHost } from './classes.host.js';
/**
* Abstract base class for all Docker resources.
* Provides standardized patterns for resource management and lifecycle.
*/
export abstract class DockerResource {
/**
* Reference to the DockerHost that manages this resource.
* All API operations go through this host instance.
*/
protected readonly dockerHost: DockerHost;
/**
* Creates a new Docker resource instance.
* @param dockerHost The DockerHost instance that manages this resource
*/
constructor(dockerHost: DockerHost) {
this.dockerHost = dockerHost;
}
/**
* Refreshes this resource's state from the Docker daemon.
* Implementations should fetch current data and update instance properties.
*/
abstract refresh(): Promise<void>;
}

View File

@@ -2,21 +2,23 @@ import * as plugins from './plugins.js';
import * as interfaces from './interfaces/index.js';
import { DockerHost } from './classes.host.js';
import { DockerResource } from './classes.base.js';
import { logger } from './logger.js';
export class DockerContainer {
// STATIC
export class DockerContainer extends DockerResource {
// STATIC (Internal - prefixed with _ to indicate internal use)
/**
* get all containers
* Internal: Get all containers
* Public API: Use dockerHost.listContainers() instead
*/
public static async getContainers(
public static async _list(
dockerHostArg: DockerHost,
): Promise<DockerContainer[]> {
const result: DockerContainer[] = [];
const response = await dockerHostArg.request('GET', '/containers/json');
// TODO: Think about getting the config by inpsecting the container
// TODO: Think about getting the config by inspecting the container
for (const containerResult of response.body) {
result.push(new DockerContainer(dockerHostArg, containerResult));
}
@@ -24,58 +26,66 @@ export class DockerContainer {
}
/**
* gets an container by Id
* @param containerId
* Internal: Get a container by ID
* Public API: Use dockerHost.getContainerById(id) instead
* Returns undefined if container does not exist
*/
public static async getContainerById(containerId: string) {
// TODO: implement get container by id
public static async _fromId(
dockerHostArg: DockerHost,
containerId: string,
): Promise<DockerContainer | undefined> {
const containers = await this._list(dockerHostArg);
return containers.find((container) => container.Id === containerId);
}
/**
* create a container
* Internal: Create a container
* Public API: Use dockerHost.createContainer(descriptor) instead
*/
public static async create(
public static async _create(
dockerHost: DockerHost,
containerCreationDescriptor: interfaces.IContainerCreationDescriptor,
) {
// check for unique hostname
const existingContainers = await DockerContainer.getContainers(dockerHost);
): Promise<DockerContainer> {
// Check for unique hostname
const existingContainers = await DockerContainer._list(dockerHost);
const sameHostNameContainer = existingContainers.find((container) => {
// TODO implement HostName Detection;
return false;
});
const response = await dockerHost.request('POST', '/containers/create', {
Hostname: containerCreationDescriptor.Hostname,
Domainname: containerCreationDescriptor.Domainname,
User: 'root',
});
if (response.statusCode < 300) {
logger.log('info', 'Container created successfully');
// Return the created container instance
const container = await DockerContainer._fromId(dockerHost, response.body.Id);
if (!container) {
throw new Error('Container was created but could not be retrieved');
}
return container;
} else {
logger.log(
'error',
'There has been a problem when creating the container',
);
logger.log('error', 'There has been a problem when creating the container');
throw new Error(`Failed to create container: ${response.statusCode}`);
}
}
// INSTANCE
// references
public dockerHost: DockerHost;
// properties
public Id: string;
public Names: string[];
public Image: string;
public ImageID: string;
public Command: string;
public Created: number;
public Ports: interfaces.TPorts;
public Labels: interfaces.TLabels;
public State: string;
public Status: string;
// INSTANCE PROPERTIES
public Id!: string;
public Names!: string[];
public Image!: string;
public ImageID!: string;
public Command!: string;
public Created!: number;
public Ports!: interfaces.TPorts;
public Labels!: interfaces.TLabels;
public State!: string;
public Status!: string;
public HostConfig: any;
public NetworkSettings: {
public NetworkSettings!: {
Networks: {
[key: string]: {
IPAMConfig: any;
@@ -95,10 +105,301 @@ export class DockerContainer {
};
};
public Mounts: any;
constructor(dockerHostArg: DockerHost, dockerContainerObjectArg: any) {
this.dockerHost = dockerHostArg;
super(dockerHostArg);
Object.keys(dockerContainerObjectArg).forEach((keyArg) => {
this[keyArg] = dockerContainerObjectArg[keyArg];
});
}
// INSTANCE METHODS
/**
* Refreshes this container's state from the Docker daemon
*/
public async refresh(): Promise<void> {
const updated = await DockerContainer._fromId(this.dockerHost, this.Id);
Object.assign(this, updated);
}
/**
* Inspects the container and returns detailed information
*/
public async inspect(): Promise<any> {
const response = await this.dockerHost.request('GET', `/containers/${this.Id}/json`);
// Update instance with fresh data
Object.assign(this, response.body);
return response.body;
}
/**
* Starts the container
*/
public async start(): Promise<void> {
const response = await this.dockerHost.request('POST', `/containers/${this.Id}/start`);
if (response.statusCode >= 300) {
throw new Error(`Failed to start container: ${response.statusCode}`);
}
await this.refresh();
}
/**
* Stops the container
* @param options Options for stopping (e.g., timeout in seconds)
*/
public async stop(options?: { t?: number }): Promise<void> {
const queryParams = options?.t ? `?t=${options.t}` : '';
const response = await this.dockerHost.request('POST', `/containers/${this.Id}/stop${queryParams}`);
if (response.statusCode >= 300) {
throw new Error(`Failed to stop container: ${response.statusCode}`);
}
await this.refresh();
}
/**
* Removes the container
* @param options Options for removal (force, remove volumes, remove link)
*/
public async remove(options?: { force?: boolean; v?: boolean; link?: boolean }): Promise<void> {
const queryParams = new URLSearchParams();
if (options?.force) queryParams.append('force', '1');
if (options?.v) queryParams.append('v', '1');
if (options?.link) queryParams.append('link', '1');
const queryString = queryParams.toString();
const response = await this.dockerHost.request(
'DELETE',
`/containers/${this.Id}${queryString ? '?' + queryString : ''}`,
);
if (response.statusCode >= 300) {
throw new Error(`Failed to remove container: ${response.statusCode}`);
}
}
/**
* Gets container logs
* @param options Log options (stdout, stderr, timestamps, tail, since, follow)
*/
public async logs(options?: {
stdout?: boolean;
stderr?: boolean;
timestamps?: boolean;
tail?: number | 'all';
since?: number;
follow?: boolean;
}): Promise<string> {
const queryParams = new URLSearchParams();
queryParams.append('stdout', options?.stdout !== false ? '1' : '0');
queryParams.append('stderr', options?.stderr !== false ? '1' : '0');
if (options?.timestamps) queryParams.append('timestamps', '1');
if (options?.tail) queryParams.append('tail', options.tail.toString());
if (options?.since) queryParams.append('since', options.since.toString());
if (options?.follow) queryParams.append('follow', '1');
const response = await this.dockerHost.request('GET', `/containers/${this.Id}/logs?${queryParams.toString()}`);
// Docker returns logs with a special format (8 bytes header + payload)
// For simplicity, we'll return the raw body as string
return response.body.toString();
}
/**
* Gets container stats
* @param options Stats options (stream for continuous stats)
*/
public async stats(options?: { stream?: boolean }): Promise<any> {
const queryParams = new URLSearchParams();
queryParams.append('stream', options?.stream ? '1' : '0');
const response = await this.dockerHost.request('GET', `/containers/${this.Id}/stats?${queryParams.toString()}`);
return response.body;
}
/**
* Streams container logs continuously (follow mode)
* Returns a readable stream that emits log data as it's produced
* @param options Log streaming options
*/
public async streamLogs(options?: {
stdout?: boolean;
stderr?: boolean;
timestamps?: boolean;
tail?: number | 'all';
since?: number;
}): Promise<plugins.smartstream.stream.Readable> {
const queryParams = new URLSearchParams();
queryParams.append('stdout', options?.stdout !== false ? '1' : '0');
queryParams.append('stderr', options?.stderr !== false ? '1' : '0');
queryParams.append('follow', '1'); // Always follow for streaming
if (options?.timestamps) queryParams.append('timestamps', '1');
if (options?.tail) queryParams.append('tail', options.tail.toString());
if (options?.since) queryParams.append('since', options.since.toString());
const response = await this.dockerHost.requestStreaming(
'GET',
`/containers/${this.Id}/logs?${queryParams.toString()}`
);
// requestStreaming returns Node.js stream
return response as plugins.smartstream.stream.Readable;
}
/**
* Attaches to the container's main process (PID 1)
* Returns a duplex stream for bidirectional communication
* @param options Attach options
*/
public async attach(options?: {
stream?: boolean;
stdin?: boolean;
stdout?: boolean;
stderr?: boolean;
logs?: boolean;
}): Promise<{
stream: plugins.smartstream.stream.Duplex;
close: () => Promise<void>;
}> {
const queryParams = new URLSearchParams();
queryParams.append('stream', options?.stream !== false ? '1' : '0');
queryParams.append('stdin', options?.stdin ? '1' : '0');
queryParams.append('stdout', options?.stdout !== false ? '1' : '0');
queryParams.append('stderr', options?.stderr !== false ? '1' : '0');
if (options?.logs) queryParams.append('logs', '1');
const response = await this.dockerHost.requestStreaming(
'POST',
`/containers/${this.Id}/attach?${queryParams.toString()}`
);
// Create a duplex stream for bidirectional communication
const nodeStream = response as plugins.smartstream.stream.Readable;
// Convert to duplex by wrapping in SmartDuplex
const duplexStream = new plugins.smartstream.SmartDuplex({
writeFunction: async (chunk) => {
// Write data is sent to the container's stdin
return chunk;
},
readableObjectMode: false,
writableObjectMode: false,
});
// Pipe container output to our duplex readable side
nodeStream.on('data', (chunk) => {
duplexStream.push(chunk);
});
nodeStream.on('end', () => {
duplexStream.push(null); // Signal end of stream
});
nodeStream.on('error', (error) => {
duplexStream.destroy(error);
});
// Helper function to close the attachment
const close = async () => {
duplexStream.end();
if (nodeStream.destroy) {
nodeStream.destroy();
}
};
return {
stream: duplexStream,
close,
};
}
/**
* Executes a command in the container
* Returns a duplex stream for command interaction
* @param command Command to execute (string or array of strings)
* @param options Exec options
*/
public async exec(
command: string | string[],
options?: {
tty?: boolean;
attachStdin?: boolean;
attachStdout?: boolean;
attachStderr?: boolean;
env?: string[];
workingDir?: string;
user?: string;
}
): Promise<{
stream: plugins.smartstream.stream.Duplex;
close: () => Promise<void>;
inspect: () => Promise<interfaces.IExecInspectInfo>;
}> {
// Step 1: Create exec instance
const createResponse = await this.dockerHost.request('POST', `/containers/${this.Id}/exec`, {
Cmd: typeof command === 'string' ? ['/bin/sh', '-c', command] : command,
AttachStdin: options?.attachStdin !== false,
AttachStdout: options?.attachStdout !== false,
AttachStderr: options?.attachStderr !== false,
Tty: options?.tty || false,
Env: options?.env || [],
WorkingDir: options?.workingDir,
User: options?.user,
});
const execId = createResponse.body.Id;
// Step 2: Start exec instance with streaming response
const startResponse = await this.dockerHost.requestStreaming(
'POST',
`/exec/${execId}/start`,
undefined, // no stream input
{
Detach: false,
Tty: options?.tty || false,
}
);
const nodeStream = startResponse as plugins.smartstream.stream.Readable;
// Create duplex stream for bidirectional communication
const duplexStream = new plugins.smartstream.SmartDuplex({
writeFunction: async (chunk) => {
return chunk;
},
readableObjectMode: false,
writableObjectMode: false,
});
// Pipe exec output to duplex readable side
nodeStream.on('data', (chunk) => {
duplexStream.push(chunk);
});
nodeStream.on('end', () => {
duplexStream.push(null);
});
nodeStream.on('error', (error) => {
duplexStream.destroy(error);
});
const close = async () => {
duplexStream.end();
if (nodeStream.destroy) {
nodeStream.destroy();
}
};
const inspect = async (): Promise<interfaces.IExecInspectInfo> => {
const inspectResponse = await this.dockerHost.request('GET', `/exec/${execId}/json`);
return inspectResponse.body;
};
return {
stream: duplexStream,
close,
inspect,
};
}
}

View File

@@ -1,8 +1,10 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import * as interfaces from './interfaces/index.js';
import { DockerContainer } from './classes.container.js';
import { DockerNetwork } from './classes.network.js';
import { DockerService } from './classes.service.js';
import { DockerSecret } from './classes.secret.js';
import { logger } from './logger.js';
import { DockerImageStore } from './classes.imagestore.js';
import { DockerImage } from './classes.image.js';
@@ -26,8 +28,8 @@ export class DockerHost {
*/
public socketPath: string;
private registryToken: string = '';
public imageStore: DockerImageStore;
public smartBucket: plugins.smartbucket.SmartBucket;
private imageStore: DockerImageStore; // Now private - use storeImage/retrieveImage instead
public smartBucket!: plugins.smartbucket.SmartBucket;
/**
* the constructor to instantiate a new docker sock instance
@@ -62,8 +64,8 @@ export class DockerHost {
console.log(`using docker sock at ${pathToUse}`);
this.socketPath = pathToUse;
this.imageStore = new DockerImageStore({
bucketDir: null,
localDirPath: this.options.imageStoreDir,
bucketDir: null!,
localDirPath: this.options.imageStoreDir!,
});
}
@@ -72,6 +74,40 @@ export class DockerHost {
}
public async stop() {
await this.imageStore.stop();
if (this.smartBucket) {
this.smartBucket.storageClient.destroy();
}
}
/**
* Ping the Docker daemon to check if it's running and accessible
* @returns Promise that resolves if Docker is available, rejects otherwise
* @throws Error if Docker ping fails
*/
public async ping(): Promise<void> {
const response = await this.request('GET', '/_ping');
if (response.statusCode !== 200) {
throw new Error(`Docker ping failed with status ${response.statusCode}`);
}
}
/**
* Get Docker daemon version information
* @returns Version info including Docker version, API version, OS, architecture, etc.
*/
public async getVersion(): Promise<{
Version: string;
ApiVersion: string;
MinAPIVersion?: string;
GitCommit: string;
GoVersion: string;
Os: string;
Arch: string;
KernelVersion: string;
BuildTime?: string;
}> {
const response = await this.request('GET', '/version');
return response.body;
}
/**
@@ -98,7 +134,7 @@ export class DockerHost {
const dockerConfigPath = plugins.smartpath.get.home(
'~/.docker/config.json',
);
const configObject = plugins.smartfile.fs.toObjectSync(dockerConfigPath);
const configObject = JSON.parse(plugins.fs.readFileSync(dockerConfigPath, 'utf8'));
const gitlabAuthBase64 = configObject.auths[registryUrlArg].auth;
const gitlabAuth: string =
plugins.smartstring.base64.decode(gitlabAuthBase64);
@@ -111,70 +147,220 @@ export class DockerHost {
}
// ==============
// NETWORKS
// NETWORKS - Public Factory API
// ==============
/**
* gets all networks
* Lists all networks
*/
public async getNetworks() {
return await DockerNetwork.getNetworks(this);
public async listNetworks() {
return await DockerNetwork._list(this);
}
/**
* create a network
*/
public async createNetwork(
optionsArg: Parameters<typeof DockerNetwork.createNetwork>[1],
) {
return await DockerNetwork.createNetwork(this, optionsArg);
}
/**
* get a network by name
* Gets a network by name
*/
public async getNetworkByName(networkNameArg: string) {
return await DockerNetwork.getNetworkByName(this, networkNameArg);
return await DockerNetwork._fromName(this, networkNameArg);
}
// ==============
// CONTAINERS
// ==============
/**
* gets all containers
* Creates a network
*/
public async getContainers() {
const containerArray = await DockerContainer.getContainers(this);
return containerArray;
public async createNetwork(
descriptor: interfaces.INetworkCreationDescriptor,
) {
return await DockerNetwork._create(this, descriptor);
}
// ==============
// SERVICES
// CONTAINERS - Public Factory API
// ==============
/**
* gets all services
* Lists all containers
*/
public async getServices() {
const serviceArray = await DockerService.getServices(this);
return serviceArray;
public async listContainers() {
return await DockerContainer._list(this);
}
// ==============
// IMAGES
// ==============
/**
* get all images
* Gets a container by ID
* Returns undefined if container does not exist
*/
public async getImages() {
return await DockerImage.getImages(this);
public async getContainerById(containerId: string): Promise<DockerContainer | undefined> {
return await DockerContainer._fromId(this, containerId);
}
/**
* get an image by name
* Creates a container
*/
public async createContainer(
descriptor: interfaces.IContainerCreationDescriptor,
) {
return await DockerContainer._create(this, descriptor);
}
// ==============
// SERVICES - Public Factory API
// ==============
/**
* Lists all services
*/
public async listServices() {
return await DockerService._list(this);
}
/**
* Gets a service by name
*/
public async getServiceByName(serviceName: string) {
return await DockerService._fromName(this, serviceName);
}
/**
* Creates a service
*/
public async createService(
descriptor: interfaces.IServiceCreationDescriptor,
) {
return await DockerService._create(this, descriptor);
}
// ==============
// IMAGES - Public Factory API
// ==============
/**
* Lists all images
*/
public async listImages() {
return await DockerImage._list(this);
}
/**
* Gets an image by name
*/
public async getImageByName(imageNameArg: string) {
return await DockerImage.getImageByName(this, imageNameArg);
return await DockerImage._fromName(this, imageNameArg);
}
/**
* Creates an image from a registry
*/
public async createImageFromRegistry(
descriptor: interfaces.IImageCreationDescriptor,
) {
return await DockerImage._createFromRegistry(this, {
creationObject: descriptor,
});
}
/**
* Creates an image from a tar stream
*/
public async createImageFromTarStream(
tarStream: plugins.smartstream.stream.Readable,
descriptor: interfaces.IImageCreationDescriptor,
) {
return await DockerImage._createFromTarStream(this, {
creationObject: descriptor,
tarStream: tarStream,
});
}
/**
* Prune unused images
* @param options Optional filters (dangling, until, label)
* @returns Object with deleted images and space reclaimed
*/
public async pruneImages(options?: {
dangling?: boolean;
filters?: Record<string, string[]>;
}): Promise<{
ImagesDeleted: Array<{ Untagged?: string; Deleted?: string }>;
SpaceReclaimed: number;
}> {
const filters: Record<string, string[]> = options?.filters || {};
// Add dangling filter if specified
if (options?.dangling !== undefined) {
filters.dangling = [options.dangling.toString()];
}
let route = '/images/prune';
if (filters && Object.keys(filters).length > 0) {
route += `?filters=${encodeURIComponent(JSON.stringify(filters))}`;
}
const response = await this.request('POST', route);
return response.body;
}
/**
* Builds an image from a Dockerfile
*/
public async buildImage(imageTag: string) {
return await DockerImage._build(this, imageTag);
}
// ==============
// SECRETS - Public Factory API
// ==============
/**
* Lists all secrets
*/
public async listSecrets() {
return await DockerSecret._list(this);
}
/**
* Gets a secret by name
*/
public async getSecretByName(secretName: string) {
return await DockerSecret._fromName(this, secretName);
}
/**
* Gets a secret by ID
*/
public async getSecretById(secretId: string) {
return await DockerSecret._fromId(this, secretId);
}
/**
* Creates a secret
*/
public async createSecret(
descriptor: interfaces.ISecretCreationDescriptor,
) {
return await DockerSecret._create(this, descriptor);
}
// ==============
// IMAGE STORE - Public API
// ==============
/**
* Stores an image in the local image store
*/
public async storeImage(
imageName: string,
tarStream: plugins.smartstream.stream.Readable,
): Promise<void> {
return await this.imageStore.storeImage(imageName, tarStream);
}
/**
* Retrieves an image from the local image store
*/
public async retrieveImage(
imageName: string,
): Promise<plugins.smartstream.stream.Readable> {
return await this.imageStore.getImage(imageName);
}
/**
@@ -196,8 +382,14 @@ export class DockerHost {
console.log(e);
}
});
nodeStream.on('error', (err) => {
// Connection resets are expected when the stream is destroyed
if ((err as any).code !== 'ECONNRESET') {
observer.error(err);
}
});
return () => {
nodeStream.emit('end');
nodeStream.destroy();
};
});
}
@@ -207,14 +399,19 @@ export class DockerHost {
*/
public async activateSwarm(addvertisementIpArg?: string) {
// determine advertisement address
let addvertisementIp: string;
let addvertisementIp: string = '';
if (addvertisementIpArg) {
addvertisementIp = addvertisementIpArg;
} else {
const smartnetworkInstance = new plugins.smartnetwork.SmartNetwork();
const defaultGateway = await smartnetworkInstance.getDefaultGateway();
if (defaultGateway) {
addvertisementIp = defaultGateway.ipv4.address;
try {
const smartnetworkInstance = new plugins.smartnetwork.SmartNetwork();
const defaultGateway = await smartnetworkInstance.getDefaultGateway();
if (defaultGateway) {
addvertisementIp = defaultGateway.ipv4.address;
}
} catch (err) {
// Failed to determine default gateway (e.g. in Deno without --allow-run)
// Docker will auto-detect the advertise address
}
}
@@ -318,7 +515,8 @@ export class DockerHost {
methodArg: string,
routeArg: string,
readStream?: plugins.smartstream.stream.Readable,
) {
jsonData?: any,
): Promise<plugins.smartstream.stream.Readable | { statusCode: number; body: string; headers: any }> {
const requestUrl = `${this.socketPath}${routeArg}`;
// Build the request using the fluent API
@@ -330,6 +528,11 @@ export class DockerHost {
.timeout(30000)
.options({ keepAlive: false, autoDrain: true }); // Disable auto-drain for streaming
// If we have JSON data, add it to the request
if (jsonData && Object.keys(jsonData).length > 0) {
smartRequest.json(jsonData);
}
// If we have a readStream, use the new stream method with logging
if (readStream) {
let counter = 0;
@@ -390,6 +593,10 @@ export class DockerHost {
// Convert web ReadableStream to Node.js stream for backward compatibility
const nodeStream = plugins.smartstream.nodewebhelpers.convertWebReadableToNodeReadable(webStream);
// Add a default error handler to prevent unhandled 'error' events from crashing the process.
// Callers that attach their own 'error' listener will still receive the event.
nodeStream.on('error', () => {});
// Add properties for compatibility
(nodeStream as any).statusCode = response.status;
(nodeStream as any).body = ''; // For compatibility

View File

@@ -1,14 +1,20 @@
import * as plugins from './plugins.js';
import * as interfaces from './interfaces/index.js';
import { DockerHost } from './classes.host.js';
import { DockerResource } from './classes.base.js';
import { logger } from './logger.js';
/**
* represents a docker image on the remote docker host
*/
export class DockerImage {
// STATIC
public static async getImages(dockerHost: DockerHost) {
export class DockerImage extends DockerResource {
// STATIC (Internal - prefixed with _ to indicate internal use)
/**
* Internal: Get all images
* Public API: Use dockerHost.listImages() instead
*/
public static async _list(dockerHost: DockerHost) {
const images: DockerImage[] = [];
const response = await dockerHost.request('GET', '/images/json');
for (const imageObject of response.body) {
@@ -17,11 +23,15 @@ export class DockerImage {
return images;
}
public static async getImageByName(
/**
* Internal: Get image by name
* Public API: Use dockerHost.getImageByName(name) instead
*/
public static async _fromName(
dockerHost: DockerHost,
imageNameArg: string,
) {
const images = await this.getImages(dockerHost);
const images = await this._list(dockerHost);
const result = images.find((image) => {
if (image.RepoTags) {
return image.RepoTags.includes(imageNameArg);
@@ -32,7 +42,11 @@ export class DockerImage {
return result;
}
public static async createFromRegistry(
/**
* Internal: Create image from registry
* Public API: Use dockerHost.createImageFromRegistry(descriptor) instead
*/
public static async _createFromRegistry(
dockerHostArg: DockerHost,
optionsArg: {
creationObject: interfaces.IImageCreationDescriptor;
@@ -45,8 +59,8 @@ export class DockerImage {
imageOriginTag: string;
} = {
imageUrl: optionsArg.creationObject.imageUrl,
imageTag: optionsArg.creationObject.imageTag,
imageOriginTag: null,
imageTag: optionsArg.creationObject.imageTag ?? '',
imageOriginTag: '',
};
if (imageUrlObject.imageUrl.includes(':')) {
const imageUrl = imageUrlObject.imageUrl.split(':')[0];
@@ -76,22 +90,36 @@ export class DockerImage {
'info',
`Successfully pulled image ${imageUrlObject.imageUrl} from the registry`,
);
const image = await DockerImage.getImageByName(
const image = await DockerImage._fromName(
dockerHostArg,
imageUrlObject.imageOriginTag,
);
if (!image) {
throw new Error(`Image ${imageUrlObject.imageOriginTag} not found after pull`);
}
return image;
} else {
logger.log('error', `Failed at the attempt of creating a new image`);
// Pull failed — check if the image already exists locally
const existingImage = await DockerImage._fromName(
dockerHostArg,
imageUrlObject.imageOriginTag,
);
if (existingImage) {
logger.log(
'warn',
`Pull failed for ${imageUrlObject.imageUrl}, using locally cached image`,
);
return existingImage;
}
throw new Error(`Failed to pull image ${imageUrlObject.imageOriginTag} and no local copy exists`);
}
}
/**
*
* @param dockerHostArg
* @param tarStreamArg
* Internal: Create image from tar stream
* Public API: Use dockerHost.createImageFromTarStream(stream, descriptor) instead
*/
public static async createFromTarStream(
public static async _createFromTarStream(
dockerHostArg: DockerHost,
optionsArg: {
creationObject: interfaces.IImageCreationDescriptor;
@@ -161,11 +189,11 @@ export class DockerImage {
}
// Now try to look up that image by the "loadedImageTag".
// Depending on Dockers response, it might be something like:
// Depending on Docker's response, it might be something like:
// "myrepo/myimage:latest" OR "sha256:someHash..."
// If Docker gave you an ID (e.g. "sha256:..."), you may need a separate
// DockerImage.getImageById method; or if you prefer, you can treat it as a name.
const newlyImportedImage = await DockerImage.getImageByName(
const newlyImportedImage = await DockerImage._fromName(
dockerHostArg,
loadedImageTag,
);
@@ -192,36 +220,51 @@ export class DockerImage {
);
}
public static async buildImage(dockerHostArg: DockerHost, dockerImageTag) {
/**
* Internal: Build image from Dockerfile
* Public API: Use dockerHost.buildImage(tag) instead
*/
public static async _build(dockerHostArg: DockerHost, dockerImageTag) {
// TODO: implement building an image
}
// INSTANCE
// references
public dockerHost: DockerHost;
// properties
// INSTANCE PROPERTIES
/**
* the tags for an image
*/
public Containers: number;
public Created: number;
public Id: string;
public Labels: interfaces.TLabels;
public ParentId: string;
public RepoDigests: string[];
public RepoTags: string[];
public SharedSize: number;
public Size: number;
public VirtualSize: number;
public Containers!: number;
public Created!: number;
public Id!: string;
public Labels!: interfaces.TLabels;
public ParentId!: string;
public RepoDigests!: string[];
public RepoTags!: string[];
public SharedSize!: number;
public Size!: number;
public VirtualSize!: number;
constructor(dockerHostArg, dockerImageObjectArg: any) {
this.dockerHost = dockerHostArg;
constructor(dockerHostArg: DockerHost, dockerImageObjectArg: any) {
super(dockerHostArg);
Object.keys(dockerImageObjectArg).forEach((keyArg) => {
this[keyArg] = dockerImageObjectArg[keyArg];
});
}
// INSTANCE METHODS
/**
* Refreshes this image's state from the Docker daemon
*/
public async refresh(): Promise<void> {
if (!this.RepoTags || this.RepoTags.length === 0) {
throw new Error('Cannot refresh image without RepoTags');
}
const updated = await DockerImage._fromName(this.dockerHost, this.RepoTags[0]);
if (updated) {
Object.assign(this, updated);
}
}
/**
* tag an image
* @param newTag
@@ -234,7 +277,7 @@ export class DockerImage {
* pulls the latest version from the registry
*/
public async pullLatestImageFromRegistry(): Promise<boolean> {
const updatedImage = await DockerImage.createFromRegistry(this.dockerHost, {
const updatedImage = await DockerImage._createFromRegistry(this.dockerHost, {
creationObject: {
imageUrl: this.RepoTags[0],
},
@@ -244,6 +287,25 @@ export class DockerImage {
return true;
}
/**
* Removes this image from the Docker daemon
*/
public async remove(options?: { force?: boolean; noprune?: boolean }): Promise<void> {
const queryParams = new URLSearchParams();
if (options?.force) queryParams.append('force', '1');
if (options?.noprune) queryParams.append('noprune', '1');
const queryString = queryParams.toString();
const response = await this.dockerHost.request(
'DELETE',
`/images/${encodeURIComponent(this.Id)}${queryString ? '?' + queryString : ''}`,
);
if (response.statusCode >= 300) {
throw new Error(`Failed to remove image: ${response.statusCode}`);
}
}
// get stuff
public async getVersion() {
if (this.Labels && this.Labels.version) {

View File

@@ -3,6 +3,8 @@ import * as paths from './paths.js';
import { logger } from './logger.js';
import type { DockerHost } from './classes.host.js';
const smartfileFactory = plugins.smartfile.SmartFileFactory.nodeFs();
export interface IDockerImageStoreConstructorOptions {
/**
* used for preparing images for longer term storage
@@ -38,14 +40,12 @@ export class DockerImageStore {
uniqueProcessingId,
);
// Create a write stream to store the tar file
const writeStream = plugins.smartfile.fsStream.createWriteStream(
initialTarDownloadPath,
);
const writeStream = plugins.fs.createWriteStream(initialTarDownloadPath);
// lets wait for the write stream to finish
await new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
tarStream.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('finish', () => resolve());
writeStream.on('error', reject);
});
logger.log(
@@ -54,44 +54,55 @@ export class DockerImageStore {
);
// lets process the image
const tarArchive = await plugins.smartarchive.SmartArchive.fromArchiveFile(
initialTarDownloadPath,
);
await tarArchive.exportToFs(extractionDir);
await plugins.smartarchive.SmartArchive.create()
.file(initialTarDownloadPath)
.extract(extractionDir);
logger.log('info', `Image ${imageName} extracted.`);
await plugins.smartfile.fs.remove(initialTarDownloadPath);
await plugins.fs.promises.rm(initialTarDownloadPath, { force: true });
logger.log('info', `deleted original tar to save space.`);
logger.log('info', `now repackaging for s3...`);
const smartfileIndexJson = await plugins.smartfile.SmartFile.fromFilePath(
const smartfileIndexJson = await smartfileFactory.fromFilePath(
plugins.path.join(extractionDir, 'index.json'),
);
const smartfileManifestJson =
await plugins.smartfile.SmartFile.fromFilePath(
plugins.path.join(extractionDir, 'manifest.json'),
);
const smartfileOciLayoutJson =
await plugins.smartfile.SmartFile.fromFilePath(
plugins.path.join(extractionDir, 'oci-layout'),
);
const smartfileRepositoriesJson =
await plugins.smartfile.SmartFile.fromFilePath(
plugins.path.join(extractionDir, 'repositories'),
);
const smartfileManifestJson = await smartfileFactory.fromFilePath(
plugins.path.join(extractionDir, 'manifest.json'),
);
const smartfileOciLayoutJson = await smartfileFactory.fromFilePath(
plugins.path.join(extractionDir, 'oci-layout'),
);
// repositories file is optional in OCI image tars
const repositoriesPath = plugins.path.join(extractionDir, 'repositories');
const hasRepositories = plugins.fs.existsSync(repositoriesPath);
const smartfileRepositoriesJson = hasRepositories
? await smartfileFactory.fromFilePath(repositoriesPath)
: null;
const indexJson = JSON.parse(smartfileIndexJson.contents.toString());
const manifestJson = JSON.parse(smartfileManifestJson.contents.toString());
const ociLayoutJson = JSON.parse(
smartfileOciLayoutJson.contents.toString(),
);
const repositoriesJson = JSON.parse(
smartfileRepositoriesJson.contents.toString(),
);
indexJson.manifests[0].annotations['io.containerd.image.name'] = imageName;
manifestJson[0].RepoTags[0] = imageName;
const repoFirstKey = Object.keys(repositoriesJson)[0];
const repoFirstValue = repositoriesJson[repoFirstKey];
repositoriesJson[imageName] = repoFirstValue;
delete repositoriesJson[repoFirstKey];
if (indexJson.manifests?.[0]?.annotations) {
indexJson.manifests[0].annotations['io.containerd.image.name'] = imageName;
}
if (manifestJson?.[0]?.RepoTags) {
manifestJson[0].RepoTags[0] = imageName;
}
if (smartfileRepositoriesJson) {
const repositoriesJson = JSON.parse(
smartfileRepositoriesJson.contents.toString(),
);
const repoFirstKey = Object.keys(repositoriesJson)[0];
const repoFirstValue = repositoriesJson[repoFirstKey];
repositoriesJson[imageName] = repoFirstValue;
delete repositoriesJson[repoFirstKey];
smartfileRepositoriesJson.contents = Buffer.from(
JSON.stringify(repositoriesJson, null, 2),
);
}
smartfileIndexJson.contents = Buffer.from(
JSON.stringify(indexJson, null, 2),
@@ -102,45 +113,51 @@ export class DockerImageStore {
smartfileOciLayoutJson.contents = Buffer.from(
JSON.stringify(ociLayoutJson, null, 2),
);
smartfileRepositoriesJson.contents = Buffer.from(
JSON.stringify(repositoriesJson, null, 2),
);
await Promise.all([
const writePromises = [
smartfileIndexJson.write(),
smartfileManifestJson.write(),
smartfileOciLayoutJson.write(),
smartfileRepositoriesJson.write(),
]);
];
if (smartfileRepositoriesJson) {
writePromises.push(smartfileRepositoriesJson.write());
}
await Promise.all(writePromises);
logger.log('info', 'repackaging archive for s3...');
const tartools = new plugins.smartarchive.TarTools();
const newTarPack = await tartools.packDirectory(extractionDir);
const newTarPack = await tartools.getDirectoryPackStream(extractionDir);
const finalTarName = `${uniqueProcessingId}.processed.tar`;
const finalTarPath = plugins.path.join(
this.options.localDirPath,
finalTarName,
);
const finalWriteStream =
plugins.smartfile.fsStream.createWriteStream(finalTarPath);
await new Promise((resolve, reject) => {
newTarPack.finalize();
const finalWriteStream = plugins.fs.createWriteStream(finalTarPath);
await new Promise<void>((resolve, reject) => {
newTarPack.pipe(finalWriteStream);
finalWriteStream.on('finish', resolve);
finalWriteStream.on('finish', () => resolve());
finalWriteStream.on('error', reject);
});
logger.log('ok', `Repackaged image ${imageName} for s3.`);
await plugins.smartfile.fs.remove(extractionDir);
const finalTarReadStream =
plugins.smartfile.fsStream.createReadStream(finalTarPath);
await plugins.fs.promises.rm(extractionDir, { recursive: true, force: true });
// Remove existing file in bucket if it exists (smartbucket v4 no longer silently overwrites)
try {
await this.options.bucketDir.fastRemove({ path: `${imageName}.tar` });
} catch (e) {
// File may not exist, which is fine
}
const finalTarReadStream = plugins.fs.createReadStream(finalTarPath);
await this.options.bucketDir.fastPutStream({
stream: finalTarReadStream,
path: `${imageName}.tar`,
});
await plugins.smartfile.fs.remove(finalTarPath);
await plugins.fs.promises.rm(finalTarPath, { force: true });
}
public async start() {
await plugins.smartfile.fs.ensureEmptyDir(this.options.localDirPath);
// Ensure the local directory exists and is empty
await plugins.fs.promises.rm(this.options.localDirPath, { recursive: true, force: true });
await plugins.fs.promises.mkdir(this.options.localDirPath, { recursive: true });
}
public async stop() {}
@@ -154,10 +171,10 @@ export class DockerImageStore {
`${imageName}.tar`,
);
if (!(await plugins.smartfile.fs.fileExists(imagePath))) {
if (!plugins.fs.existsSync(imagePath)) {
throw new Error(`Image ${imageName} does not exist.`);
}
return plugins.smartfile.fsStream.createReadStream(imagePath);
return plugins.fs.createReadStream(imagePath);
}
}

View File

@@ -2,11 +2,18 @@ import * as plugins from './plugins.js';
import * as interfaces from './interfaces/index.js';
import { DockerHost } from './classes.host.js';
import { DockerResource } from './classes.base.js';
import { DockerService } from './classes.service.js';
import { logger } from './logger.js';
export class DockerNetwork {
public static async getNetworks(
export class DockerNetwork extends DockerResource {
// STATIC (Internal - prefixed with _ to indicate internal use)
/**
* Internal: Get all networks
* Public API: Use dockerHost.getNetworks() instead
*/
public static async _list(
dockerHost: DockerHost,
): Promise<DockerNetwork[]> {
const dockerNetworks: DockerNetwork[] = [];
@@ -19,69 +26,65 @@ export class DockerNetwork {
return dockerNetworks;
}
public static async getNetworkByName(
/**
* Internal: Get network by name
* Public API: Use dockerHost.getNetworkByName(name) instead
*/
public static async _fromName(
dockerHost: DockerHost,
dockerNetworkNameArg: string,
) {
const networks = await DockerNetwork.getNetworks(dockerHost);
const networks = await DockerNetwork._list(dockerHost);
return networks.find(
(dockerNetwork) => dockerNetwork.Name === dockerNetworkNameArg,
);
}
public static async createNetwork(
/**
* Internal: Create a network
* Public API: Use dockerHost.createNetwork(descriptor) instead
*/
public static async _create(
dockerHost: DockerHost,
networkCreationDescriptor: interfaces.INetworkCreationDescriptor,
): Promise<DockerNetwork> {
const response = await dockerHost.request('POST', '/networks/create', {
Name: networkCreationDescriptor.Name,
CheckDuplicate: true,
Driver: 'overlay',
EnableIPv6: false,
/* IPAM: {
Driver: 'default',
Config: [
{
Subnet: `172.20.${networkCreationDescriptor.NetworkNumber}.0/16`,
IPRange: `172.20.${networkCreationDescriptor.NetworkNumber}.0/24`,
Gateway: `172.20.${networkCreationDescriptor.NetworkNumber}.11`
}
]
}, */
Internal: false,
Attachable: true,
Driver: networkCreationDescriptor.Driver || 'overlay',
EnableIPv6: networkCreationDescriptor.EnableIPv6 || false,
IPAM: networkCreationDescriptor.IPAM,
Internal: networkCreationDescriptor.Internal || false,
Attachable: networkCreationDescriptor.Attachable !== undefined ? networkCreationDescriptor.Attachable : true,
Labels: networkCreationDescriptor.Labels,
Ingress: false,
});
if (response.statusCode < 300) {
logger.log('info', 'Created network successfully');
return await DockerNetwork.getNetworkByName(
const network = await DockerNetwork._fromName(
dockerHost,
networkCreationDescriptor.Name,
);
if (!network) {
throw new Error('Network was created but could not be retrieved');
}
return network;
} else {
logger.log(
'error',
'There has been an error creating the wanted network',
);
return null;
throw new Error('There has been an error creating the wanted network');
}
}
// INSTANCE
// references
public dockerHost: DockerHost;
// properties
public Name: string;
public Id: string;
public Created: string;
public Scope: string;
public Driver: string;
public EnableIPv6: boolean;
public Internal: boolean;
public Attachable: boolean;
public Ingress: false;
public IPAM: {
// INSTANCE PROPERTIES
public Name!: string;
public Id!: string;
public Created!: string;
public Scope!: string;
public Driver!: string;
public EnableIPv6!: boolean;
public Internal!: boolean;
public Attachable!: boolean;
public Ingress!: false;
public IPAM!: {
Driver: 'default' | 'bridge' | 'overlay';
Config: [
{
@@ -93,11 +96,23 @@ export class DockerNetwork {
};
constructor(dockerHostArg: DockerHost) {
this.dockerHost = dockerHostArg;
super(dockerHostArg);
}
// INSTANCE METHODS
/**
* Refreshes this network's state from the Docker daemon
*/
public async refresh(): Promise<void> {
const updated = await DockerNetwork._fromName(this.dockerHost, this.Name);
if (updated) {
Object.assign(this, updated);
}
}
/**
* removes the network
* Removes the network
*/
public async remove() {
const response = await this.dockerHost.request(
@@ -106,7 +121,7 @@ export class DockerNetwork {
);
}
public async getContainersOnNetwork(): Promise<
public async listContainersOnNetwork(): Promise<
Array<{
Name: string;
EndpointID: string;
@@ -115,7 +130,7 @@ export class DockerNetwork {
IPv6Address: string;
}>
> {
const returnArray = [];
const returnArray: any[] = [];
const response = await this.dockerHost.request(
'GET',
`/networks/${this.Id}`,
@@ -128,7 +143,7 @@ export class DockerNetwork {
}
public async getContainersOnNetworkForService(serviceArg: DockerService) {
const containersOnNetwork = await this.getContainersOnNetwork();
const containersOnNetwork = await this.listContainersOnNetwork();
const containersOfService = containersOnNetwork.filter((container) => {
return container.Name.startsWith(serviceArg.Spec.Name);
});

View File

@@ -1,12 +1,18 @@
import * as plugins from './plugins.js';
import { DockerHost } from './classes.host.js';
import { DockerResource } from './classes.base.js';
// interfaces
import * as interfaces from './interfaces/index.js';
export class DockerSecret {
// STATIC
public static async getSecrets(dockerHostArg: DockerHost) {
export class DockerSecret extends DockerResource {
// STATIC (Internal - prefixed with _ to indicate internal use)
/**
* Internal: Get all secrets
* Public API: Use dockerHost.listSecrets() instead
*/
public static async _list(dockerHostArg: DockerHost) {
const response = await dockerHostArg.request('GET', '/secrets');
const secrets: DockerSecret[] = [];
for (const secret of response.body) {
@@ -17,20 +23,32 @@ export class DockerSecret {
return secrets;
}
public static async getSecretByID(dockerHostArg: DockerHost, idArg: string) {
const secrets = await this.getSecrets(dockerHostArg);
/**
* Internal: Get secret by ID
* Public API: Use dockerHost.getSecretById(id) instead
*/
public static async _fromId(dockerHostArg: DockerHost, idArg: string) {
const secrets = await this._list(dockerHostArg);
return secrets.find((secret) => secret.ID === idArg);
}
public static async getSecretByName(
/**
* Internal: Get secret by name
* Public API: Use dockerHost.getSecretByName(name) instead
*/
public static async _fromName(
dockerHostArg: DockerHost,
nameArg: string,
) {
const secrets = await this.getSecrets(dockerHostArg);
const secrets = await this._list(dockerHostArg);
return secrets.find((secret) => secret.Spec.Name === nameArg);
}
public static async createSecret(
/**
* Internal: Create a secret
* Public API: Use dockerHost.createSecret(descriptor) instead
*/
public static async _create(
dockerHostArg: DockerHost,
secretDescriptor: interfaces.ISecretCreationDescriptor,
) {
@@ -48,31 +66,41 @@ export class DockerSecret {
Object.assign(newSecretInstance, response.body);
Object.assign(
newSecretInstance,
await DockerSecret.getSecretByID(dockerHostArg, newSecretInstance.ID),
await DockerSecret._fromId(dockerHostArg, newSecretInstance.ID),
);
return newSecretInstance;
}
// INSTANCE
public ID: string;
public Spec: {
// INSTANCE PROPERTIES
public ID!: string;
public Spec!: {
Name: string;
Labels: interfaces.TLabels;
};
public Version: {
public Version!: {
Index: string;
};
public dockerHost: DockerHost;
constructor(dockerHostArg: DockerHost) {
this.dockerHost = dockerHostArg;
super(dockerHostArg);
}
// INSTANCE METHODS
/**
* Refreshes this secret's state from the Docker daemon
*/
public async refresh(): Promise<void> {
const updated = await DockerSecret._fromId(this.dockerHost, this.ID);
if (updated) {
Object.assign(this, updated);
}
}
/**
* updates a secret
* Updates a secret
*/
public async update(contentArg: string) {
const route = `/secrets/${this.ID}/update?=version=${this.Version.Index}`;
const response = await this.dockerHost.request(
'POST',
`/secrets/${this.ID}/update?version=${this.Version.Index}`,
@@ -84,11 +112,16 @@ export class DockerSecret {
);
}
/**
* Removes this secret from the Docker daemon
*/
public async remove() {
await this.dockerHost.request('DELETE', `/secrets/${this.ID}`);
}
// get things
/**
* Gets the version label of this secret
*/
public async getVersion() {
return this.Spec.Labels.version;
}

View File

@@ -2,13 +2,19 @@ import * as plugins from './plugins.js';
import * as interfaces from './interfaces/index.js';
import { DockerHost } from './classes.host.js';
import { DockerResource } from './classes.base.js';
import { DockerImage } from './classes.image.js';
import { DockerSecret } from './classes.secret.js';
import { logger } from './logger.js';
export class DockerService {
// STATIC
public static async getServices(dockerHost: DockerHost) {
export class DockerService extends DockerResource {
// STATIC (Internal - prefixed with _ to indicate internal use)
/**
* Internal: Get all services
* Public API: Use dockerHost.listServices() instead
*/
public static async _list(dockerHost: DockerHost) {
const services: DockerService[] = [];
const response = await dockerHost.request('GET', '/services');
for (const serviceObject of response.body) {
@@ -19,32 +25,50 @@ export class DockerService {
return services;
}
public static async getServiceByName(
/**
* Internal: Get service by name
* Public API: Use dockerHost.getServiceByName(name) instead
*/
public static async _fromName(
dockerHost: DockerHost,
networkName: string,
): Promise<DockerService> {
const allServices = await DockerService.getServices(dockerHost);
const allServices = await DockerService._list(dockerHost);
const wantedService = allServices.find((service) => {
return service.Spec.Name === networkName;
});
if (!wantedService) {
throw new Error(`Service not found: ${networkName}`);
}
return wantedService;
}
/**
* creates a service
* Internal: Create a service
* Public API: Use dockerHost.createService(descriptor) instead
*/
public static async createService(
public static async _create(
dockerHost: DockerHost,
serviceCreationDescriptor: interfaces.IServiceCreationDescriptor,
): Promise<DockerService> {
// lets get the image
logger.log(
'info',
`now creating service ${serviceCreationDescriptor.name}`,
);
// await serviceCreationDescriptor.image.pullLatestImageFromRegistry();
const serviceVersion = await serviceCreationDescriptor.image.getVersion();
// Resolve image (support both string and DockerImage instance)
let imageInstance: DockerImage;
if (typeof serviceCreationDescriptor.image === 'string') {
const foundImage = await DockerImage._fromName(dockerHost, serviceCreationDescriptor.image);
if (!foundImage) {
throw new Error(`Image not found: ${serviceCreationDescriptor.image}`);
}
imageInstance = foundImage;
} else {
imageInstance = serviceCreationDescriptor.image;
}
const serviceVersion = await imageInstance.getVersion();
const labels: interfaces.TLabels = {
...serviceCreationDescriptor.labels,
@@ -90,6 +114,7 @@ export class DockerService {
}
}
// Resolve networks (support both string[] and DockerNetwork[])
const networkArray: Array<{
Target: string;
Aliases: string[];
@@ -101,13 +126,16 @@ export class DockerService {
logger.log('warn', 'Skipping null network in service creation');
continue;
}
// Resolve network name
const networkName = typeof network === 'string' ? network : network.Name;
networkArray.push({
Target: network.Name,
Target: networkName,
Aliases: [serviceCreationDescriptor.networkAlias],
});
}
const ports = [];
const ports: Array<{ Protocol: string; PublishedPort: number; TargetPort: number }> = [];
for (const port of serviceCreationDescriptor.ports) {
const portArray = port.split(':');
const hostPort = portArray[0];
@@ -119,9 +147,21 @@ export class DockerService {
});
}
// lets configure secrets
// Resolve secrets (support both string[] and DockerSecret[])
const secretArray: any[] = [];
for (const secret of serviceCreationDescriptor.secrets) {
// Resolve secret instance
let secretInstance: DockerSecret;
if (typeof secret === 'string') {
const foundSecret = await DockerSecret._fromName(dockerHost, secret);
if (!foundSecret) {
throw new Error(`Secret not found: ${secret}`);
}
secretInstance = foundSecret;
} else {
secretInstance = secret;
}
secretArray.push({
File: {
Name: 'secret.json', // TODO: make sure that works with multiple secrets
@@ -129,33 +169,24 @@ export class DockerService {
GID: '33',
Mode: 384,
},
SecretID: secret.ID,
SecretName: secret.Spec.Name,
SecretID: secretInstance.ID,
SecretName: secretInstance.Spec.Name,
});
}
// lets configure limits
const memoryLimitMB =
serviceCreationDescriptor.resources &&
serviceCreationDescriptor.resources.memorySizeMB
? serviceCreationDescriptor.resources.memorySizeMB
: 1000;
const memoryLimitMB = serviceCreationDescriptor.resources?.memorySizeMB ?? 1000;
const limits = {
MemoryBytes: memoryLimitMB * 1000000,
};
if (serviceCreationDescriptor.resources) {
limits.MemoryBytes =
serviceCreationDescriptor.resources.memorySizeMB * 1000000;
}
const response = await dockerHost.request('POST', '/services/create', {
Name: serviceCreationDescriptor.name,
TaskTemplate: {
ContainerSpec: {
Image: serviceCreationDescriptor.image.RepoTags[0],
Image: imageInstance.RepoTags[0],
Labels: labels,
Secrets: secretArray,
Mounts: mounts,
@@ -189,21 +220,21 @@ export class DockerService {
},
});
const createdService = await DockerService.getServiceByName(
const createdService = await DockerService._fromName(
dockerHost,
serviceCreationDescriptor.name,
);
return createdService;
}
// INSTANCE
public dockerHostRef: DockerHost;
// INSTANCE PROPERTIES
// Note: dockerHost (not dockerHostRef) for consistency with base class
public ID: string;
public Version: { Index: number };
public CreatedAt: string;
public UpdatedAt: string;
public Spec: {
public ID!: string;
public Version!: { Index: number };
public CreatedAt!: string;
public UpdatedAt!: string;
public Spec!: {
Name: string;
Labels: interfaces.TLabels;
TaskTemplate: {
@@ -226,30 +257,52 @@ export class DockerService {
Mode: {};
Networks: [any[]];
};
public Endpoint: { Spec: {}; VirtualIPs: [any[]] };
public Endpoint!: { Spec: {}; VirtualIPs: [any[]] };
constructor(dockerHostArg: DockerHost) {
this.dockerHostRef = dockerHostArg;
super(dockerHostArg);
}
// INSTANCE METHODS
/**
* Refreshes this service's state from the Docker daemon
*/
public async refresh(): Promise<void> {
const updated = await DockerService._fromName(this.dockerHost, this.Spec.Name);
if (updated) {
Object.assign(this, updated);
}
}
/**
* Removes this service from the Docker daemon
*/
public async remove() {
await this.dockerHostRef.request('DELETE', `/services/${this.ID}`);
await this.dockerHost.request('DELETE', `/services/${this.ID}`);
}
/**
* Re-reads service data from Docker engine
* @deprecated Use refresh() instead
*/
public async reReadFromDockerEngine() {
const dockerData = await this.dockerHostRef.request(
const dockerData = await this.dockerHost.request(
'GET',
`/services/${this.ID}`,
);
// TODO: Better assign: Object.assign(this, dockerData);
}
/**
* Checks if this service needs an update based on image version
*/
public async needsUpdate(): Promise<boolean> {
// TODO: implement digest based update recognition
await this.reReadFromDockerEngine();
const dockerImage = await DockerImage.createFromRegistry(
this.dockerHostRef,
const dockerImage = await DockerImage._createFromRegistry(
this.dockerHost,
{
creationObject: {
imageUrl: this.Spec.TaskTemplate.ContainerSpec.Image,
@@ -268,6 +321,7 @@ export class DockerService {
return true;
} else {
console.log(`service ${this.Spec.Name} is up to date.`);
return false;
}
}
}

View File

@@ -1,3 +1,4 @@
export * from './classes.base.js';
export * from './classes.host.js';
export * from './classes.container.js';
export * from './classes.image.js';

View File

@@ -1,7 +1,50 @@
import { DockerNetwork } from '../classes.network.js';
/**
* Container creation descriptor supporting both string references and class instances.
* Strings will be resolved to resources internally.
*/
export interface IContainerCreationDescriptor {
Hostname: string;
Domainname: string;
networks?: DockerNetwork[];
/** Network names (strings) or DockerNetwork instances */
networks?: (string | DockerNetwork)[];
}
/**
* Information about an exec instance, including exit code and running state.
* Retrieved via container.exec().inspect()
*/
export interface IExecInspectInfo {
/** Exit code of the exec command (0 = success) */
ExitCode: number;
/** Whether the exec is currently running */
Running: boolean;
/** Process ID */
Pid: number;
/** Container ID where exec runs */
ContainerID: string;
/** Exec instance ID */
ID: string;
/** Whether stderr is open */
OpenStderr: boolean;
/** Whether stdin is open */
OpenStdin: boolean;
/** Whether stdout is open */
OpenStdout: boolean;
/** Whether exec can be removed */
CanRemove: boolean;
/** Detach keys */
DetachKeys: string;
/** Process configuration */
ProcessConfig: {
/** Whether TTY is allocated */
tty: boolean;
/** Entrypoint */
entrypoint: string;
/** Command arguments */
arguments: string[];
/** Whether running in privileged mode */
privileged: boolean;
};
}

View File

@@ -3,4 +3,18 @@
*/
export interface INetworkCreationDescriptor {
Name: string;
Driver?: 'bridge' | 'overlay' | 'host' | 'none' | 'macvlan';
Attachable?: boolean;
Labels?: Record<string, string>;
IPAM?: {
Driver?: string;
Config?: Array<{
Subnet?: string;
Gateway?: string;
IPRange?: string;
AuxiliaryAddresses?: Record<string, string>;
}>;
};
Internal?: boolean;
EnableIPv6?: boolean;
}

View File

@@ -5,13 +5,20 @@ import { DockerNetwork } from '../classes.network.js';
import { DockerSecret } from '../classes.secret.js';
import { DockerImage } from '../classes.image.js';
/**
* Service creation descriptor supporting both string references and class instances.
* Strings will be resolved to resources internally.
*/
export interface IServiceCreationDescriptor {
name: string;
image: DockerImage;
/** Image tag (string) or DockerImage instance */
image: string | DockerImage;
labels: interfaces.TLabels;
networks: DockerNetwork[];
/** Network names (strings) or DockerNetwork instances */
networks: (string | DockerNetwork)[];
networkAlias: string;
secrets: DockerSecret[];
/** Secret names (strings) or DockerSecret instances */
secrets: (string | DockerSecret)[];
ports: string[];
accessHostDockerSock?: boolean;
resources?: {

View File

@@ -1,9 +1,9 @@
import * as plugins from './plugins.js';
import { tmpdir } from 'node:os';
export const packageDir = plugins.path.resolve(
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
'../',
);
export const nogitDir = plugins.path.resolve(packageDir, '.nogit/');
plugins.smartfile.fs.ensureDir(nogitDir);
export const nogitDir = plugins.path.resolve(tmpdir(), 'apiclient-docker');

View File

@@ -1,13 +1,15 @@
// node native path
// node native
import * as fs from 'node:fs';
import * as path from 'node:path';
export { path };
export { fs, path };
// @pushrocks scope
import * as lik from '@push.rocks/lik';
import * as smartarchive from '@push.rocks/smartarchive';
import * as smartbucket from '@push.rocks/smartbucket';
import * as smartfile from '@push.rocks/smartfile';
import * as smartjson from '@push.rocks/smartjson';
import * as smartlog from '@push.rocks/smartlog';
import * as smartnetwork from '@push.rocks/smartnetwork';
@@ -24,6 +26,7 @@ export {
smartarchive,
smartbucket,
smartfile,
smartjson,
smartlog,
smartnetwork,

View File

@@ -6,9 +6,7 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {}
"verbatimModuleSyntax": true
},
"exclude": ["dist_*/**/*.d.ts"]
}