Schedule icon
List icon
Commands icon
Docker icon
Log icon
If icon
Pause icon
SlackIncomingWebhook icon

Enforce a MinIO bucket lifecycle expiry and noncurrent version policy

Find MinIO buckets with no lifecycle rule, install an expiry plus noncurrent version policy behind an approval, then read it back and fail if absent.

Categories
DataInfrastructure

Findings

Measured against MinIO DEVELOPMENT.2025-05-24T17-08-30Z while this blueprint was built.

  • A fresh versioned bucket has no lifecycle configuration at all. get-bucket-lifecycle answers NoSuchLifecycleConfiguration rather than an empty rule set, so any detector that parses a configuration has to treat the error as the finding instead of an outage.
  • Nine current objects in the test bucket listed as 18 version entries. Nine noncurrent versions were already stored and billed, invisible to a normal listing, after nine overwrites.
  • MinIO accepts a NoncurrentVersionExpiration rule on a bucket whose versioning was never enabled and reads it straight back. The clause is inert there. That is why this flow reads get-bucket-versioning first and refuses to remediate a bucket that is not versioned, rather than writing a rule that looks like protection.
  • A lifecycle PUT replaces the whole configuration. A remediation that sends only its own rule silently drops every rule already on the bucket, so the rule document here carries the existing rules alongside the new one.
  • A PUT refused for lack of s3:PutLifecycleConfiguration is reported by the client, but the configuration read-back is the only thing that proves state changed. The re-verify task is the gate and it fails the run.
  • io.kestra.plugin.minio.List with filter: DIRECTORY throws a null pointer on plugin 1.4.8, because a common prefix entry has no lastModified and the task converts that field unconditionally. Top level prefixes are grouped from the recursive key listing instead.

Safety

The flow installs a policy. It never deletes an object itself and nothing disappears while the run is going. The policy it writes does delete later, on MinIO's own schedule: current objects under the target prefix once they pass expire_after_days. Noncurrent versions anywhere in the bucket go once they pass noncurrent_expire_after_days. The run prints the exact rule document plus the number of objects already past the window before anybody approves anything, so the size of the first sweep is known in advance.

Read-only is the default. dry_run is true and auto_remediate is false, both have to change for a write, the write also waits on a Pause, a bucket listed in protected_buckets is never written. A bucket whose versioning is not enabled is never written either.

How it works

  1. list_bucket_objects (io.kestra.plugin.minio.List) lists current objects. This is the count the console shows and it carries the lastModified of every key, which is what sizes the first expiry sweep.
  2. list_version_entries (the same plugin with includeVersions: true) lists the bucket again with version listing on. Every stored version and every delete marker comes back as its own entry, so the gap against the first listing is the noncurrent mass the noncurrent clause bounds.
  3. assess_lifecycle (io.kestra.plugin.scripts.shell.Commands on the AWS CLI image) reads get-bucket-versioning and get-bucket-lifecycle-configuration, joins them to both listings, and classifies the bucket as COMPLIANT, NO_CONFIG, MISSING_EXPIRATION, MISSING_NONCURRENT, VERSIONING_OFF or PROTECTED. It groups the keys into top level prefixes so the report says which prefixes the proposed rule covers and which are left growing. It writes the exact rule document the remediation would PUT and prints it, which is what makes a dry run useful. An unreadable bucket fails the task rather than reading as clean.
  4. log_assessment (io.kestra.plugin.core.log.Log) records the verdict and the counts whatever happens next.
  5. remediation_gate (io.kestra.plugin.core.flow.If) passes only when the bucket needs a rule, auto_remediate is true and dry_run is false. Everything else goes to log_no_action.
  6. approve_policy (io.kestra.plugin.core.flow.Pause) holds for an operator, who answers approved. A resume with no answer is read as a decline.
  7. apply_lifecycle PUTs the rule document. Its exit status is recorded and is deliberately not the gate.
  8. reverify_lifecycle reads the configuration back and fails the run unless an enabled rule covering the target prefix carries both the object expiry and the noncurrent expiry the inputs asked for. A failure here fires the errors block.

What you get

  • A verdict per bucket that separates "no configuration at all" from "a configuration that forgot noncurrent versions", which is the case an object-level audit misses.
  • The hidden version mass stated as a number, from two listings of the same bucket.
  • The size of the first expiry sweep before the policy goes in, so nobody approves a rule without knowing what it will delete.
  • The rule document printed in full on every run, dry or not.
  • A write that cannot happen by accident and cannot be reported as done unless the read-back agrees.

Who it's for

