NAME

DBIO::Test::Kubernetes - Provision temporary database pods in Kubernetes for DBIO testing

VERSION

version 0.900004

SYNOPSIS

my $k8s = DBIO::Test::Kubernetes->new(
    kubeconfig => $path,
    databases  => [qw(pg mysql)],
);

$k8s->deploy_databases;
$k8s->wait_for_ready;

# Local mode: port-forward and get env vars
$k8s->setup_port_forwards;
my %env = $k8s->env_vars;
local @ENV{keys %env} = values %env;
system('prove', '-Ilib', 't/');

$k8s->cleanup;

# In a namespace that is already there (and stays there)
my $k8s = DBIO::Test::Kubernetes->new(
    existing_namespace => 'dev-dbio',
    databases          => [qw(pg mysql)],
);

See t/kubernetes/smoke.t for a runnable example.

DESCRIPTION

DBIO::Test::Kubernetes provisions temporary test databases inside Kubernetes and exposes the corresponding DBIO_TEST_* environment variables for local or in-cluster test runs.

It is intended for integration-style test runs that need short-lived database instances without hand-managed local services or bespoke local containers.

databases takes any of the following names and defaults to pg and mysql; any other name croaks. env_vars returns <prefix>_DSN, <prefix>_USER and <prefix>_PASS for each database, with the prefix given here:

  • pg -- image postgres:16, prefix DBIO_TEST_PG

  • pg-ext -- image src.ci/srv/postgres:18, prefix DBIO_TEST_PG_EXT

  • mysql -- image mysql:8.0.30, prefix DBIO_TEST_MYSQL

  • duckdb -- image duckdb/duckdb:latest, prefix DBIO_TEST_DUCKDB

METHODS

new

my $k8s = DBIO::Test::Kubernetes->new(
    kubeconfig         => $path,                   # optional
    databases          => [qw(pg mysql)],          # optional
    namespace          => 'my-run',                # or:
    existing_namespace => 'dev-dbio',              # one of the two
);

Builds the run; nothing is created in the cluster yet. The kubeconfig is kubeconfig, else $ENV{DBIO_TEST_KUBECONFIG}, else $ENV{KUBECONFIG}, else ~/.kube/config; a file that is not there croaks. databases names what to deploy (see "DESCRIPTION") and an unknown name croaks. namespace and existing_namespace are the two kinds described under "NAMESPACES"; giving both, or an empty existing_namespace, throws a DBIO::Exception. Without either, a namespace of its own is named from a random suffix.

namespace

The name of the namespace this run works in: the one it created, or the existing_namespace it was given.

databases

The arrayref of database names this run deploys.

api

The Kubernetes::REST client built from the kubeconfig.

kubeconfig

Path of the kubeconfig file in use.

uses_existing_namespace

True when the instance was built with existing_namespace: it works in a namespace it did not create and will not delete. See "NAMESPACES".

created_objects

my @objects = $k8s->created_objects;   # ({ kind => 'Pod', name => '...' }, ...)

The objects this run has created and not yet deleted, in creation order, each as a hashref with kind and name. An object is listed once the API has accepted it, so the list is what "cleanup" will remove -- and what is left behind when cleanup is not called.

deploy_databases

$k8s->deploy_databases;

Creates the namespace (unless it is an existing_namespace) and one pod and one ClusterIP service per database, every one with a cpu and memory limit (see "RESOURCE LIMITS"). Each object is recorded as it is created, so "cleanup" can remove it even when this call dies halfway. Returns once the objects are created; use "wait_for_ready" to wait for the pods.

wait_for_ready

$k8s->wait_for_ready( timeout => 120, interval => 3 );

Polls each database pod until its Ready condition is true. timeout (seconds, default 120) is one deadline for all databases together; interval (default 3) is the sleep between polls. Croaks when "deploy_databases" was not called first, and on timeout. A failure to read a pod other than "not there yet" is thrown, not waited out.

connection_info

my $info = $k8s->connection_info( mode => 'local' );
# { pg => { dsn, user, pass, host, port }, ... }

