Files
git.stella-ops.org/docs/modules/release-orchestrator/deployment/agentless.md

11 KiB

Agentless Deployment (SSH/WinRM)

Agentless deployment using SSH and WinRM for remote execution without installing agents.

Status: Planned (not yet implemented) Source: Architecture Advisory Section 10.4 Related Modules: Agents Module, Deploy Orchestrator Sprints: 108_004 SSH Agent, 108_005 WinRM Agent

Overview

Agentless deployment enables deployment to targets without requiring a pre-installed agent. The orchestrator connects directly to targets using SSH (Linux/Unix) or WinRM (Windows) to execute deployment commands.


SSH Remote Executor

Capabilities

  • SSH key-based authentication
  • File transfer via SFTP
  • Remote command execution
  • Docker operations over SSH
  • Script execution
  • Backup and rollback

Connection Management

class SSHRemoteExecutor implements TargetExecutor {
  private ssh: SSHClient;

  async connect(config: SSHConnectionConfig): Promise<void> {
    const privateKey = await this.secrets.getSecret(config.privateKeyRef);

    this.ssh = new SSHClient();
    await this.ssh.connect({
      host: config.host,
      port: config.port || 22,
      username: config.username,
      privateKey: privateKey.value,
      readyTimeout: config.connectionTimeout || 30000,
      keepaliveInterval: 10000,
    });
  }
}

Deploy Task Flow

async deploy(task: DeployTaskPayload): Promise<DeployResult> {
  const { artifacts, config } = task;
  const deployDir = config.deploymentDirectory;

  try {
    // 1. Ensure deployment directory exists
    await this.exec(`mkdir -p ${deployDir}`);
    await this.exec(`mkdir -p ${deployDir}/.stella-backup`);

    // 2. Backup current deployment
    await this.exec(`cp -r ${deployDir}/* ${deployDir}/.stella-backup/ 2>/dev/null || true`);

    // 3. Upload artifacts
    for (const artifact of artifacts) {
      const content = await this.fetchArtifact(artifact);
      const remotePath = path.join(deployDir, artifact.name);
      await this.uploadFile(content, remotePath);
    }

    // 4. Run pre-deploy hook
    if (task.hooks?.preDeploy) {
      await this.runRemoteHook(task.hooks.preDeploy, deployDir);
    }

    // 5. Execute deployment script
    const deployScript = artifacts.find(a => a.type === "deploy_script");
    if (deployScript) {
      const scriptPath = path.join(deployDir, deployScript.name);
      await this.exec(`chmod +x ${scriptPath}`);

      const result = await this.exec(scriptPath, {
        cwd: deployDir,
        timeout: config.deploymentTimeout,
        env: config.environment,
      });

      if (result.exitCode !== 0) {
        throw new DeploymentError(`Deploy script failed: ${result.stderr}`);
      }
    }

    // 6. Run post-deploy hook
    if (task.hooks?.postDeploy) {
      await this.runRemoteHook(task.hooks.postDeploy, deployDir);
    }

    // 7. Health check
    if (config.healthCheck) {
      const healthy = await this.runHealthCheck(config.healthCheck);
      if (!healthy) {
        await this.rollback(task);
        throw new HealthCheckFailedError("Health check failed");
      }
    }

    // 8. Write version sticker
    await this.writeSticker(config.sticker, deployDir);

    // 9. Cleanup backup
    await this.exec(`rm -rf ${deployDir}/.stella-backup`);

    return {
      success: true,
      logs: this.getLogs(),
      durationMs: this.getDuration(),
    };

  } finally {
    this.ssh.end();
  }
}

Command Execution

private async exec(
  command: string,
  options?: ExecOptions
): Promise<CommandResult> {
  return new Promise((resolve, reject) => {
    const timeout = options?.timeout || 60000;
    let stdout = "";
    let stderr = "";

    this.ssh.exec(command, { cwd: options?.cwd }, (err, stream) => {
      if (err) {
        reject(err);
        return;
      }

      const timer = setTimeout(() => {
        stream.close();
        reject(new TimeoutError(`Command timed out after ${timeout}ms`));
      }, timeout);

      stream.on("data", (data: Buffer) => {
        stdout += data.toString();
        this.log(data.toString());
      });

      stream.stderr.on("data", (data: Buffer) => {
        stderr += data.toString();
        this.log(`[stderr] ${data.toString()}`);
      });

      stream.on("close", (code: number) => {
        clearTimeout(timer);
        resolve({ exitCode: code, stdout, stderr });
      });
    });
  });
}

File Upload via SFTP

private async uploadFile(content: Buffer | string, remotePath: string): Promise<void> {
  return new Promise((resolve, reject) => {
    this.ssh.sftp((err, sftp) => {
      if (err) {
        reject(err);
        return;
      }

      const writeStream = sftp.createWriteStream(remotePath);
      writeStream.on("close", () => resolve());
      writeStream.on("error", reject);
      writeStream.end(content);
    });
  });
}

Rollback

async rollback(task: RollbackTaskPayload): Promise<DeployResult> {
  const deployDir = task.config.deploymentDirectory;

  // Restore from backup
  await this.exec(`rm -rf ${deployDir}/*`);
  await this.exec(`cp -r ${deployDir}/.stella-backup/* ${deployDir}/`);

  // Re-run deployment from backup
  const deployScript = path.join(deployDir, "deploy.sh");
  await this.exec(deployScript, { cwd: deployDir });

  return {
    success: true,
    logs: this.getLogs(),
    durationMs: this.getDuration(),
  };
}

