Files

11 KiB

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 Related Modules: Agents Module, Deploy Orchestrator Sprints: 108_002 Docker Agent, 108_003 Compose Agent

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

// 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

// 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

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

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

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

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

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