Returns a hashref, keyed by database name, of what a test needs to connect. mode is local (the default: 127.0.0.1 and the port "setup_port_forwards" chose, which croaks if that was not called) or cluster (the service's cluster DNS name and its own port, for a runner inside the cluster).

env_vars

my %env = $k8s->env_vars( mode => 'local' );

Flattens "connection_info" into the DBIO_TEST_*_DSN, _USER and _PASS environment variables the driver test suites read. Takes the same mode option.

setup_port_forwards

$k8s->setup_port_forwards;

Starts one kubectl port-forward per database to a free local port, for local mode. Croaks unless "deploy_databases" and "wait_for_ready" came first, and when a forward died within its first two seconds. The processes are stopped by "cleanup" (and, as a last resort, when the object is destroyed).

deploy_test_job

my $job = $k8s->deploy_test_job(
    image      => 'dbio-test:latest',
    prove_args => ['-l', 't/'],
    resources  => { requests => {...}, limits => {...} },
);

Creates a Job that runs prove inside the cluster against the databases in cluster mode and returns its name. resources follows "RESOURCE LIMITS": it must set a cpu and a memory limit or the call throws.

wait_for_job

my $exit = $k8s->wait_for_job( name => $job, timeout => 3600, interval => 5 );

Polls the Job until it has succeeded (returns 0) or failed (returns 1). name defaults to this run's job. Croaks on timeout.

fetch_job_logs

my $logs = $k8s->fetch_job_logs( name => $job );
# [ { pod => $name, log => $text }, ... ]

Returns the log of each pod the Job ran, read with kubectl logs.

cleanup

$k8s->cleanup;

Stops the port-forwards and deletes what this run created: its own namespace, or, in an existing namespace, each recorded object and nothing else. A delete that fails warns and leaves the object in created_objects. Safe to call after a failed deploy_databases, and more than once.

NAMESPACES

A run works in one namespace, and the constructor is told which kind it is. It is never inferred from what the cluster happens to permit.

  • A namespace of its own -- the default

    Without a namespace argument a name dbio-test-XXXXXXXX is generated; namespace => $name names it instead. deploy_databases creates the namespace, and cleanup deletes it, which takes everything in it along. This needs a cluster account that may create and delete namespaces. Objects carry plain names: a pod pg behind a service pg-svc, a job dbio-test-runner.

  • An existing namespace -- existing_namespace => $name

    For an account that is confined to one namespace, usually shared with other workloads. No namespace is created and none is ever deleted. Because the namespace is not this run's alone, three things differ:

    Every object name carries the id of the run: a pod dbio-test-XXXXXXXX-pg behind a service dbio-test-XXXXXXXX-pg-svc, a job dbio-test-XXXXXXXX-runner.

    Pods carry only the labels dbio-test-run (the run id) and dbio-test-db (the database name), and each service selects on both. Services and the job carry the label dbio-test-run as well, so that it finds every object of a run. No selector written for another workload in the namespace matches these pods, so no service that was there before starts routing to them, and two runs at once do not see each other's pods.

    cleanup deletes exactly the objects this run created, each by kind and name, as recorded when the API accepted it -- never by label selector.

namespace and existing_namespace together throw. In both kinds cleanup works from what was recorded at creation, so it also clears up after a deploy_databases that died halfway; created_objects returns that record.

The databases are reached the same way in both kinds: a ClusterIP service per database, port-forwarded to a free local port by setup_port_forwards, or addressed by its cluster DNS name in cluster mode.

RESOURCE LIMITS

Every container this module creates carries a resources block with both requests and limits, and the limits always constrain cpu and memory. Test databases routinely run on small shared machines next to other services; an unlimited container that is forgotten about can starve or OOM the whole host, so the unlimited shape is not reachable from here.

The default envelope is 250m/256Mi requested, 1 core / 1Gi limited, matching the checked-in manifests of the driver distributions.

Database pods always get this default envelope: the database specs are built in, and none of them sets a resources of its own. deploy_test_job accepts a resources option to raise the ceiling for the test runner:

resources => {
    requests => { cpu => '500m', memory => '512Mi' },
    limits   => { cpu => '2',    memory => '4Gi'   },
},

A supplied resources that does not set both a cpu and a memory limit is a hard error, not a silent fallback to the default: asking for an unlimited (or half-limited) container is treated as a mistake worth stopping for.

AUTHOR

DBIO & DBIx::Class Authors

COPYRIGHT AND LICENSE

Copyright (C) 2026 DBIO Authors Portions Copyright (C) 2005-2025 DBIx::Class Authors Based on DBIx::Class, heavily modified.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.