Skip to content

debezium/dbz#2101 Add pipeline deployment lifecycle with Ansible-based container management - #493

Open
d1vyanshu-kumar wants to merge 5 commits into
debezium:mainfrom
d1vyanshu-kumar:debezium/dbz#2101
Open

debezium/dbz#2101 Add pipeline deployment lifecycle with Ansible-based container management#493
d1vyanshu-kumar wants to merge 5 commits into
debezium:mainfrom
d1vyanshu-kumar:debezium/dbz#2101

Conversation

@d1vyanshu-kumar

Copy link
Copy Markdown
Contributor

Fixes debezium/dbz#2101

Description

Implements the full pipeline deployment lifecycle for host-mode environments using Ansible ad-hoc commands over SSH instead of the future Host Agent. This allows pipelines to be deployed, monitored, stopped, started, and undeployed on remote servers without blocking on the Host Agent implementation.

Changes

Pipeline Configuration Mapping

  • Add HostPipelineMapper to convert pipeline objects into flat application.properties format with source, sink, offset storage, schema history, transforms, and signal/notification defaults
  • Apply platform-required defaults (signal channels, notification channels) after user config to prevent silent overrides, consistent with the operator PipelineMapper
  • Map debezium.format.key and debezium.format.value from JsonConverter to Json to match Debezium Server's expected format enum

Deployment Service & Host Selection

  • Add HostDeploymentService with transactional deployment creation, pessimistic-locked host selection, and port allocation
  • Add DeployStrategy interface with RoundRobinStrategy (least-loaded, deterministic tie-breaking by host ID)
  • Add deployedAt timestamp to HostDeploymentEntity for deployment age tracking
  • Add findReadyHosts() query for container cleanup when deployment records are removed by cascade

Pipeline Controller

  • Add HostPipelineController orchestrating deploy, stop, start, and undeploy operations via AnsibleCommandRunner
  • Config files are copied to remote hosts using Ansible copy module; containers are managed using Ansible shell module running Docker commands
  • Clean up existing deployment records and containers before redeployment to prevent UNIQUE constraint violations on pipeline_id
  • Handle undeploy when the deployment DB record was already removed by ON DELETE CASCADE by falling back to docker rm -f on all ready hosts
  • Add null guard on Arc.container() in CDI request context activation to support plain JUnit test execution

Status Monitoring

  • Add HostDeploymentStatusPoller that checks container health every 30 seconds via docker inspect
  • Implement a 5-minute grace period after deployment to allow Docker image pulls on fresh servers before marking containers as FAILED
  • Detect config drift by comparing SHA-256 hash of deployed config against the live file on the remote host
  • Reset deployedAt timestamp when transitioning back to DEPLOYING (restart scenario) to prevent false FAILED marking
  • Add ansible_become_timeout (60s) to prevent Ansible become hangs on slow hosts

Ansible Command Runner

  • Add AnsibleCommandRunner as a reusable wrapper for executing Ansible ad-hoc commands with structured result handling via sealed CommandResult interface

Pipeline Deletion

  • Add ON DELETE CASCADE to FK_host_deployment_pipeline foreign key so deleting a pipeline from the UI automatically removes the associated host_deployment row without a constraint violation
  • Add @OnDelete(action = CASCADE) on HostDeploymentEntity.pipeline to keep the JPA model consistent with the database schema

Dev Profile Configuration

  • Override JDBC offset and schema history connection URLs under the %dev profile so that Debezium Server containers on remote hosts connect to the correct PostgreSQL instance instead of localhost

Testing

  • HostPipelineMapperTest — 17 unit tests covering source/sink/transform mapping, offset/schema history config, signal defaults ordering, format mapping, and SHA-256 hashing
  • HostPipelineControllerTest — 17 unit tests for deploy/stop/start/undeploy orchestration, idempotent redeployment cleanup, and cascade undeploy fallback
  • HostDeploymentStatusPollerTest — 12 unit tests covering all state transitions, grace period logic, config drift detection, and become timeout configuration
  • HostDeploymentServiceIT — 15 integration tests against real PostgreSQL for deployment CRUD, status transitions, deployedAt lifecycle, pessimistic locking, ready host queries, and cascade deletion
  • AnsibleCommandRunnerTest — unit tests for command construction and result parsing
  • RoundRobinStrategyTest — unit tests for least-loaded selection and tie-breaking

