Tiering logs/ to cold storage and expiring at 90 days in one call

One lifecycle call carries the prefix, the cold class and the delete day on Infrai — but there is no transition-day field. What's expressible, and what isn't.

Mostly yes. On Infrai the retention policy for a prefix is a single POST /v1/storage/bucket/set_lifecycle/{bucket} carrying a rule list, and one rule names the prefix, the colder storage class to move matched objects into, and the day count after which they’re deleted. What a rule can’t express is the when of the transition — there’s a transition_class field but no transition-day field, only expire_days. So “delete at 90” is exact and “cold at 30” is not.

That gap is the interesting part of the question, so let’s write the config first and then look at what the schema will and won’t take.

The call itself

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-retention-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"prefix":"logs/","transition_class":"STANDARD_IA","expire_days":90}]}'
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_2ea1b6a83cb64ad89b92f1",
    "name": "kb-retention-0726",
    "vendor": "cos",
    "region": "ap-singapore",
    "acl": "private",
    "cors_rules": [],
    "lifecycle_rules": [
      { "prefix": "logs/", "transition_class": "STANDARD_IA", "expire_days": 90 }
    ]
  }
}

A rule takes exactly three things. prefix is a plain key-prefix match, so logs/ covers everything below it. expire_days is an integer of at least 1. transition_class is the colder class, passed through to the backing vendor.

The submitted list replaces the current one wholesale — there’s no add-a-rule endpoint, so a deploy script that sets only the logs/ rule will drop the tmp/ rule somebody added last quarter. Read before you write:

curl -sS "https://api.infrai.cc/v1/storage/bucket/get/kb-retention-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_2ea1b6a83cb64ad89b92f1",
    "name": "kb-retention-0726",
    "region": "ap-singapore",
    "lifecycle_rules": [
      { "prefix": "logs/", "transition_class": "STANDARD_IA", "expire_days": 90 },
      { "prefix": "tmp/", "expire_days": 1 }
    ]
  }
}

The validator tells you when you’ve invented a field

This is why a 200 here is worth trusting. Try to smuggle in the transition day you wish existed, borrowing field names from another provider’s policy, and the request is refused before anything is stored:

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-retention-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"prefix":"logs/","transition_days":30,"storage_class":"STANDARD_IA","expire_days":90}]}'
{
  "ok": false,
  "error": {
    "code": "STORAGE_INVALID_LIFECYCLE_RULES",
    "http_status": 400,
    "message": "rule #0: unknown field(s): storage_class, transition_days",
    "retryable": false
  }
}

The rule index and the offending field names are both in the message, which matters when you’re pushing twelve rules from a config file. expire_days: 0 comes back the same way, with rule #0: expire_days must be >=1. A 200 from this endpoint therefore means every field you sent is a field the platform acts on — checked against the live API on 27 July 2026.

transition_class is vendor vocabulary rather than a friendly enum, and the accepted set belongs to the backing store. On the COS-backed bucket we tested, STANDARD_IA and DEEP_ARCHIVE were both taken; ARCHIVE, INTELLIGENT_TIERING and a lowercase glacier were not. Try your class string against a throwaway bucket before it reaches a deploy pipeline.

So where does the 30-day boundary go?

RequirementInfrai lifecycleAmazon S3 lifecycle
Match a prefixprefixFilter.Prefix
Delete after N daysexpire_daysExpiration.Days
Move to a colder classtransition_classTransition.StorageClass
Move after N daysnot expressibleTransition.Days
Rules per bucketreplace-the-listreplace-the-list
Cost of the config callfreefree

If that transition day is genuinely load-bearing — a contractual schedule, or a finance model that assumes 60 of the 90 days sit in the cheap tier — you’d be better off putting the log bucket on Backblaze B2 and using its native transition-day rules. One bucket, one exception, an honest call to make.

If it isn’t load-bearing, write the logs cold on day zero and let lifecycle handle only the delete:

LINE='{"ts":"2026-07-27T00:00:00Z","level":"info","msg":"rotated"}'
BODY=$(printf '{"data_base64":"%s","content_type":"application/x-ndjson","storage_class":"infrequent_access"}' \
  "$(printf '%s' "$LINE" | base64 | tr -d '\n')")

curl -sS -X PUT "https://api.infrai.cc/v1/storage/object/put/kb-retention-0726/logs/app/2026-07-27.jsonl" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "$BODY"

Note the body shape: this route carries base64 inside JSON rather than raw bytes, and it’s documented for small objects. A rotated log line is fine; a 200 MB archive isn’t, and past roughly 1 MB you want a presigned or multipart upload. Logs get written once and read almost never, so day zero is usually the right answer anyway — the 30-day boundary mostly exists in S3 examples because early deletion from a cold class carries a minimum-duration charge.

Audit it, because a rule you can’t observe isn’t a policy

This job answers one question: is anything under logs/ still here that the 90-day rule should have removed?

import process from "node:process";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const BUCKET = "kb-retention-0726";
const PREFIX = "logs/";
const MAX_AGE_DAYS = 90;

async function get(path) {
  const res = await fetch(`${API}${path}`, { method: "GET", headers: { Authorization: `Bearer ${KEY}` } });
  const json = await res.json();
  if (!res.ok || json.ok === false) {
    const e = json.error ?? {};
    throw new Error(`GET ${path} -> HTTP ${res.status} ${e.code ?? ""} ${e.message ?? ""}`);
  }
  return json.data;
}

const cutoff = Date.now() - MAX_AGE_DAYS * 86_400_000;
const overdue = [];
let cursor = null;
let scanned = 0;

do {
  const qs = new URLSearchParams({ prefix: PREFIX, limit: "1000" });
  if (cursor) qs.set("cursor", cursor);
  const page = await get(`/v1/storage/object/list/${BUCKET}?${qs}`);
  for (const obj of page.items) {
    scanned += 1;
    const modified = Date.parse(obj.last_modified ?? obj.created_at);
    if (Number.isFinite(modified) && modified < cutoff) overdue.push({ key: obj.key, size_bytes: obj.size_bytes });
  }
  cursor = page.next_cursor;
} while (cursor);

console.log(`scanned ${scanned} objects under ${PREFIX}; ${overdue.length} past ${MAX_AGE_DAYS} days`);
if (overdue.length) {
  const bytes = overdue.reduce((n, o) => n + o.size_bytes, 0);
  console.error(`${(bytes / 1e6).toFixed(1)} MB still present past the retention window`);
  process.exitCode = 1;
}

Confirm the listing by hand first. It’s free, so run it as often as you like, and each row carries content_type, size_bytes and last_modified:

curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-retention-0726?prefix=logs/&limit=10" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Here’s the part that would normally mean another vendor. Scheduling that audit is POST /v1/cron/create and raising the alarm when it trips is POST /v1/errors/capture — both on the same key that just wrote the lifecycle rule, with no second account, no second SDK, and nothing extra to reconcile at month end.

What it costs

Bucket create, set_lifecycle, bucket/get and object/list are all free and rate-limited, and none of them touches the $2 of credit a new account starts with — verified 27 July 2026. Read today’s numbers straight from the API:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free'), c['billing'].get('unit','')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"

Storage rates drift downward and campaigns run, so what you read is as likely to be lower as equal. The bill you’re actually managing here is GB-months of stored bytes, plus egress whenever something reads them back — exactly what the cold class and the 90-day expiry exist to shrink. And the limitation to carry away is the one at the top: the transition day lives in a scheduled job or in your head, never in the rule.

References

Browse more storage developer guides