WinRM Remote Executor

Capabilities

  • NTLM/Kerberos authentication
  • PowerShell script execution
  • File transfer via base64 encoding
  • Windows container operations
  • Windows service management

Connection Management

class WinRMRemoteExecutor implements TargetExecutor {
  private winrm: WinRMClient;

  async connect(config: WinRMConnectionConfig): Promise<void> {
    const credential = await this.secrets.getSecret(config.credentialRef);

    this.winrm = new WinRMClient({
      host: config.host,
      port: config.port || 5986,
      username: credential.username,
      password: credential.password,
      protocol: config.useHttps ? "https" : "http",
      authentication: config.authType || "ntlm",  // ntlm, kerberos, basic
    });

    await this.winrm.openShell();
  }
}

Deploy Task Flow

async deploy(task: DeployTaskPayload): Promise<DeployResult> {
  const { artifacts, config } = task;
  const deployDir = config.deploymentDirectory;

  try {
    // 1. Ensure deployment directory exists
    await this.execPowerShell(`
      if (-not (Test-Path "${deployDir}")) {
        New-Item -ItemType Directory -Path "${deployDir}" -Force
      }
      if (-not (Test-Path "${deployDir}\\.stella-backup")) {
        New-Item -ItemType Directory -Path "${deployDir}\\.stella-backup" -Force
      }
    `);

    // 2. Backup current deployment
    await this.execPowerShell(`
      Get-ChildItem "${deployDir}" -Exclude ".stella-backup" |
        Copy-Item -Destination "${deployDir}\\.stella-backup" -Recurse -Force
    `);

    // 3. Upload artifacts
    for (const artifact of artifacts) {
      const content = await this.fetchArtifact(artifact);
      const remotePath = `${deployDir}\\${artifact.name}`;
      await this.uploadFile(content, remotePath);
    }

    // 4. Run pre-deploy hook
    if (task.hooks?.preDeploy) {
      await this.runRemoteHook(task.hooks.preDeploy, deployDir);
    }

    // 5. Execute deployment script
    const deployScript = artifacts.find(a => a.type === "deploy_script");
    if (deployScript) {
      const scriptPath = `${deployDir}\\${deployScript.name}`;

      const result = await this.execPowerShell(`
        Set-Location "${deployDir}"
        & "${scriptPath}"
        exit $LASTEXITCODE
      `, { timeout: config.deploymentTimeout });

      if (result.exitCode !== 0) {
        throw new DeploymentError(`Deploy script failed: ${result.stderr}`);
      }
    }

    // 6. Run post-deploy hook
    if (task.hooks?.postDeploy) {
      await this.runRemoteHook(task.hooks.postDeploy, deployDir);
    }

    // 7. Health check
    if (config.healthCheck) {
      const healthy = await this.runHealthCheck(config.healthCheck);
      if (!healthy) {
        await this.rollback(task);
        throw new HealthCheckFailedError("Health check failed");
      }
    }

    // 8. Write version sticker
    await this.writeSticker(config.sticker, deployDir);

    // 9. Cleanup backup
    await this.execPowerShell(`
      Remove-Item -Path "${deployDir}\\.stella-backup" -Recurse -Force
    `);

    return {
      success: true,
      logs: this.getLogs(),
      durationMs: this.getDuration(),
    };

  } finally {
    this.winrm.closeShell();
  }
}

PowerShell Execution

private async execPowerShell(
  script: string,
  options?: ExecOptions
): Promise<CommandResult> {
  const encoded = Buffer.from(script, "utf16le").toString("base64");
  return this.winrm.runCommand(
    `powershell -EncodedCommand ${encoded}`,
    { timeout: options?.timeout || 60000 }
  );
}

File Upload

private async uploadFile(content: Buffer | string, remotePath: string): Promise<void> {
  // Use PowerShell to write file content
  const base64Content = Buffer.from(content).toString("base64");

  await this.execPowerShell(`
    $bytes = [Convert]::FromBase64String("${base64Content}")
    [IO.File]::WriteAllBytes("${remotePath}", $bytes)
  `);
}

Security Considerations

SSH Security

  1. Key-Based Authentication: Always use SSH keys, never passwords
  2. Key Rotation: Regularly rotate SSH keys
  3. Bastion Hosts: Use jump hosts for network isolation
  4. Connection Timeouts: Enforce strict connection timeouts
  5. Known Hosts: Verify host fingerprints

WinRM Security

  1. HTTPS Required: Always use WinRM over HTTPS in production
  2. Certificate Validation: Validate server certificates
  3. Kerberos Preferred: Use Kerberos when available, NTLM as fallback
  4. Credential Protection: Store credentials in vault
  5. Session Cleanup: Always close sessions after use

Configuration Examples

SSH Target Configuration

target:
  name: web-server-01
  type: ssh
  connection:
    host: 192.168.1.100
    port: 22
    username: deploy
    privateKeyRef: vault://ssh-keys/deploy-key
  deployment:
    directory: /opt/myapp
    healthCheck:
      command: curl -f http://localhost:8080/health
      timeout: 30

WinRM Target Configuration

target:
  name: windows-server-01
  type: winrm
  connection:
    host: 192.168.1.200
    port: 5986
    useHttps: true
    authType: kerberos
    credentialRef: vault://windows-creds/deploy-user
  deployment:
    directory: C:\Apps\MyApp
    healthCheck:
      command: Invoke-WebRequest -Uri http://localhost:8080/health -UseBasicParsing
      timeout: 30

See Also