PR Checklist

  • I have read the contribution guidelines and the governance document on PR expectations.
  • Minimal changes to code not directly related to your change (e.g. no unnecessary formatting changes or refactoring to existing code)
  • One feature/change per PR unless tightly coupled
  • Do a rebase on upstream main

…d container management

Signed-off-by: divyanshu_Kumar <154233802+d1vyanshu-kumar@users.noreply.github.com>
…format mapping, and offset JDBC URLs

Signed-off-by: divyanshu_Kumar <154233802+d1vyanshu-kumar@users.noreply.github.com>
…ployment

Signed-off-by: divyanshu_Kumar <154233802+d1vyanshu-kumar@users.noreply.github.com>
@d1vyanshu-kumar

Copy link
Copy Markdown
Contributor Author
image image image

@d1vyanshu-kumar

d1vyanshu-kumar commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Hi @mfvitale, @kmos, could you take a look at this when you get a chance?

One thing I wanted to mention, during our call we discussed using file-based offset/schema storage for Host mode, but after working through the implementation I went with the same JDBC-based approach (JdbcOffsetBackingStore / JdbcSchemaHistory) that we already use in Operator mode. The main reason is that with file-based storage, offsets live inside the container filesystem, so if a container gets recreated during redeploy or moved to another host, those offsets are lost and Debezium would have to re-read the WAL from scratch. With JDBC storage the containers stay fully stateless - the offset bookmark lives in a database table, so redeploys just resume from where they left off.

It also kept things simpler on the implementation side since we reuse the existing PipelineConfigGroup config paths without needing extra volume mount logic over SSH. The %dev profile points to a Postgres on the target host for local testing, and in production it resolves through the same OFFSET_JDBC_URL env var as Operator mode.

That said, if you'd prefer the file-based approach instead, I'm happy to rework it, just let me know!

@mfvitale

Copy link
Copy Markdown
Member

That said, if you'd prefer the file-based approach instead, I'm happy to rework it, just let me know!

We should go with file based offset mounting as a volume on the target host.

@kmos
kmos requested review from kmos and mfvitale August 13, 2026 22:14
@kmos

kmos commented Aug 13, 2026

Copy link
Copy Markdown
Member

That said, if you'd prefer the file-based approach instead, I'm happy to rework it, just let me know!

We should go with file based offset mounting as a volume on the target host.

I agree. The target host shouldn't change like a k8s pod or volume. I think it's fine for now

Comment on lines 31 to 45
server_port integer not null,
deployment_status varchar(255) not null,
config_hash varchar(255) not null,
deployed_at timestamp(6) with time zone not null,
primary key (id)
);

alter table if exists host_deployment
add constraint FK_host_deployment_pipeline
foreign key (pipeline_id)
references pipeline;
references pipeline
on delete cascade;

alter table if exists host_deployment
add constraint FK_host_deployment_host_status

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to create another change file. You cannot modify an old one.

* @return the selected host entity
* @throws io.debezium.DebeziumException if no hosts are available
*/
HostStatusEntity select(List<HostStatusEntity> readyHosts, LoadCounter loadCounter);

@mfvitale mfvitale Aug 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LoadCounter is specific for the round-robin load based implementation. Should not be in the interface

* used in config drift detection.
*/
@ApplicationScoped
public class HostPipelineMapper {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that you can simply this a lot using the io.debezium.platform.environment.operator.PipelineMapper.map(pipelineFlat) and the call the asConfiguration on the DebeziumServer returned from the map method.

This is effectively what the operator does to create the config map with the application.properties

String sshAlias = allocation.hostStatus().getSshAlias();
int port = allocation.allocatedPort();

String containerName = hostConfig.containerNamePrefix() + pipelineId;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use the pipeline the name ad we do for the k8s

containerName, hostConfig.debeziumServerImage(),
port, mappedConfig.configHash());

