add release orchestrator docs and sprints gaps fills
This commit is contained in:
403
docs/modules/release-orchestrator/deployment/agent-based.md
Normal file
403
docs/modules/release-orchestrator/deployment/agent-based.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# Agent-Based Deployment
|
||||
|
||||
> Agent-based deployment using Docker and Compose agents for executing tasks on targets.
|
||||
|
||||
**Status:** Planned (not yet implemented)
|
||||
**Source:** [Architecture Advisory Section 10.3](../../../product/advisories/09-Jan-2026%20-%20Stella%20Ops%20Orchestrator%20Architecture.md)
|
||||
**Related Modules:** [Agents Module](../modules/agents.md), [Deploy Orchestrator](../modules/deploy-orchestrator.md)
|
||||
**Sprints:** [108_002 Docker Agent](../../../../implplan/SPRINT_20260110_108_002_AGENTS_docker.md), [108_003 Compose Agent](../../../../implplan/SPRINT_20260110_108_003_AGENTS_compose.md)
|
||||
|
||||
## Overview
|
||||
|
||||
Agent-based deployment uses lightweight agents installed on target hosts to execute deployment tasks. Agents communicate with the orchestrator over mTLS and receive tasks through heartbeat polling or WebSocket streams.
|
||||
|
||||
---
|
||||
|
||||
## Agent Task Protocol
|
||||
|
||||
### Task Payload Structure
|
||||
|
||||
```typescript
|
||||
// Task assignment (Core -> Agent)
|
||||
interface AgentTask {
|
||||
id: UUID;
|
||||
type: TaskType;
|
||||
targetId: UUID;
|
||||
payload: TaskPayload;
|
||||
credentials: EncryptedCredentials;
|
||||
timeout: number;
|
||||
priority: TaskPriority;
|
||||
idempotencyKey: string;
|
||||
assignedAt: DateTime;
|
||||
expiresAt: DateTime;
|
||||
}
|
||||
|
||||
type TaskType =
|
||||
| "deploy"
|
||||
| "rollback"
|
||||
| "health-check"
|
||||
| "inspect"
|
||||
| "execute-command"
|
||||
| "upload-files"
|
||||
| "write-sticker"
|
||||
| "read-sticker";
|
||||
|
||||
interface DeployTaskPayload {
|
||||
image: string;
|
||||
digest: string;
|
||||
config: DeployConfig;
|
||||
artifacts: ArtifactReference[];
|
||||
previousDigest?: string;
|
||||
hooks: {
|
||||
preDeploy?: HookConfig;
|
||||
postDeploy?: HookConfig;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Task Result Structure
|
||||
|
||||
```typescript
|
||||
// Task result (Agent -> Core)
|
||||
interface TaskResult {
|
||||
taskId: UUID;
|
||||
success: boolean;
|
||||
startedAt: DateTime;
|
||||
completedAt: DateTime;
|
||||
|
||||
// Success details
|
||||
outputs?: Record<string, any>;
|
||||
artifacts?: ArtifactReference[];
|
||||
|
||||
// Failure details
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
retriable?: boolean;
|
||||
|
||||
// Logs
|
||||
logs: string;
|
||||
|
||||
// Metrics
|
||||
metrics: {
|
||||
pullDurationMs?: number;
|
||||
deployDurationMs?: number;
|
||||
healthCheckDurationMs?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Agent Implementation
|
||||
|
||||
The Docker agent deploys single containers to Docker hosts with digest verification.
|
||||
|
||||
### Docker Agent Capabilities
|
||||
|
||||
- Pull images with digest verification
|
||||
- Create and start containers
|
||||
- Stop and remove containers
|
||||
- Health check monitoring
|
||||
- Version sticker management
|
||||
- Rollback to previous container
|
||||
|
||||
### Deploy Task Flow
|
||||
|
||||
```typescript
|
||||
class DockerAgent implements TargetExecutor {
|
||||
private docker: Docker;
|
||||
|
||||
async deploy(task: DeployTaskPayload): Promise<DeployResult> {
|
||||
const { image, digest, config, previousDigest } = task;
|
||||
const containerName = config.containerName;
|
||||
|
||||
// 1. Pull image and verify digest
|
||||
this.log(`Pulling image ${image}@${digest}`);
|
||||
await this.docker.pull(image, { digest });
|
||||
|
||||
const pulledDigest = await this.getImageDigest(image);
|
||||
if (pulledDigest !== digest) {
|
||||
throw new DigestMismatchError(
|
||||
`Expected digest ${digest}, got ${pulledDigest}. Possible tampering detected.`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Run pre-deploy hook
|
||||
if (task.hooks?.preDeploy) {
|
||||
await this.runHook(task.hooks.preDeploy, "pre-deploy");
|
||||
}
|
||||
|
||||
// 3. Stop and rename existing container
|
||||
const existingContainer = await this.findContainer(containerName);
|
||||
if (existingContainer) {
|
||||
this.log(`Stopping existing container ${containerName}`);
|
||||
await existingContainer.stop({ t: 10 });
|
||||
await existingContainer.rename(`${containerName}-previous-${Date.now()}`);
|
||||
}
|
||||
|
||||
// 4. Create new container
|
||||
this.log(`Creating container ${containerName} from ${image}@${digest}`);
|
||||
const container = await this.docker.createContainer({
|
||||
name: containerName,
|
||||
Image: `${image}@${digest}`, // Always use digest, not tag
|
||||
Env: this.buildEnvVars(config.environment),
|
||||
HostConfig: {
|
||||
PortBindings: this.buildPortBindings(config.ports),
|
||||
Binds: this.buildBindMounts(config.volumes),
|
||||
RestartPolicy: { Name: config.restartPolicy || "unless-stopped" },
|
||||
Memory: config.memoryLimit,
|
||||
CpuQuota: config.cpuLimit,
|
||||
},
|
||||
Labels: {
|
||||
"stella.release.id": config.releaseId,
|
||||
"stella.release.name": config.releaseName,
|
||||
"stella.digest": digest,
|
||||
"stella.deployed.at": new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Start container
|
||||
this.log(`Starting container ${containerName}`);
|
||||
await container.start();
|
||||
|
||||
// 6. Wait for container to be healthy (if health check configured)
|
||||
if (config.healthCheck) {
|
||||
this.log(`Waiting for container health check`);
|
||||
const healthy = await this.waitForHealthy(container, config.healthCheck.timeout);
|
||||
if (!healthy) {
|
||||
// Rollback to previous container
|
||||
await this.rollbackContainer(containerName, existingContainer);
|
||||
throw new HealthCheckFailedError(`Container ${containerName} failed health check`);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Run post-deploy hook
|
||||
if (task.hooks?.postDeploy) {
|
||||
await this.runHook(task.hooks.postDeploy, "post-deploy");
|
||||
}
|
||||
|
||||
// 8. Cleanup previous container
|
||||
if (existingContainer && config.cleanupPrevious !== false) {
|
||||
this.log(`Removing previous container`);
|
||||
await existingContainer.remove({ force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
containerId: container.id,
|
||||
previousDigest: previousDigest,
|
||||
logs: this.getLogs(),
|
||||
durationMs: this.getDuration(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rollback Implementation
|
||||
|
||||
```typescript
|
||||
async rollback(task: RollbackTaskPayload): Promise<DeployResult> {
|
||||
const { containerName, targetDigest } = task;
|
||||
|
||||
// Find previous container or use specified digest
|
||||
if (targetDigest) {
|
||||
// Deploy specific digest
|
||||
return this.deploy({
|
||||
...task,
|
||||
digest: targetDigest,
|
||||
});
|
||||
}
|
||||
|
||||
// Find and restore previous container
|
||||
const previousContainer = await this.findContainer(`${containerName}-previous-*`);
|
||||
if (!previousContainer) {
|
||||
throw new RollbackError(`No previous container found for ${containerName}`);
|
||||
}
|
||||
|
||||
// Stop current, rename, start previous
|
||||
const currentContainer = await this.findContainer(containerName);
|
||||
if (currentContainer) {
|
||||
await currentContainer.stop({ t: 10 });
|
||||
await currentContainer.rename(`${containerName}-failed-${Date.now()}`);
|
||||
}
|
||||
|
||||
await previousContainer.rename(containerName);
|
||||
await previousContainer.start();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
containerId: previousContainer.id,
|
||||
logs: this.getLogs(),
|
||||
durationMs: this.getDuration(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Version Sticker Management
|
||||
|
||||
```typescript
|
||||
async writeSticker(sticker: VersionSticker): Promise<void> {
|
||||
const stickerPath = this.config.stickerPath || "/var/stella/version.json";
|
||||
const stickerContent = JSON.stringify(sticker, null, 2);
|
||||
|
||||
// Write to host filesystem or container volume
|
||||
if (this.config.stickerLocation === "volume") {
|
||||
// Write to shared volume
|
||||
await this.docker.run("alpine", [
|
||||
"sh", "-c",
|
||||
`echo '${stickerContent}' > ${stickerPath}`
|
||||
], {
|
||||
HostConfig: {
|
||||
Binds: [`${this.config.stickerVolume}:/var/stella`]
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Write directly to host
|
||||
fs.writeFileSync(stickerPath, stickerContent);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compose Agent Implementation
|
||||
|
||||
The Compose agent deploys multi-container applications defined in Docker Compose files.
|
||||
|
||||
### Compose Agent Capabilities
|
||||
|
||||
- Pull images for all services
|
||||
- Verify digests for all services
|
||||
- Deploy using compose lock files
|
||||
- Health check all services
|
||||
- Rollback to previous deployment
|
||||
- Version sticker management
|
||||
|
||||
### Deploy Task Flow
|
||||
|
||||
```typescript
|
||||
class ComposeAgent implements TargetExecutor {
|
||||
async deploy(task: DeployTaskPayload): Promise<DeployResult> {
|
||||
const { artifacts, config } = task;
|
||||
const deployDir = config.deploymentDirectory;
|
||||
|
||||
// 1. Write compose lock file
|
||||
const composeLock = artifacts.find(a => a.type === "compose_lock");
|
||||
const composeContent = await this.fetchArtifact(composeLock);
|
||||
|
||||
const composePath = path.join(deployDir, "compose.stella.lock.yml");
|
||||
await fs.writeFile(composePath, composeContent);
|
||||
|
||||
// 2. Write any additional config files
|
||||
for (const artifact of artifacts.filter(a => a.type === "config")) {
|
||||
const content = await this.fetchArtifact(artifact);
|
||||
await fs.writeFile(path.join(deployDir, artifact.name), content);
|
||||
}
|
||||
|
||||
// 3. Run pre-deploy hook
|
||||
if (task.hooks?.preDeploy) {
|
||||
await this.runHook(task.hooks.preDeploy, deployDir);
|
||||
}
|
||||
|
||||
// 4. Pull images
|
||||
this.log("Pulling images...");
|
||||
const pullResult = await this.runCompose(deployDir, ["pull"]);
|
||||
if (!pullResult.success) {
|
||||
throw new Error(`Failed to pull images: ${pullResult.stderr}`);
|
||||
}
|
||||
|
||||
// 5. Verify digests
|
||||
await this.verifyDigests(composePath, config.expectedDigests);
|
||||
|
||||
// 6. Deploy
|
||||
this.log("Deploying services...");
|
||||
const upResult = await this.runCompose(deployDir, [
|
||||
"up", "-d",
|
||||
"--remove-orphans",
|
||||
"--force-recreate"
|
||||
]);
|
||||
|
||||
if (!upResult.success) {
|
||||
throw new Error(`Failed to deploy: ${upResult.stderr}`);
|
||||
}
|
||||
|
||||
// 7. Wait for services to be healthy
|
||||
if (config.healthCheck) {
|
||||
this.log("Waiting for services to be healthy...");
|
||||
const healthy = await this.waitForServicesHealthy(
|
||||
deployDir,
|
||||
config.healthCheck.timeout
|
||||
);
|
||||
|
||||
if (!healthy) {
|
||||
// Rollback
|
||||
await this.rollbackToBackup(deployDir);
|
||||
throw new HealthCheckFailedError("Services failed health check");
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Run post-deploy hook
|
||||
if (task.hooks?.postDeploy) {
|
||||
await this.runHook(task.hooks.postDeploy, deployDir);
|
||||
}
|
||||
|
||||
// 9. Write version sticker
|
||||
await this.writeSticker(config.sticker, deployDir);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
logs: this.getLogs(),
|
||||
durationMs: this.getDuration(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Digest Verification
|
||||
|
||||
```typescript
|
||||
private async verifyDigests(
|
||||
composePath: string,
|
||||
expectedDigests: Record<string, string>
|
||||
): Promise<void> {
|
||||
const composeContent = yaml.parse(await fs.readFile(composePath, "utf-8"));
|
||||
|
||||
for (const [service, expectedDigest] of Object.entries(expectedDigests)) {
|
||||
const serviceConfig = composeContent.services[service];
|
||||
if (!serviceConfig) {
|
||||
throw new Error(`Service ${service} not found in compose file`);
|
||||
}
|
||||
|
||||
const image = serviceConfig.image;
|
||||
if (!image.includes("@sha256:")) {
|
||||
throw new Error(`Service ${service} image not pinned to digest: ${image}`);
|
||||
}
|
||||
|
||||
const actualDigest = image.split("@")[1];
|
||||
if (actualDigest !== expectedDigest) {
|
||||
throw new DigestMismatchError(
|
||||
`Service ${service}: expected ${expectedDigest}, got ${actualDigest}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Digest Verification:** All deployments verify image digests before execution
|
||||
2. **Credential Encryption:** Credentials are encrypted in transit and at rest
|
||||
3. **mTLS Communication:** All agent-server communication uses mutual TLS
|
||||
4. **Hook Sandboxing:** Pre/post-deploy hooks run in isolated environments
|
||||
5. **Audit Logging:** All deployment actions are logged with actor context
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Agents Module](../modules/agents.md)
|
||||
- [Agent Security](../security/agent-security.md)
|
||||
- [Deployment Orchestrator](../modules/deploy-orchestrator.md)
|
||||
- [Agentless Deployment](agentless.md)
|
||||
Reference in New Issue
Block a user