Platform and data teams running self-hosted MinIO or any S3-compatible object store where versioning was turned on and lifecycle policy never was. It suits the team that already pays for noncurrent version storage and wants the policy installed once, per bucket, with an approval and a receipt.

Why orchestrate this with Kestra

The read, the decision, the approval and the proof are one run with one audit trail. The inventory comes from a typed MinIO task rather than a parsed CLI dump, the approval is a first-class Pause instead of a ticket, the dangerous combination needs two inputs plus a human. The re-verify is a task that can fail the execution. A shell script on cron gives you none of that. A scheduled read-only audit is one disabled: false away.

Tested end to end

Run on Kestra 2.0.5 against MinIO DEVELOPMENT.2025-05-24T17-08-30Z on a shared Docker network, with the AWS CLI client from public.ecr.aws/aws-cli/aws-cli:latest.

  • A bucket already carrying an enabled rule with both clauses: verdict COMPLIANT, no action.
  • A versioned bucket with no configuration: verdict NO_CONFIG, nine current objects against 18 version entries, nine noncurrent, six objects under temp/, staging/ reported as left growing.
  • dry_run=true: the rule document printed, mc ilm ls still reported no configuration.
  • auto_remediate=true, dry_run=false, approved: the rule installed, the read-back confirmed both clauses. MinIO's own client printed kestra-expiry-guard at 30 days plus 7 days afterwards.
  • A bucket already carrying an expiry-only rule: verdict MISSING_NONCURRENT, remediated to two enabled rules, the pre-existing 45 day rule untouched.
  • Declined approval: nothing written, the configuration still absent.
  • A bucket named in protected_buckets, run with the write switches on: verdict PROTECTED, no action.
  • A bucket whose versioning is not enabled, run with the write switches on: verdict VERSIONING_OFF, no action.
  • A credential without s3:PutLifecycleConfiguration: the PUT was refused, the PUT task still reported success, the read-back found no rule and failed the run, which fired the Slack errors block.
  • An unreachable endpoint: the MinIO listing failed instead of reporting a clean bucket.
  • expire_after_days=0 on a fresh object moved the first-sweep count from 0 to 1, which proves the age window is computed rather than assumed.

Prerequisites

  • A MinIO or S3-compatible endpoint reachable from Kestra and from the client container.
  • Docker task runner available for the AWS CLI image.
  • Bucket versioning enabled on any bucket you intend to remediate.

Secrets

  • MINIO_ACCESS_KEY_ID and MINIO_SECRET_KEY_ID: credentials with s3:ListBucket, s3:ListBucketVersions, s3:GetBucketVersioning, s3:GetLifecycleConfiguration and, for the write, s3:PutLifecycleConfiguration.
  • SLACK_WEBHOOK_URL: incoming webhook for the failure alert.

Inputs

  • dry_run (BOOL, default true): report only.
  • auto_remediate (BOOL, default false): allow the write once dry_run is false.
  • bucket (STRING): the bucket to assess.
  • target_prefix (STRING, default temp/): prefix the object expiry clause applies to.
  • expire_after_days (INT, default 30): age at which MinIO deletes a current object under that prefix.
  • noncurrent_expire_after_days (INT, default 7): rollback window for noncurrent versions, bucket wide.
  • protected_buckets (STRING, default empty): comma-separated names this flow never writes to.
  • max_keys (INT, default 1000): ceiling per listing.
  • minio_endpoint, minio_access_key, minio_secret_key: connection.
  • awscli_image, docker_network: where the client runs.

Quick start

  1. Add MINIO_ACCESS_KEY_ID, MINIO_SECRET_KEY_ID and SLACK_WEBHOOK_URL to your namespace secrets.
  2. Run it with bucket set and nothing else changed. It writes nothing and prints the verdict, the version arithmetic and the rule document.
  3. Read the first-sweep count. If it is larger than you expected, raise expire_after_days or narrow target_prefix and run again.
  4. To install the rule, run with auto_remediate: true and dry_run: false, then answer approved: true on the pause.
  5. Confirm with MinIO's own client: mc ilm ls <alias>/<bucket>.
  6. Set disabled: false on the trigger for a weekly read-only audit.

How to extend

  • Add a core.flow.Loop with concurrencyLimit: 1 over a bucket list to assess a whole tenant in one run, keeping one approval per bucket.
  • Add AbortIncompleteMultipartUpload to the rule document for the orphaned multipart parts nobody sees.
  • Add a transition to a cheaper tier before the expiry for buckets where the data has to stay.
  • Write each verdict to KV so a later run can report which buckets changed since the last audit.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.