CommandResult mkdirResult = ansibleRunner.createDirectory(sshAlias, configDir);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose that all commands below this point are Ansible related. Since we will replace this part with the host agent, I could see an interface with the start, stop, restart and in this PR the implementation is done via Ansible and then the other one that will call the API?

* this class will be swapped for HTTP REST calls — all other logic
* (host selection, port allocation, status tracking) stays unchanged.
*/
@Dependent

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be @ApplicationScoped?

In CDI, for a @Dependent scoped bean, the container will create a new instance specifically for each observer method invocation, then destroy it afterward. The instance injected into HostEnvironmentController, the one holding the executor, is never notified when the shutdown event is observed.

* @return the command result
*/
public CommandResult copyContent(String sshAlias, String content, String destPath) {
String copyArgs = "content='" + content + "' dest=" + destPath + " mode=0644";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The content argument is being wrapped here in single quotes; however, if any user-provided config value, such as a password or other connector property, contains a single quote, the Ansible copy module argument parsing will break.

We should either add escaping logic to guard against this, use Ansible's stdin, or write to a temp file and use src= rather than content=.

@d1vyanshu-kumar d1vyanshu-kumar Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've switched from content='...' to writing a local temp file and using src= instead. The config content never appears in the Ansible command string now, so single quotes (or any other characters) in user-provided values can't break argument parsing.

# Must point to a Postgres reachable from the remote Docker container.
# Override via HOST_OFFSET_JDBC_URL env var when the VM IP changes.
connection:
url: ${HOST_OFFSET_JDBC_URL:jdbc:postgresql://192.168.252.23:5432/testdb?loggerLevel=OFF}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the IP here might be specific to your environment? Same for HOST_SCHEMA_HISTORY_JDBC_URL below.

@mfvitale mfvitale Aug 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Naros This part must be removed since we want to use file based offset on the host as default offset/schema storage

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mfvitale, @kmos Agreed; switched to file-based offset and schema history storage. The host's data directory (/opt/debezium/data/) is bind-mounted into the container at /debezium/data. Offset data survives container restarts. Also removed the hardcoded 192.168.252.23 JDBC URLs from application.yml.

controller.undeploy(999L);

// Give the executor time to process
Thread.sleep(500);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we not use this instead?

assertThat(latch.await(ASYNC_WAIT_SECONDS, TimeUnit.SECONDS)).isTrue();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Replaced all Thread.sleep(500) calls with CountDownLatch.await(ASYNC_WAIT_SECONDS, TimeUnit.SECONDS) , same pattern used in HostProvisioningServiceTest. No more timing-dependent flakiness.

…cycle

Signed-off-by: divyanshu_Kumar <154233802+d1vyanshu-kumar@users.noreply.github.com>
@d1vyanshu-kumar

d1vyanshu-kumar commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Hey @mfvitale @Naros @kmos - pushed a commit addressing all the review feedback here is summary:

  1. Migration immutability - Reverted V3.6.0 to its original heartbeat-only content and moved all host deployment DDL into a new V3.7.0.2 migration file. Flyway checksum stays intact for existing installations.

  2. LoadCounter out of DeployStrategy - Removed it from the interface. The deployment count query now lives inside RoundRobinStrategy via direct EntityManager injection, so future strategies don't need to deal with load counting at all.

  3. HostPipelineMapper delegation - Refactored to delegate to
    PipelineMapper.map().asConfiguration().getAsMapSimple() and only override offset/schema storage from JDBC to file-based for host mode.

  4. Container naming - Now uses pipeline.getName() instead of a numeric ID prefix, matching the K8s convention. docker ps on the host shows the actual pipeline name.

  5. HostContainerRuntime interface - Extracted deploy/undeploy/stop/start/logs into an interface. AnsibleContainerRuntime implements it. The controller depends only on the interface, so swapping to the Host Agent later won't require controller changes.

  6. @ Dependent → @ApplicationScoped - Fixed the CDI scope on HostPipelineController so the shutdown observer fires on the real singleton that holds the running thread pool.

  7. copyContent injection fix - Switched from content='...' to writing a local temp file and using src= instead. Config content never appears in the Ansible command string now.

  8. File-based offset/schema storage - Host data directory is bind-mounted at /debezium/data. Removed the hardcoded JDBC URLs from application.yml. Verified offsets.dat is created on the host after the first snapshot.

  9. Thread.sleep → CountDownLatch Replaced all Thread.sleep(500) in async tests with CountDownLatch.await() for deterministic test behavior.

Also fixed a container permission issue that came up during live testing - the Debezium Server container couldn't write to the root-owned data directory. Per @mfvitale's suggestion, added --user $(id -u):$(id -g) to the docker run command so the container process matches the host directory owner. Tested and verified that offsets.dat is now created successfully.

As we discussed, I've opened a separate issue for the shared group provisioning improvement as a follow-up: dbz#2451

I have verified everything end-to-end on a live host server, and it is working smoothly. Please feel free to pull the latest changes and test it on your end, and let me know if anything else needs attention!

@kmos kmos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have done a round of review, my suggestion is to take a look to the following article and internal links and apply a refactoring.

* @param hostStatus the locked and selected host entity
* @param allocatedPort the unique port assigned for this deployment
*/
public record HostAllocation(HostStatusEntity hostStatus, int allocatedPort) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move this record outside this class

@Transactional(REQUIRES_NEW)
public Optional<HostDeploymentEntity> findByPipelineId(Long pipelineId) {
return em.createQuery(
"SELECT d FROM host_deployment d WHERE d.pipeline.id = :pipelineId",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract the query

@Transactional(REQUIRES_NEW)
public List<HostDeploymentEntity> findByStatus(DeploymentStatus status) {
return em.createQuery(
"SELECT d FROM host_deployment d WHERE d.deploymentStatus = :status",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract the query

* @return the persisted deployment entity
*/
@Transactional(REQUIRES_NEW)
public HostDeploymentEntity createDeployment(Long pipelineId, Long hostStatusId,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it really necessary to return an HostDeploymentEntity? it could be void?

*/
@Transactional(REQUIRES_NEW)
public HostDeploymentEntity createDeployment(Long pipelineId, Long hostStatusId,
String containerName, String imageVersion,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reduce the number of parameter creating a new record class. Try to follow the rule max 3 params


if (!finished) {
process.destroyForcibly();
String timeoutMessage = "Ansible ad-hoc command timed out after "

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inline

return new CommandResult.Failure(output);
}
catch (IOException e) {
logger.errorv(e, "Failed to start Ansible ad-hoc process");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as the previous comment related to stacktrace

}

@Override
public HostStatusEntity select(List<HostStatusEntity> readyHosts) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why using the entity? You could use a domain class

* @see AnsibleHostProvisioner
*/
@ApplicationScoped
public class AnsibleCommandRunner {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could refactor using command pattern: https://refactoring.guru/design-patterns/command

String configPath = configDir + PATH_SEPARATOR + CONFIG_FILE_NAME;
String dataDir = hostConfig.dataBasePath() + PATH_SEPARATOR + containerName;

// 1. Create config directory

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using comment in the code, express your intentions: extract classes that describe the business

Comment on lines -5 to -11

-- Host-based pipeline deployment tables
-- Purely additive: no changes to existing tables, sequences, indexes, or constraints.

create sequence host_status_SEQ start with 1 increment by 50;

create sequence host_deployment_SEQ start with 1 increment by 50;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@d1vyanshu-kumar Existing file should not be modified. If you need to add column to an already existing table you just need to crate the alter DDL statement on a new migration file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mfvitale ok, I see it now. I moved the host tables out to a new file, but deleting them from V3.6.0 still counts as modifying it, so that's why this popped up again. My bad, makes sense now.

I've reverted V3.6.0 and V3.7.0.2 back to their original state and added the deployed_at column + the ON DELETE CASCADE change through a new migration file instead, so neither of the existing files get touched. Let me know if that looks right. 🙂

…eployment

Signed-off-by: divyanshu_Kumar <154233802+d1vyanshu-kumar@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Host Deployment ~6] Implement Ansible-based pipeline deployment service

4 participants