Preflight
Reference for abaca/preflight.
The abaca.preflight module implements the S3 conformance check suite that Abacá runs before accepting an S3 bucket as a usable backup target. Use these types and functions when you need to understand, extend, or test the enrollment preflight path — for example, when writing integration tests with the abaca-tempest-plugin, implementing a custom check injector, or diagnosing a failed target enrollment.
The module is intentionally split into two layers: abaca.preflight.result (stdlib-only types, safe to import anywhere) and abaca.preflight.runner / abaca.preflight.s3 (boto3-dependent orchestration, loaded lazily). This split lets unit tests construct and assert on result objects without requiring boto3 in the test environment.
PreflightInputs
The data bundle you assemble and pass to run_preflight. All secret fields are held in memory only for the duration of a single preflight call; the runner must not log them and must not persist them.
| Parameter | Type | Required | Description |
|---|---|---|---|
endpoint | str | Yes | Full URL of the S3-compatible endpoint (e.g. https://s3.us-east-1.wasabisys.com). |
bucket | str | Yes | Name of the S3 bucket to validate. |
region | str | Yes | AWS-style region string (e.g. us-east-1). Falls back to us-east-1 inside the boto3 session if empty, but should be set explicitly. |
access_key | str | Yes | S3 access key ID. Never logged or persisted by the runner. |
secret_key | str | Yes | S3 secret access key. Never logged or persisted by the runner. |
ca_cert | str | None | No | PEM-encoded CA certificate as a string (not a file path). Passed through Barbican before reaching the runner; materialised to a temporary file (mode 0600) for the duration of the boto3 session, then deleted. Pass None to use the system CA bundle. |
run_preflight
| Parameter | Type | Required | Description |
|---|---|---|---|
inputs | PreflightInputs | Yes | Connection and credential bundle for the target bucket. |
s3_factory | Callable | None | No | Injectable factory (inputs) -> (client, close_fn). When None, defaults to abaca.preflight.s3.build_s3_client. Provide a stub in tests to avoid requiring boto3. |
check_impls | dict | None | No | Injectable map of check name → check function. When None, defaults to the six functions in abaca.preflight.s3. Must cover all six check names if provided: endpoint_reach, rw_roundtrip, addressing_mode, object_lock, lifecycle_rules, bucket_contents. |
build_s3_client
| Parameter | Type | Required | Description |
|---|---|---|---|
inputs | PreflightInputs | Yes | Connection and credential bundle. |
addressing_style | str | No | One of "auto", "path", or "virtual". Defaults to "auto" (lets boto3 choose based on the endpoint hostname). The addressing-mode check calls this function twice with "virtual" and "path" explicitly to probe which style the server accepts. |
run_preflight → PreflightResult
run_preflight always returns a PreflightResult, even when checks fail or the endpoint is unreachable. It never raises — errors are captured as FAIL-status CheckResult entries.
PreflightResult fields:
| Field | Type | Description |
|---|---|---|
checks | list[CheckResult] | Ordered list of individual check outcomes. The list may be shorter than six entries if endpoint_reach fails and short-circuits the remaining checks. |
capabilities | dict[str, Any] | Discovered bucket properties extracted from check details. Used by downstream Kopia routing and retention alignment; downstream code reads from this dict rather than re-inspecting individual CheckResult entries. |
overall | CheckStatus (computed property) | Aggregate status across all checks. Precedence: fail > warn > adoptable > ok. |
capabilities keys populated by run_preflight:
| Key | Type | Set when |
|---|---|---|
addressing_mode | str | addressing_mode check passes. Value is "virtual_hosted" or "path". |
object_lock | bool | object_lock check returns OK or WARN. True if Object Lock is enabled on the bucket. |
object_lock_mode | str | object_lock check returns OK or WARN and the bucket has a default retention mode configured. Value mirrors the bucket's DefaultRetention.Mode (e.g. "GOVERNANCE" or "COMPLIANCE"). |
lifecycle_unverifiable | bool | Always set after the lifecycle_rules check. True if the check returned WARN (permissions prevented inspection); False if the check returned OK. |
reclaims_noncurrent_versions | bool | Set when lifecycle_rules returns OK. True if the bucket has a lifecycle rule that expires noncurrent object versions, allowing Kopia's reclaimed blobs to be collected. |
existing_repo_adoptable | bool (True) | Set when bucket_contents returns ADOPTABLE, indicating the bucket already contains a Kopia repository that enrollment may adopt rather than overwrite. |
PreflightResult.overall → CheckStatus
Returns the single highest-severity status across all checks in checks, evaluated in the order: FAIL > WARN > ADOPTABLE > OK. A result with no checks returns OK.
PreflightResult.first_failure → CheckResult | None
Returns the first CheckResult whose status is FAIL, or None if no check failed. Useful for surfacing the blocking error in API responses and log messages without iterating the full list.
CheckResult.to_dict → dict[str, Any]
Serialises the check record to a JSON-safe dict with the keys name, status, detail, and category. This is the structure persisted in the target row's preflight_detail column and returned verbatim in API responses.
PreflightResult.to_dict → dict[str, Any]
Serialises the full preflight outcome to a JSON-safe dict with the keys overall, checks (list of CheckResult.to_dict() results), and capabilities.
build_s3_client → (client, close_fn)
Returns a 2-tuple: a configured boto3 S3 client and a zero-argument close callable. Always call close() after you are done with the client — it deletes the temporary CA certificate file if one was materialised.
Individual check functions capture all exceptions internally and return a CheckResult with status=FAIL rather than raising. run_preflight itself does not raise under normal conditions.
The following conditions produce FAIL-status results with the indicated category:
| Check | Condition | category |
|---|---|---|
endpoint_reach | HeadBucket raises any exception (DNS failure, TCP timeout, TLS error, authentication error, bucket not found) | tenant_action_required |
rw_roundtrip | PutObject, GetObject, or readback content mismatch raises or mismatches | tenant_action_required |
addressing_mode | Neither "virtual" nor "path" style successfully reaches the bucket | tenant_action_required |
The following checks never produce FAIL results:
| Check | Maximum severity | Notes |
|---|---|---|
object_lock | WARN | Access-denied on GetObjectLockConfiguration yields WARN; missing configuration is treated as OK with enabled=False. |
lifecycle_rules | WARN | Inability to read lifecycle rules yields WARN; the presence of dangerous rules yields WARN or OK depending on rule content. |
bucket_contents | ADOPTABLE | A bucket already containing a Kopia repository (kopia.repository marker key present) returns ADOPTABLE, not FAIL. An empty or non-Kopia bucket returns OK. |
WARN results are recoverable-by-design. Enrollment proceeds on a WARN overall status; the capabilities dict records what was discovered so downstream layers can adapt. Only a FAIL overall status blocks enrollment.
category is only meaningful on FAIL results. WARN and ADOPTABLE results have category=None.
The two possible category values mirror abaca.common.exceptions.ErrorCategory:
| Value | Meaning |
|---|---|
tenant_action_required | The failure is attributable to tenant-supplied configuration (wrong credentials, wrong endpoint, missing bucket). The tenant must correct it. |
operator_action_required | The failure is attributable to infrastructure the operator controls. (No current check sets this category, but the field exists for future checks.) |
Assemble inputs and run preflight against a real bucket
from abaca.preflight.result import PreflightInputs, CheckStatus
from abaca.preflight.runner import run_preflight
inputs = PreflightInputs(
endpoint="https://s3.us-east-1.wasabisys.com",
bucket="acme-abaca-backups",
region="us-east-1",
access_key="AKIAIOSFODNN7EXAMPLE",
secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
ca_cert=None, # use system CA bundle
)
result = run_preflight(inputs)
print(result.to_dict())
Expected output (all checks passing, Object Lock enabled in GOVERNANCE mode, noncurrent-version expiration configured):
{
"overall": "ok",
"checks": [
{"name": "endpoint_reach", "status": "ok", "detail": {"endpoint": "https://s3.us-east-1.wasabisys.com", "bucket": "acme-abaca-backups"}, "category": null},
{"name": "rw_roundtrip", "status": "ok", "detail": {"sentinel_key": ".abaca-preflight/a3f1c2d4e5b6f7a8"}, "category": null},
{"name": "addressing_mode", "status": "ok", "detail": {"mode": "virtual_hosted", "attempts": {"virtual": "ok", "path": "ok"}}, "category": null},
{"name": "object_lock", "status": "ok", "detail": {"enabled": true, "config": {"ObjectLockEnabled": "Enabled", "Rule": {"DefaultRetention": {"Mode": "GOVERNANCE", "Days": 30}}}}, "category": null},
{"name": "lifecycle_rules", "status": "ok", "detail": {"reclaims_noncurrent_versions": true}, "category": null},
{"name": "bucket_contents", "status": "ok", "detail": {}, "category": null}
],
"capabilities": {
"addressing_mode": "virtual_hosted",
"object_lock": true,
"object_lock_mode": "GOVERNANCE",
"lifecycle_unverifiable": false,
"reclaims_noncurrent_versions": true
}
}
Detect a blocking failure and surface the reason
result = run_preflight(inputs)
if result.overall is CheckStatus.FAIL:
failure = result.first_failure()
print(f"Preflight blocked by '{failure.name}': {failure.detail}")
print(f"Action required by: {failure.category}")
Example output when the bucket is unreachable:
Preflight blocked by 'endpoint_reach': {'error': 'EndpointResolutionError', 'message': 'Could not resolve endpoint: ...'}
Action required by: tenant_action_required
Run preflight with stub checks in a unit test (no boto3 required)
Because run_preflight accepts injectable s3_factory and check_impls, you can unit-test any logic that consumes PreflightResult without a live S3 endpoint or boto3 installed:
from abaca.preflight.result import (
CheckResult, CheckStatus, PreflightInputs,
CHECK_ENDPOINT, CHECK_RW_ROUNDTRIP, CHECK_ADDRESSING,
CHECK_OBJECT_LOCK, CHECK_LIFECYCLE, CHECK_CONTENTS,
)
from abaca.preflight.runner import run_preflight
def _ok(name):
return CheckResult(name=name, status=CheckStatus.OK, detail={})
stub_checks = {
CHECK_ENDPOINT: lambda client, inputs: _ok(CHECK_ENDPOINT),
CHECK_RW_ROUNDTRIP: lambda client, inputs: _ok(CHECK_RW_ROUNDTRIP),
CHECK_ADDRESSING: lambda inputs: CheckResult(
name=CHECK_ADDRESSING, status=CheckStatus.OK,
detail={"mode": "path", "attempts": {"virtual": "HeadBucketError", "path": "ok"}}
),
CHECK_OBJECT_LOCK: lambda client, inputs: CheckResult(
name=CHECK_OBJECT_LOCK, status=CheckStatus.OK,
detail={"enabled": False}
),
CHECK_LIFECYCLE: lambda client, inputs: CheckResult(
name=CHECK_LIFECYCLE, status=CheckStatus.OK,
detail={"reclaims_noncurrent_versions": False}
),
CHECK_CONTENTS: lambda client, inputs: _ok(CHECK_CONTENTS),
}
# s3_factory returns a no-op client and a no-op close function
stub_factory = lambda inputs: (object(), lambda: None)
inputs = PreflightInputs(
endpoint="http://minio.local:9000",
bucket="test-bucket",
region="us-east-1",
access_key="minioadmin",
secret_key="minioadmin",
)
result = run_preflight(inputs, s3_factory=stub_factory, check_impls=stub_checks)
assert result.overall is CheckStatus.OK
assert result.capabilities["addressing_mode"] == "path"
assert result.capabilities["object_lock"] is False
assert result.capabilities["reclaims_noncurrent_versions"] is False
Handle the ADOPTABLE outcome during enrollment
When a bucket already contains a Kopia repository, the bucket_contents check returns ADOPTABLE and the overall status is also ADOPTABLE. This is not a failure — the enrollment path offers the operator the option to adopt the existing repository rather than initialise a fresh one:
from abaca.preflight.result import CheckStatus
result = run_preflight(inputs)
if result.overall is CheckStatus.ADOPTABLE:
print("Bucket contains an existing Kopia repository.")
print("You may adopt it during enrollment instead of creating a new one.")
elif result.overall is CheckStatus.WARN:
print("Enrollment can proceed; review warnings before confirming:")
for check in result.checks:
if check.status is CheckStatus.WARN:
print(f" WARN [{check.name}]: {check.detail}")
elif result.overall is CheckStatus.OK:
print("All checks passed. Safe to enroll.")
else:
failure = result.first_failure()
print(f"Cannot enroll: {failure.name} failed ({failure.category})")
Check execution order and short-circuiting. The six checks always run in this fixed order: endpoint_reach → rw_roundtrip → addressing_mode → object_lock → lifecycle_rules → bucket_contents. If endpoint_reach returns FAIL, the runner skips all remaining checks and returns immediately with a PreflightResult whose capabilities is an empty dict. This avoids firing additional network calls at a demonstrably unreachable endpoint.
preflight_detail stability. Check names (endpoint_reach, rw_roundtrip, addressing_mode, object_lock, lifecycle_rules, bucket_contents) are the stable identifiers persisted in the target row's preflight_detail column and surfaced verbatim in API responses. Do not rename them — downstream code and API consumers key on these strings.
Secret hygiene. PreflightInputs.access_key and PreflightInputs.secret_key must never appear in logs, exception messages, or CheckResult.detail dicts. The S3 check functions scrub error messages before storing them in detail. If you write a custom check implementation, you are responsible for the same hygiene. The ca_cert field is a PEM string, not a path; the runner materialises it to a temporary file with mode 0600 for the duration of the boto3 session and deletes it in the close() callback.
WARN does not block enrollment. A WARN overall result means enrollment proceeds, but with reduced assurance about one or more properties. For example, if object_lock returns WARN because the bucket's access policy prevents reading the lock configuration, Abacá records object_lock: false in capabilities and enrollment continues. Operators should review warnings before confirming production targets.
ADOPTABLE is distinct from OK. The ADOPTABLE status on bucket_contents means the bucket contains the kopia.repository marker object, indicating an existing Kopia repository. This propagates to PreflightResult.overall (since ADOPTABLE outranks OK in the precedence chain). The enrollment path interprets ADOPTABLE as a choice point — adopt the existing repository or abort — not as a disqualifying condition.
NoncurrentVersionExpiration lifecycle rules are intentionally permitted. Earlier versions of the preflight treated a noncurrent-version expiration rule as a fatal condition. This was reversed: Kopia's live data is always the current version of a key; a version becomes noncurrent only after Kopia itself overwrites or deletes the key. S3 Object Lock still enforces the WORM retention window on any version within its retain-until period, regardless of lifecycle rules. Permitting noncurrent expiration allows versioned buckets to reclaim storage from blobs Kopia has already discarded. The reclaims_noncurrent_versions capability key lets operators and tooling observe whether this collection is configured.
object_lock_mode must match the bucket's own default. The object_lock_mode capability is read from the bucket's DefaultRetention.Mode field, not inferred or defaulted by Abacá. Kopia must be told which mode to stamp on the objects it writes, and that mode must be the one the bucket was configured with. Applying COMPLIANCE retention to objects in a GOVERNANCE bucket would be irreversible — a COMPLIANCE retain-until cannot be shortened or removed by anyone, including the account owner.
boto3 is a lazy import. abaca.preflight.result depends only on the standard library. abaca.preflight.runner and abaca.preflight.s3 import boto3 only when run_preflight is called without injectable stubs. This means you can import and use result types in any context (tests, the API layer, the conductor) without boto3 being installed in that environment.