From 6fe70e0a1dff5617bee6009f868ff901b6fbdbdc Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 24 Nov 2025 12:20:30 +0000 Subject: [PATCH] BREAKING CHANGE(DockerHost): Refactor public API to DockerHost facade; introduce DockerResource base; make resource static methods internal; support flexible descriptors and stream compatibility --- changelog.md | 15 + package.json | 2 +- readme.hints.md | 118 ++++++++ readme.md | 512 ++++++++++++++++++++++++----------- test/test.nonci.node+deno.ts | 200 +++++++++++--- ts/00_commitinfo_data.ts | 2 +- ts/classes.base.ts | 27 ++ ts/classes.container.ts | 337 +++++++++++++++++++++-- ts/classes.host.ts | 184 +++++++++++-- ts/classes.image.ts | 91 +++++-- ts/classes.network.ts | 49 +++- ts/classes.secret.ts | 62 ++++- ts/classes.service.ts | 103 +++++-- ts/index.ts | 1 + ts/interfaces/container.ts | 7 +- ts/interfaces/service.ts | 13 +- 16 files changed, 1388 insertions(+), 335 deletions(-) create mode 100644 ts/classes.base.ts diff --git a/changelog.md b/changelog.md index b08ed59..80e11f8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,20 @@ # Changelog +## 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/getNetworks/getNetworkByName, createSecret/getSecrets/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 diff --git a/package.json b/package.json index 2396fda..cde63fa 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "typings": "dist_ts/index.d.ts", "type": "module", "scripts": { - "test": "(tstest test/ --verbose --logfile --timeout 600)", + "test": "(tstest test/ --verbose --logfile --timeout 300)", "build": "(tsbuild --web --allowimplicitany)", "buildDocs": "tsdoc" }, diff --git a/readme.hints.md b/readme.hints.md index ed734f6..799239b 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -1,5 +1,123 @@ # Docker Module - Development Hints +## OOP Refactoring - Clean Architecture (2025-11-24) + +### Architecture Changes +The module has been restructured to follow a clean OOP Facade pattern: +- **DockerHost** is now the single entry point for all Docker operations +- All resource classes extend abstract `DockerResource` base class +- Static methods are prefixed with `_` to indicate internal use +- Public API is exclusively through DockerHost methods + +### Key Changes + +**1. Factory Pattern** +- All resource creation/retrieval goes through DockerHost: + ```typescript + // Old (deprecated): + const container = await DockerContainer.getContainers(dockerHost); + const network = await DockerNetwork.createNetwork(dockerHost, descriptor); + + // New (clean API): + const containers = await dockerHost.getContainers(); + const network = await dockerHost.createNetwork(descriptor); + ``` + +**2. Container Management Methods Added** +The DockerContainer class now has full CRUD and streaming operations: + +**Lifecycle:** +- `container.start()` - Start container +- `container.stop(options?)` - Stop container +- `container.remove(options?)` - Remove container +- `container.refresh()` - Reload state + +**Information:** +- `container.inspect()` - Get detailed info +- `container.logs(options)` - Get logs as string (one-shot) +- `container.stats(options)` - Get stats + +**Streaming & Interactive:** +- `container.streamLogs(options)` - Stream logs continuously (follow mode) +- `container.attach(options)` - Attach to main process (PID 1) with bidirectional stream +- `container.exec(command, options)` - Execute commands in container interactively + +**Example - Stream Logs:** +```typescript +const container = await dockerHost.getContainerById('abc123'); +const logStream = await container.streamLogs({ timestamps: true }); + +logStream.on('data', (chunk) => { + console.log(chunk.toString()); +}); +``` + +**Example - Attach to Container:** +```typescript +const { stream, close } = await container.attach({ + stdin: true, + stdout: true, + stderr: true +}); + +// Pipe to/from process +process.stdin.pipe(stream); +stream.pipe(process.stdout); + +// Later: detach +await close(); +``` + +**Example - Execute Command:** +```typescript +const { stream, close } = await container.exec('ls -la /app', { + tty: true +}); + +stream.on('data', (chunk) => { + console.log(chunk.toString()); +}); + +stream.on('end', async () => { + await close(); +}); +``` + +**3. DockerResource Base Class** +All resource classes now extend `DockerResource`: +- Consistent `dockerHost` property (not `dockerHostRef`) +- Required `refresh()` method +- Standardized constructor pattern + +**4. ImageStore Encapsulation** +- `dockerHost.imageStore` is now private +- Use `dockerHost.storeImage(name, stream)` instead +- Use `dockerHost.retrieveImage(name)` instead + +**5. Creation Descriptors Support Both Primitives and Instances** +Interfaces now accept both strings and class instances: +```typescript +// Both work: +await dockerHost.createService({ + image: 'nginx:latest', // String + networks: ['my-network'], // String array + secrets: ['my-secret'] // String array +}); + +await dockerHost.createService({ + image: imageInstance, // DockerImage instance + networks: [networkInstance], // DockerNetwork array + secrets: [secretInstance] // DockerSecret array +}); +``` + +### Migration Guide +Replace all static method calls with dockerHost methods: +- `DockerContainer.getContainers(host)` β†’ `dockerHost.getContainers()` +- `DockerImage.createFromRegistry(host, opts)` β†’ `dockerHost.createImageFromRegistry(opts)` +- `DockerService.createService(host, desc)` β†’ `dockerHost.createService(desc)` +- `dockerHost.imageStore.storeImage(...)` β†’ `dockerHost.storeImage(...)` + ## smartrequest v5+ Migration (2025-11-17) ### Breaking Change diff --git a/readme.md b/readme.md index a9074cc..e39315f 100644 --- a/readme.md +++ b/readme.md @@ -2,11 +2,16 @@ > **Powerful TypeScript client for Docker Remote API** - Build, manage, and orchestrate Docker containers, images, networks, and swarm services with type-safe elegance. +## Issue Reporting and Security + +For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly. + ## πŸš€ Features - 🎯 **Full TypeScript Support** - Complete type definitions for all Docker API entities - πŸ”„ **Async/Await Ready** - Modern promise-based architecture for seamless async operations -- πŸ“¦ **Container Management** - Create, list, inspect, and manage containers effortlessly +- πŸ“¦ **Container Management** - Full lifecycle control: create, start, stop, remove, inspect containers +- πŸ”Œ **Interactive Containers** - Stream logs, attach to processes, execute commands in real-time - πŸ–ΌοΈ **Image Handling** - Pull from registries, build from tarballs, export, and manage tags - 🌐 **Network Operations** - Create and manage Docker networks with full IPAM support - πŸ” **Secrets Management** - Handle Docker secrets securely in swarm mode @@ -23,7 +28,7 @@ pnpm add @apiclient.xyz/docker # Using npm -npm install @apiclient.xyz/docker --save +npm install @apiclient.xyz/docker # Using yarn yarn add @apiclient.xyz/docker @@ -46,10 +51,40 @@ console.log('βœ… Docker is running'); const containers = await docker.getContainers(); console.log(`Found ${containers.length} containers`); +// Get a specific container and interact with it +const container = await docker.getContainerById('abc123'); +await container.start(); + +// Stream logs in real-time +const logStream = await container.streamLogs({ follow: true }); +logStream.on('data', (chunk) => console.log(chunk.toString())); + // Don't forget to clean up await docker.stop(); ``` +## πŸ—οΈ Clean Architecture + +The module follows a **Facade pattern** with `DockerHost` as the single entry point: + +```typescript +const docker = new DockerHost({}); + +// All operations go through DockerHost +const containers = await docker.getContainers(); // List containers +const container = await docker.getContainerById('id'); // Get specific container +const network = await docker.createNetwork({ Name: 'my-net' }); // Create network +const service = await docker.createService(descriptor); // Deploy service +const image = await docker.createImageFromRegistry({ imageUrl: 'nginx' }); + +// Resources support both strings and instances +await docker.createService({ + image: 'nginx:latest', // String works! + networks: ['my-network'], // String array works! + secrets: [secretInstance] // Or use actual instances +}); +``` + ## πŸ”Œ Socket Path Configuration The library determines which Docker socket to use in the following priority order: @@ -160,39 +195,178 @@ containers.forEach((container) => { #### Get Container by ID ```typescript -import { DockerContainer } from '@apiclient.xyz/docker'; - -const container = await DockerContainer.getContainerById(docker, 'abc123'); +const container = await docker.getContainerById('abc123'); if (container) { console.log(`Found: ${container.Names[0]}`); console.log(`Running: ${container.State === 'running'}`); } ``` +#### Container Lifecycle Operations + +```typescript +// Get a container +const container = await docker.getContainerById('abc123'); + +// Start the container +await container.start(); +console.log('Container started'); + +// Stop the container (with optional timeout) +await container.stop({ t: 10 }); // 10 seconds graceful stop +console.log('Container stopped'); + +// Restart by starting again +await container.start(); + +// Remove the container +await container.remove({ force: true, v: true }); // force + remove volumes +console.log('Container removed'); +``` + +#### Inspect Container Details + +```typescript +const container = await docker.getContainerById('abc123'); + +// Get detailed information +const details = await container.inspect(); +console.log('Container details:', details); + +// Or just refresh the container state +await container.refresh(); +console.log('Updated state:', container.State); +``` + +#### Get Container Logs + +```typescript +// Get logs as a string (one-shot) +const logs = await container.logs({ + stdout: true, + stderr: true, + timestamps: true, + tail: 100, // Last 100 lines +}); +console.log(logs); +``` + +#### Stream Logs in Real-Time πŸ”₯ + +```typescript +// Stream logs continuously (follow mode) +const logStream = await container.streamLogs({ + stdout: true, + stderr: true, + timestamps: true, + tail: 50, // Start with last 50 lines, then follow +}); + +logStream.on('data', (chunk) => { + console.log(chunk.toString()); +}); + +logStream.on('error', (err) => { + console.error('Stream error:', err); +}); + +// Stop streaming when done +// logStream.destroy(); +``` + +#### Attach to Container Process πŸ”₯ + +Attach to the container's main process (PID 1) for interactive session: + +```typescript +const { stream, close } = await container.attach({ + stdin: true, + stdout: true, + stderr: true, + logs: true, // Include previous logs +}); + +// Pipe to/from process streams +process.stdin.pipe(stream); +stream.pipe(process.stdout); + +// Handle stream events +stream.on('end', () => { + console.log('Attachment ended'); +}); + +// Later: detach cleanly +await close(); +``` + +#### Execute Commands in Container πŸ”₯ + +Run commands inside a running container: + +```typescript +// Execute a command +const { stream, close } = await container.exec('ls -la /app', { + tty: true, + user: 'root', + workingDir: '/app', + env: ['DEBUG=true'], +}); + +// Handle output +stream.on('data', (chunk) => { + console.log(chunk.toString()); +}); + +stream.on('end', async () => { + console.log('Command finished'); + await close(); +}); + +// Execute with array of arguments +const { stream: stream2, close: close2 } = await container.exec( + ['bash', '-c', 'echo "Hello from container"'], + { tty: true } +); +``` + +#### Get Container Stats + +```typescript +// Get stats (one-shot) +const stats = await container.stats({ stream: false }); +console.log('CPU Usage:', stats.cpu_stats); +console.log('Memory Usage:', stats.memory_stats); +``` + +#### Create Containers + +```typescript +const newContainer = await docker.createContainer({ + Hostname: 'my-app', + Domainname: 'local', + networks: ['my-network'], // Can use string or DockerNetwork instance +}); +console.log(`Container created: ${newContainer.Id}`); +``` + ### πŸ–ΌοΈ Image Management #### Pull Images from Registry ```typescript -import { DockerImage } from '@apiclient.xyz/docker'; - // Pull from Docker Hub -const image = await DockerImage.createFromRegistry(docker, { - creationObject: { - imageUrl: 'nginx', - imageTag: 'alpine', // Optional, defaults to 'latest' - }, +const image = await docker.createImageFromRegistry({ + imageUrl: 'nginx', + imageTag: 'alpine', // Optional, defaults to 'latest' }); console.log(`Image pulled: ${image.RepoTags[0]}`); console.log(`Size: ${(image.Size / 1024 / 1024).toFixed(2)} MB`); // Pull from private registry -const privateImage = await DockerImage.createFromRegistry(docker, { - creationObject: { - imageUrl: 'registry.example.com/my-app', - imageTag: 'v2.0.0', - }, +const privateImage = await docker.createImageFromRegistry({ + imageUrl: 'registry.example.com/my-app', + imageTag: 'v2.0.0', }); ``` @@ -200,16 +374,12 @@ const privateImage = await DockerImage.createFromRegistry(docker, { ```typescript import * as fs from 'fs'; -import { DockerImage } from '@apiclient.xyz/docker'; // Import from a tar file const tarStream = fs.createReadStream('./my-image.tar'); -const importedImage = await DockerImage.createFromTarStream(docker, { - tarStream, - creationObject: { - imageUrl: 'my-app', - imageTag: 'v1.0.0', - }, +const importedImage = await docker.createImageFromTarStream(tarStream, { + imageUrl: 'my-app', + imageTag: 'v1.0.0', }); console.log(`Imported: ${importedImage.RepoTags[0]}`); @@ -219,7 +389,7 @@ console.log(`Imported: ${importedImage.RepoTags[0]}`); ```typescript // Get image by name -const image = await DockerImage.getImageByName(docker, 'nginx:alpine'); +const image = await docker.getImageByName('nginx:alpine'); // Export to tar stream const exportStream = await image.exportToTarStream(); @@ -233,18 +403,6 @@ writeStream.on('finish', () => { }); ``` -#### Tag Images - -```typescript -// Tag an existing image -await DockerImage.tagImageByIdOrName(docker, 'nginx:alpine', { - registry: 'myregistry.com', - imageName: 'web-server', - imageTag: 'v1.0.0', -}); -// Result: myregistry.com/web-server:v1.0.0 -``` - #### List All Images ```typescript @@ -258,31 +416,25 @@ images.forEach((img) => { }); ``` +#### Remove Images + +```typescript +const image = await docker.getImageByName('nginx:alpine'); +await image.remove({ force: true }); +console.log('Image removed'); +``` + ### 🌐 Network Management #### Create Custom Networks ```typescript -import { DockerNetwork } from '@apiclient.xyz/docker'; - -// Create a bridge network -const network = await DockerNetwork.createNetwork(docker, { +// Create an overlay network (for swarm) +const network = await docker.createNetwork({ Name: 'my-app-network', - Driver: 'bridge', + Driver: 'overlay', EnableIPv6: false, - IPAM: { - Driver: 'default', - Config: [ - { - Subnet: '172.28.0.0/16', - Gateway: '172.28.0.1', - }, - ], - }, - Labels: { - project: 'my-app', - environment: 'production', - }, + Attachable: true, }); console.log(`Network created: ${network.Name} (${network.Id})`); @@ -301,7 +453,7 @@ networks.forEach((net) => { }); // Get specific network by name -const appNetwork = await DockerNetwork.getNetworkByName(docker, 'my-app-network'); +const appNetwork = await docker.getNetworkByName('my-app-network'); // Get containers connected to this network const containers = await appNetwork.getContainersOnNetwork(); @@ -311,7 +463,7 @@ console.log(`Containers on network: ${containers.length}`); #### Remove a Network ```typescript -const network = await DockerNetwork.getNetworkByName(docker, 'my-app-network'); +const network = await docker.getNetworkByName('my-app-network'); await network.remove(); console.log('Network removed'); ``` @@ -329,39 +481,35 @@ console.log('Swarm mode activated'); #### Deploy Services ```typescript -import { DockerService, DockerImage, DockerNetwork, DockerSecret } from '@apiclient.xyz/docker'; - // Create prerequisites -const network = await DockerNetwork.createNetwork(docker, { +const network = await docker.createNetwork({ Name: 'app-network', Driver: 'overlay', // Use overlay for swarm }); -const image = await DockerImage.createFromRegistry(docker, { - creationObject: { - imageUrl: 'nginx', - imageTag: 'latest', - }, +const image = await docker.createImageFromRegistry({ + imageUrl: 'nginx', + imageTag: 'latest', }); -const secret = await DockerSecret.createSecret(docker, { +const secret = await docker.createSecret({ name: 'api-key', version: '1.0.0', contentArg: 'super-secret-key', labels: { app: 'my-app' }, }); -// Create a service -const service = await DockerService.createService(docker, { +// Create a service (supports both strings and instances!) +const service = await docker.createService({ name: 'web-api', - image: image, + image: image, // Or use string: 'nginx:latest' labels: { app: 'api', version: '1.0.0', }, - networks: [network], + networks: [network], // Or use strings: ['app-network'] networkAlias: 'api', - secrets: [secret], + secrets: [secret], // Or use strings: ['api-key'] ports: ['80:3000'], // host:container resources: { memorySizeMB: 512, @@ -380,13 +528,13 @@ const services = await docker.getServices(); services.forEach((service) => { console.log(`Service: ${service.Spec.Name}`); console.log(` Image: ${service.Spec.TaskTemplate.ContainerSpec.Image}`); - if (service.Spec.Mode.Replicated) { - console.log(` Replicas: ${service.Spec.Mode.Replicated.Replicas}`); - } }); // Get service by name -const myService = await DockerService.getServiceByName(docker, 'web-api'); +const myService = await docker.getServiceByName('web-api'); + +// Refresh service state +await myService.refresh(); // Check if service needs update const needsUpdate = await myService.needsUpdate(); @@ -404,10 +552,8 @@ console.log('Service removed'); Secrets are only available in Docker Swarm mode. ```typescript -import { DockerSecret } from '@apiclient.xyz/docker'; - // Create a secret -const secret = await DockerSecret.createSecret(docker, { +const secret = await docker.createSecret({ name: 'database-password', version: '1.0.0', contentArg: 'my-super-secret-password', @@ -420,14 +566,14 @@ const secret = await DockerSecret.createSecret(docker, { console.log(`Secret created: ${secret.ID}`); // List all secrets -const secrets = await DockerSecret.getSecrets(docker); +const secrets = await docker.getSecrets(); secrets.forEach((s) => { console.log(`Secret: ${s.Spec.Name}`); console.log(` Labels:`, s.Spec.Labels); }); // Get secret by name -const dbSecret = await DockerSecret.getSecretByName(docker, 'database-password'); +const dbSecret = await docker.getSecretByName('database-password'); // Update secret content await dbSecret.update('new-password-value'); @@ -437,24 +583,27 @@ await dbSecret.remove(); console.log('Secret removed'); ``` -### πŸ’Ύ S3 Image Storage +### πŸ’Ύ Image Storage -Store and retrieve Docker images from S3-compatible storage: +Store and retrieve Docker images from local storage or S3: ```typescript -// Configure S3 storage for the image store +// Store image to local storage +const imageStream = fs.createReadStream('./my-app.tar'); +await docker.storeImage('my-app-v1', imageStream); +console.log('Image stored locally'); + +// Retrieve image from storage +const storedImageStream = await docker.retrieveImage('my-app-v1'); +storedImageStream.pipe(fs.createWriteStream('./restored-image.tar')); + +// Configure S3 storage (optional) await docker.addS3Storage({ endpoint: 's3.amazonaws.com', accessKey: 'AKIAIOSFODNN7EXAMPLE', accessSecret: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', bucketName: 'my-docker-images', }); - -// Store an image to S3 -const imageStream = fs.createReadStream('./my-app.tar'); -await docker.imageStore.storeImage('my-app-v1', imageStream); - -console.log('Image stored to S3'); ``` ### πŸ“Š Event Monitoring @@ -498,14 +647,12 @@ await docker.auth({ console.log('βœ… Authenticated with registry'); // Or read credentials from Docker config file -const authToken = await docker.getAuthTokenFromDockerConfig('registry.example.com'); +await docker.getAuthTokenFromDockerConfig('registry.example.com'); // Now you can pull private images -const privateImage = await DockerImage.createFromRegistry(docker, { - creationObject: { - imageUrl: 'registry.example.com/private/app', - imageTag: 'latest', - }, +const privateImage = await docker.createImageFromRegistry({ + imageUrl: 'registry.example.com/private/app', + imageTag: 'latest', }); ``` @@ -516,7 +663,7 @@ const privateImage = await DockerImage.createFromRegistry(docker, { Deploy a complete multi-service application stack: ```typescript -import { DockerHost, DockerNetwork, DockerSecret, DockerService, DockerImage } from '@apiclient.xyz/docker'; +import { DockerHost } from '@apiclient.xyz/docker'; async function deployStack() { const docker = new DockerHost({}); @@ -527,7 +674,7 @@ async function deployStack() { console.log('βœ… Swarm initialized'); // Create overlay network for service communication - const network = await DockerNetwork.createNetwork(docker, { + const network = await docker.createNetwork({ Name: 'app-network', Driver: 'overlay', Attachable: true, @@ -535,7 +682,7 @@ async function deployStack() { console.log('βœ… Network created'); // Create secrets - const dbPassword = await DockerSecret.createSecret(docker, { + const dbPassword = await docker.createSecret({ name: 'db-password', version: '1.0.0', contentArg: 'strong-database-password', @@ -543,30 +690,14 @@ async function deployStack() { }); console.log('βœ… Secrets created'); - // Pull images - const postgresImage = await DockerImage.createFromRegistry(docker, { - creationObject: { - imageUrl: 'postgres', - imageTag: '14-alpine', - }, - }); - - const appImage = await DockerImage.createFromRegistry(docker, { - creationObject: { - imageUrl: 'my-app', - imageTag: 'latest', - }, - }); - console.log('βœ… Images pulled'); - // Deploy database service - const dbService = await DockerService.createService(docker, { + const dbService = await docker.createService({ name: 'postgres-db', - image: postgresImage, + image: 'postgres:14-alpine', // Using string for convenience labels: { tier: 'database' }, - networks: [network], + networks: ['app-network'], // Using string array networkAlias: 'postgres', - secrets: [dbPassword], + secrets: ['db-password'], // Using string array ports: [], resources: { memorySizeMB: 1024, @@ -575,13 +706,13 @@ async function deployStack() { console.log('βœ… Database service deployed'); // Deploy application service - const appService = await DockerService.createService(docker, { + const appService = await docker.createService({ name: 'web-app', - image: appImage, + image: 'my-app:latest', labels: { tier: 'application' }, - networks: [network], + networks: ['app-network'], networkAlias: 'app', - secrets: [dbPassword], + secrets: ['db-password'], ports: ['80:3000'], resources: { memorySizeMB: 512, @@ -595,6 +726,49 @@ async function deployStack() { deployStack().catch(console.error); ``` +### Container Debugging Session + +Interactive debugging session with a running container: + +```typescript +async function debugContainer(containerId: string) { + const docker = new DockerHost({}); + await docker.start(); + + const container = await docker.getContainerById(containerId); + + // First, check container state + await container.inspect(); + console.log(`Container: ${container.Names[0]}`); + console.log(`State: ${container.State}`); + + // Get recent logs + const logs = await container.logs({ tail: 50 }); + console.log('Recent logs:', logs); + + // Stream live logs in one terminal + console.log('\n--- Live Logs ---'); + const logStream = await container.streamLogs({ timestamps: true }); + logStream.on('data', (chunk) => { + process.stdout.write(chunk); + }); + + // Execute diagnostic commands + console.log('\n--- Running Diagnostics ---'); + const { stream, close } = await container.exec('ps aux', { tty: true }); + + stream.on('data', (chunk) => { + console.log(chunk.toString()); + }); + + stream.on('end', async () => { + console.log('\nDiagnostics complete'); + await close(); + await docker.stop(); + }); +} +``` + ### Image Pipeline: Pull, Tag, Export ```typescript @@ -603,23 +777,13 @@ async function imagePipeline() { await docker.start(); // Pull latest image - const image = await DockerImage.createFromRegistry(docker, { - creationObject: { - imageUrl: 'node', - imageTag: '18-alpine', - }, + const image = await docker.createImageFromRegistry({ + imageUrl: 'node', + imageTag: '18-alpine', }); console.log('βœ… Image pulled'); - // Tag for private registry - await DockerImage.tagImageByIdOrName(docker, 'node:18-alpine', { - registry: 'registry.company.com', - imageName: 'base/node', - imageTag: 'v18-alpine', - }); - console.log('βœ… Image tagged'); - - // Export to tar + // Export to tar for backup/transfer const exportStream = await image.exportToTarStream(); const writeStream = fs.createWriteStream('./node-18-alpine.tar'); @@ -631,6 +795,11 @@ async function imagePipeline() { }); console.log('βœ… Image exported to tar'); + // Store in image store (with S3 backup if configured) + const tarStream = fs.createReadStream('./node-18-alpine.tar'); + await docker.storeImage('node-18-alpine-backup', tarStream); + console.log('βœ… Image stored in image store'); + await docker.stop(); } ``` @@ -645,7 +814,11 @@ import type { IImageCreationDescriptor, IServiceCreationDescriptor, ISecretCreationDescriptor, + IContainerCreationDescriptor, + INetworkCreationDescriptor, TLabels, + TPorts, + DockerResource, } from '@apiclient.xyz/docker'; // Full IntelliSense support @@ -678,55 +851,68 @@ const docker = new DockerHost({ await docker.start(); // Build and push process -const image = await DockerImage.createFromTarStream(docker, { - tarStream: buildArtifactStream, - creationObject: { - imageUrl: 'my-app', - imageTag: process.env.CI_COMMIT_SHA, - }, +const buildStream = fs.createReadStream('./build-artifact.tar'); +const image = await docker.createImageFromTarStream(buildStream, { + imageUrl: 'my-app', + imageTag: process.env.CI_COMMIT_SHA, }); -await DockerImage.tagImageByIdOrName(docker, `my-app:${process.env.CI_COMMIT_SHA}`, { - registry: 'registry.company.com', - imageName: 'production/my-app', - imageTag: 'latest', -}); - -// Push to registry (authentication required) -// Note: Pushing requires proper registry authentication +console.log(`βœ… Image built: my-app:${process.env.CI_COMMIT_SHA}`); ``` -### Dynamic Service Scaling +### Health Check Service ```typescript -// Monitor and scale services based on load -const services = await docker.getServices(); -const webService = services.find(s => s.Spec.Name === 'web-app'); +async function healthCheckService() { + const docker = new DockerHost({}); -if (webService && webService.Spec.Mode.Replicated) { - const currentReplicas = webService.Spec.Mode.Replicated.Replicas; - console.log(`Current replicas: ${currentReplicas}`); + try { + await docker.ping(); + const containers = await docker.getContainers(); - // Scale based on your metrics - // (Scaling API would need to be implemented) + const unhealthy = containers.filter(c => c.State !== 'running'); + if (unhealthy.length > 0) { + console.warn(`⚠️ ${unhealthy.length} containers not running`); + // Send alerts, restart services, etc. + } + + return { healthy: true, containers: containers.length }; + } catch (error) { + console.error('❌ Docker health check failed:', error); + return { healthy: false, error: error.message }; + } } ``` ## πŸ“– API Documentation - **Package Repository**: [https://code.foss.global/apiclient.xyz/docker](https://code.foss.global/apiclient.xyz/docker) +- **npm Package**: [https://www.npmjs.com/package/@apiclient.xyz/docker](https://www.npmjs.com/package/@apiclient.xyz/docker) - **Docker Engine API Reference**: [https://docs.docker.com/engine/api/latest/](https://docs.docker.com/engine/api/latest/) -- **Issues & Bug Reports**: [https://code.foss.global/apiclient.xyz/docker/issues](https://code.foss.global/apiclient.xyz/docker/issues) ## πŸ”‘ Key Concepts -- **DockerHost**: Main entry point for Docker API communication +- **DockerHost**: Main entry point - all operations flow through this facade +- **Flexible Descriptors**: Accept both string references and class instances - **Health Checks**: Use `ping()` method to verify Docker daemon accessibility - **Socket Path Priority**: Constructor option β†’ `DOCKER_HOST` env β†’ CI mode β†’ default socket - **Swarm Mode Required**: Services and secrets require Docker Swarm to be activated - **Type Safety**: Full TypeScript support with comprehensive interfaces -- **Streaming Support**: Real-time event monitoring and tar stream operations -- **S3 Integration**: Built-in image storage/retrieval from S3-compatible storage +- **Streaming Support**: Real-time log streaming, event monitoring, and container attachment +- **Interactive Containers**: Attach to processes, execute commands, stream logs +- **Clean Architecture**: Facade pattern with internal delegation for maintainability + +## πŸ†• Recent Updates + +### Version 2.1.0 - Architecture & Features + +- ✨ **Clean OOP Architecture**: Refactored to Facade pattern with DockerHost as single entry point +- ✨ **Container Streaming**: Added `streamLogs()`, `attach()`, and `exec()` methods +- ✨ **Flexible Descriptors**: Support both string references and class instances +- ✨ **Complete Container API**: All lifecycle methods (start, stop, remove, logs, inspect, stats) +- ✨ **DockerResource Base Class**: Consistent patterns across all resources +- πŸ”§ **Improved Type Safety**: Better TypeScript definitions throughout +- πŸ“š **Enhanced Documentation**: Comprehensive examples and migration guides ## License and Legal Information diff --git a/test/test.nonci.node+deno.ts b/test/test.nonci.node+deno.ts index 02a0fe3..4ed0217 100644 --- a/test/test.nonci.node+deno.ts +++ b/test/test.nonci.node+deno.ts @@ -33,7 +33,7 @@ tap.test('should list networks', async () => { }); 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 +41,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 +66,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 +76,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(); }); @@ -99,24 +91,19 @@ tap.test('should list all services', async (tools) => { }); 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', @@ -133,14 +120,9 @@ tap.test('should create a service', async () => { 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 testImage = await testDockerHost.createImageFromRegistry({ + imageUrl: 'code.foss.global/host.today/ht-docker-node:latest', + }); const fsWriteStream = plugins.smartfile.fsStream.createWriteStream( plugins.path.join(paths.nogitDir, 'testimage.tar'), ); @@ -155,13 +137,10 @@ tap.test('should import images', async () => { const fsReadStream = plugins.smartfile.fsStream.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); @@ -177,8 +156,8 @@ tap.test('should expose a working DockerImageStore', async () => { }; 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.path.join(paths.nogitDir, 'testimage.tar'), @@ -186,6 +165,143 @@ tap.test('should expose a working DockerImageStore', async () => { ); }); +// CONTAINER STREAMING FEATURES +let testContainer: docker.DockerContainer; + +tap.test('should get an existing container for streaming tests', async () => { + const containers = await testDockerHost.getContainers(); + + // 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'); +}); + tap.test('cleanup', async () => { await testDockerHost.stop(); }); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 803afdc..d99f4bb 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@apiclient.xyz/docker', - version: '2.1.0', + version: '3.0.0', description: 'Provides easy communication with Docker remote API from Node.js, with TypeScript support.' } diff --git a/ts/classes.base.ts b/ts/classes.base.ts new file mode 100644 index 0000000..cb27671 --- /dev/null +++ b/ts/classes.base.ts @@ -0,0 +1,27 @@ +import { 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; +} diff --git a/ts/classes.container.ts b/ts/classes.container.ts index b8a6332..8993ab8 100644 --- a/ts/classes.container.ts +++ b/ts/classes.container.ts @@ -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.getContainers() instead */ - public static async getContainers( + public static async _list( dockerHostArg: DockerHost, ): Promise { 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,46 +26,49 @@ export class DockerContainer { } /** - * gets an container by Id - * @param containerId + * Internal: Get a container by ID + * Public API: Use dockerHost.getContainerById(id) instead */ - public static async getContainerById(containerId: string) { - // TODO: implement get container by id + public static async _fromId( + dockerHostArg: DockerHost, + containerId: string, + ): Promise { + const response = await dockerHostArg.request('GET', `/containers/${containerId}/json`); + return new DockerContainer(dockerHostArg, response.body); } /** - * 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 { + // 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 + return await DockerContainer._fromId(dockerHost, response.body.Id); } 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 + // INSTANCE PROPERTIES public Id: string; public Names: string[]; public Image: string; @@ -95,10 +100,294 @@ 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 { + const updated = await DockerContainer._fromId(this.dockerHost, this.Id); + Object.assign(this, updated); + } + + /** + * Inspects the container and returns detailed information + */ + public async inspect(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + }> { + 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; + }> { + // 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(); + } + }; + + return { + stream: duplexStream, + close, + }; + } } diff --git a/ts/classes.host.ts b/ts/classes.host.ts index f8f71c6..b509f04 100644 --- a/ts/classes.host.ts +++ b/ts/classes.host.ts @@ -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,7 +28,7 @@ export class DockerHost { */ public socketPath: string; private registryToken: string = ''; - public imageStore: DockerImageStore; + private imageStore: DockerImageStore; // Now private - use storeImage/retrieveImage instead public smartBucket: plugins.smartbucket.SmartBucket; /** @@ -123,70 +125,190 @@ export class DockerHost { } // ============== - // NETWORKS + // NETWORKS - Public Factory API // ============== + /** - * gets all networks + * Gets all networks */ public async getNetworks() { - return await DockerNetwork.getNetworks(this); + return await DockerNetwork._list(this); } /** - * create a network - */ - public async createNetwork( - optionsArg: Parameters[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); + } + + /** + * Creates a network + */ + public async createNetwork( + descriptor: interfaces.INetworkCreationDescriptor, + ) { + return await DockerNetwork._create(this, descriptor); } // ============== - // CONTAINERS + // CONTAINERS - Public Factory API // ============== + /** - * gets all containers + * Gets all containers */ public async getContainers() { - const containerArray = await DockerContainer.getContainers(this); - return containerArray; + return await DockerContainer._list(this); + } + + /** + * Gets a container by ID + */ + public async getContainerById(containerId: string) { + return await DockerContainer._fromId(this, containerId); + } + + /** + * Creates a container + */ + public async createContainer( + descriptor: interfaces.IContainerCreationDescriptor, + ) { + return await DockerContainer._create(this, descriptor); } // ============== - // SERVICES + // SERVICES - Public Factory API // ============== /** - * gets all services + * Gets all services */ public async getServices() { - const serviceArray = await DockerService.getServices(this); - return serviceArray; + 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 + // IMAGES - Public Factory API // ============== /** - * get all images + * Gets all images */ public async getImages() { - return await DockerImage.getImages(this); + return await DockerImage._list(this); } /** - * get an image by name + * 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, + }); + } + + /** + * Builds an image from a Dockerfile + */ + public async buildImage(imageTag: string) { + return await DockerImage._build(this, imageTag); + } + + // ============== + // SECRETS - Public Factory API + // ============== + + /** + * Gets all secrets + */ + public async getSecrets() { + 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 { + return await this.imageStore.storeImage(imageName, tarStream); + } + + /** + * Retrieves an image from the local image store + */ + public async retrieveImage( + imageName: string, + ): Promise { + return await this.imageStore.getImage(imageName); } /** @@ -330,6 +452,7 @@ export class DockerHost { methodArg: string, routeArg: string, readStream?: plugins.smartstream.stream.Readable, + jsonData?: any, ) { const requestUrl = `${this.socketPath}${routeArg}`; @@ -342,6 +465,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; diff --git a/ts/classes.image.ts b/ts/classes.image.ts index e915ade..0f2ef21 100644 --- a/ts/classes.image.ts +++ b/ts/classes.image.ts @@ -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.getImages() 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; @@ -76,7 +90,7 @@ 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, ); @@ -87,11 +101,10 @@ export class DockerImage { } /** - * - * @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 +174,11 @@ export class DockerImage { } // Now try to look up that image by the "loadedImageTag". - // Depending on Docker’s 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,15 +205,15 @@ 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 */ @@ -215,13 +228,28 @@ export class DockerImage { 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 { + 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 +262,7 @@ export class DockerImage { * pulls the latest version from the registry */ public async pullLatestImageFromRegistry(): Promise { - const updatedImage = await DockerImage.createFromRegistry(this.dockerHost, { + const updatedImage = await DockerImage._createFromRegistry(this.dockerHost, { creationObject: { imageUrl: this.RepoTags[0], }, @@ -244,6 +272,25 @@ export class DockerImage { return true; } + /** + * Removes this image from the Docker daemon + */ + public async remove(options?: { force?: boolean; noprune?: boolean }): Promise { + 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) { diff --git a/ts/classes.network.ts b/ts/classes.network.ts index 6a52a04..8025f44 100644 --- a/ts/classes.network.ts +++ b/ts/classes.network.ts @@ -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 { const dockerNetworks: DockerNetwork[] = []; @@ -19,17 +26,25 @@ 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 { @@ -54,7 +69,7 @@ export class DockerNetwork { }); if (response.statusCode < 300) { logger.log('info', 'Created network successfully'); - return await DockerNetwork.getNetworkByName( + return await DockerNetwork._fromName( dockerHost, networkCreationDescriptor.Name, ); @@ -67,11 +82,7 @@ export class DockerNetwork { } } - // INSTANCE - // references - public dockerHost: DockerHost; - - // properties + // INSTANCE PROPERTIES public Name: string; public Id: string; public Created: string; @@ -93,11 +104,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 { + 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( diff --git a/ts/classes.secret.ts b/ts/classes.secret.ts index ec5210e..f3a90cb 100644 --- a/ts/classes.secret.ts +++ b/ts/classes.secret.ts @@ -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.getSecrets() 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,12 +66,12 @@ 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 + // INSTANCE PROPERTIES public ID: string; public Spec: { Name: string; @@ -63,13 +81,24 @@ export class DockerSecret { 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 { + 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}`; @@ -84,11 +113,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; } diff --git a/ts/classes.service.ts b/ts/classes.service.ts index b4498ab..c2db7a6 100644 --- a/ts/classes.service.ts +++ b/ts/classes.service.ts @@ -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.getServices() instead + */ + public static async _list(dockerHost: DockerHost) { const services: DockerService[] = []; const response = await dockerHost.request('GET', '/services'); for (const serviceObject of response.body) { @@ -19,11 +25,15 @@ 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 { - const allServices = await DockerService.getServices(dockerHost); + const allServices = await DockerService._list(dockerHost); const wantedService = allServices.find((service) => { return service.Spec.Name === networkName; }); @@ -31,20 +41,30 @@ export class DockerService { } /** - * 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 { - // 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') { + imageInstance = await DockerImage._fromName(dockerHost, serviceCreationDescriptor.image); + if (!imageInstance) { + throw new Error(`Image not found: ${serviceCreationDescriptor.image}`); + } + } else { + imageInstance = serviceCreationDescriptor.image; + } + + const serviceVersion = await imageInstance.getVersion(); const labels: interfaces.TLabels = { ...serviceCreationDescriptor.labels, @@ -90,6 +110,7 @@ export class DockerService { } } + // Resolve networks (support both string[] and DockerNetwork[]) const networkArray: Array<{ Target: string; Aliases: string[]; @@ -101,8 +122,11 @@ 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], }); } @@ -119,9 +143,20 @@ 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') { + secretInstance = await DockerSecret._fromName(dockerHost, secret); + if (!secretInstance) { + throw new Error(`Secret not found: ${secret}`); + } + } else { + secretInstance = secret; + } + secretArray.push({ File: { Name: 'secret.json', // TODO: make sure that works with multiple secrets @@ -129,8 +164,8 @@ export class DockerService { GID: '33', Mode: 384, }, - SecretID: secret.ID, - SecretName: secret.Spec.Name, + SecretID: secretInstance.ID, + SecretName: secretInstance.Spec.Name, }); } @@ -155,7 +190,7 @@ export class DockerService { Name: serviceCreationDescriptor.name, TaskTemplate: { ContainerSpec: { - Image: serviceCreationDescriptor.image.RepoTags[0], + Image: imageInstance.RepoTags[0], Labels: labels, Secrets: secretArray, Mounts: mounts, @@ -189,15 +224,15 @@ 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 }; @@ -229,27 +264,49 @@ export class DockerService { 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 { + 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 { // 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, diff --git a/ts/index.ts b/ts/index.ts index b7c0641..6969f43 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,3 +1,4 @@ +export * from './classes.base.js'; export * from './classes.host.js'; export * from './classes.container.js'; export * from './classes.image.js'; diff --git a/ts/interfaces/container.ts b/ts/interfaces/container.ts index b149159..a4dde7a 100644 --- a/ts/interfaces/container.ts +++ b/ts/interfaces/container.ts @@ -1,7 +1,12 @@ 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)[]; } diff --git a/ts/interfaces/service.ts b/ts/interfaces/service.ts index b48250a..cee36f8 100644 --- a/ts/interfaces/service.ts +++ b/ts/interfaces/service.ts @@ -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?: {