Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 49 additions & 17 deletions components/buckets/lifecycle-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { LifecycleNewForm } from "@/components/lifecycle/new-form"
import { useDialog } from "@/lib/feedback/dialog"
import { useMessage } from "@/lib/feedback/message"
import { isMissingBucketConfiguration, removeMatchingBucketRule } from "@/lib/bucket-configuration"
import { getLifecycleActions } from "@/lib/bucket-lifecycle"
import type { ColumnDef } from "@tanstack/react-table"

interface LifecycleRule {
Expand All @@ -31,7 +32,7 @@ interface LifecycleRule {
ExpiredObjectDeleteMarker?: boolean
}
NoncurrentVersionExpiration?: { NoncurrentDays?: number }
Transitions?: Array<{ Days?: number; StorageClass?: string }>
Transitions?: Array<{ Days?: number; Date?: string; StorageClass?: string }>
NoncurrentVersionTransitions?: Array<{
NoncurrentDays?: number
StorageClass?: string
Expand Down Expand Up @@ -157,9 +158,18 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader
id: "version",
header: () => t("Version"),
accessorFn: (row) =>
row.NoncurrentVersionExpiration || row.NoncurrentVersionTransitions
? t("Non-current Version")
: t("Current Version"),
getLifecycleActions(row)
.map((action) => (action.version === "noncurrent" ? t("Non-current Version") : t("Current Version")))
.join(" / "),
cell: ({ row }) => (
<div className="space-y-1">
{getLifecycleActions(row.original).map((action, index) => (
<div key={`${action.type}-${action.version}-${index}`}>
{action.version === "noncurrent" ? t("Non-current Version") : t("Current Version")}
</div>
))}
</div>
),
},
{
id: "deleteMarker",
Expand All @@ -170,7 +180,17 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader
id: "tier",
header: () => t("Tier"),
accessorFn: (row) =>
row.Transitions?.[0]?.StorageClass || row.NoncurrentVersionTransitions?.[0]?.StorageClass || "--",
getLifecycleActions(row)
.map((action) => action.storageClass)
.filter(Boolean)
.join(" / ") || "--",
cell: ({ row }) => (
<div className="space-y-1">
{getLifecycleActions(row.original).map((action, index) => (
<div key={`${action.type}-${action.version}-${index}`}>{action.storageClass || "--"}</div>
))}
</div>
),
},
{
id: "prefix",
Expand All @@ -181,11 +201,18 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader
id: "timeCycle",
header: () => `${t("Time Cycle")} (${t("Days")})`,
accessorFn: (row) =>
row.Expiration?.Days ??
row.NoncurrentVersionExpiration?.NoncurrentDays ??
row.Transitions?.[0]?.Days ??
row.NoncurrentVersionTransitions?.[0]?.NoncurrentDays ??
"",
getLifecycleActions(row)
.map((action) => action.days ?? action.date ?? "")
.join(" / "),
cell: ({ row }) => (
<div className="space-y-1">
{getLifecycleActions(row.original).map((action, index) => (
<div key={`${action.type}-${action.version}-${index}`}>
{action.days !== undefined ? `${action.days} ${t("Days")}` : action.date || "--"}
</div>
))}
</div>
),
},
{
id: "status",
Expand Down Expand Up @@ -286,12 +313,8 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader
) : (
data.map((rule) => {
const identity = rule.ID ?? JSON.stringify(rule)
const type = rule.Transitions || rule.NoncurrentVersionTransitions ? t("Transition") : t("Expire")
const cycle =
rule.Expiration?.Days ??
rule.NoncurrentVersionExpiration?.NoncurrentDays ??
rule.Transitions?.[0]?.Days ??
rule.NoncurrentVersionTransitions?.[0]?.NoncurrentDays
const actions = getLifecycleActions(rule)
const type = actions.some((action) => action.type === "transition") ? t("Transition") : t("Expire")
return (
<article key={identity} className="space-y-4 border p-4">
<div className="flex items-start justify-between gap-3">
Expand All @@ -310,7 +333,16 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader
</div>
<div>
<dt className="text-xs text-muted-foreground">{t("Time Cycle")}</dt>
<dd className="tabular-nums">{cycle ? `${cycle} ${t("Days")}` : "-"}</dd>
<dd className="space-y-1 tabular-nums">
{actions.length > 0
? actions.map((action, index) => (
<div key={`${action.type}-${action.version}-${index}`}>
{action.version === "noncurrent" ? t("Non-current Version") : t("Current Version")}:{" "}
{action.days !== undefined ? `${action.days} ${t("Days")}` : action.date || "--"}
</div>
))
: "-"}
</dd>
</div>
</dl>
{canEditLifecycle ? (
Expand Down
10 changes: 10 additions & 0 deletions docs/screenshots/lifecycle-rule-desktop.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions docs/screenshots/lifecycle-rule-mobile.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 74 additions & 0 deletions lib/bucket-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,80 @@ export type BucketVersioningMode = "unversioned" | "enabled" | "suspended"

export const MAX_LIFECYCLE_RULES = 1000

export type LifecycleAction = {
type: "expiration" | "transition"
version: "current" | "noncurrent"
days?: number
date?: string
storageClass?: string
expiredObjectDeleteMarker?: boolean
}

type LifecycleRuleActionInput = {
Expiration?: {
Days?: number
Date?: string
StorageClass?: string
ExpiredObjectDeleteMarker?: boolean
}
NoncurrentVersionExpiration?: { NoncurrentDays?: number }
Transitions?: Array<{ Days?: number; Date?: string; StorageClass?: string }>
NoncurrentVersionTransitions?: Array<{ NoncurrentDays?: number; StorageClass?: string }>
}

export function getLifecycleActions(rule: LifecycleRuleActionInput): LifecycleAction[] {
const actions: LifecycleAction[] = []

const addAction = (action: LifecycleAction) => {
actions.push(
Object.fromEntries(Object.entries(action).filter(([, value]) => value !== undefined)) as LifecycleAction,
)
}

if (rule.Expiration) {
const { Days, Date, StorageClass, ExpiredObjectDeleteMarker } = rule.Expiration
if (Days !== undefined || Date !== undefined || ExpiredObjectDeleteMarker !== undefined) {
addAction({
type: "expiration",
version: "current",
days: Days,
date: Date,
storageClass: StorageClass,
expiredObjectDeleteMarker: ExpiredObjectDeleteMarker,
})
}
}

if (rule.NoncurrentVersionExpiration?.NoncurrentDays !== undefined) {
addAction({
type: "expiration",
version: "noncurrent",
days: rule.NoncurrentVersionExpiration.NoncurrentDays,
})
}

for (const transition of rule.Transitions ?? []) {
addAction({
type: "transition",
version: "current",
days: transition.Days,
date: transition.Date,
storageClass: transition.StorageClass,
})
}

for (const transition of rule.NoncurrentVersionTransitions ?? []) {
addAction({
type: "transition",
version: "noncurrent",
days: transition.NoncurrentDays,
storageClass: transition.StorageClass,
})
}

return actions
}

export function getBucketVersioningMode(status?: string): BucketVersioningMode {
if (status === "Enabled") return "enabled"
if (status === "Suspended") return "suspended"
Expand Down
31 changes: 31 additions & 0 deletions tests/lib/bucket-settings-safety.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
buildCurrentVersionExpirationRules,
buildLifecycleFilter,
buildNoncurrentVersionExpirationRule,
getLifecycleActions,
findIncompleteLifecycleTag,
getBucketVersioningMode,
hasCompleteLifecycleTags,
Expand Down Expand Up @@ -220,6 +221,36 @@ test("lifecycle helpers preserve suspended versioning and reject partial tag pai
)
})

test("lifecycle action projection preserves current and non-current actions", () => {
assert.deepEqual(
getLifecycleActions({
Expiration: { Days: 30 },
NoncurrentVersionExpiration: { NoncurrentDays: 40 },
}),
[
{ type: "expiration", version: "current", days: 30 },
{ type: "expiration", version: "noncurrent", days: 40 },
],
)
})

test("lifecycle action projection preserves every transition", () => {
assert.deepEqual(
getLifecycleActions({
Transitions: [
{ Days: 30, StorageClass: "STANDARD_IA" },
{ Date: "2030-01-01T00:00:00.000Z", StorageClass: "GLACIER" },
],
NoncurrentVersionTransitions: [{ NoncurrentDays: 10, StorageClass: "STANDARD_IA" }],
}),
[
{ type: "transition", version: "current", days: 30, storageClass: "STANDARD_IA" },
{ type: "transition", version: "current", date: "2030-01-01T00:00:00.000Z", storageClass: "GLACIER" },
{ type: "transition", version: "noncurrent", days: 10, storageClass: "STANDARD_IA" },
],
)
})

test("bucket reads fail closed and stale responses cannot update the active bucket", () => {
assert.match(bucketInfoSource, /Promise\.allSettled/)
assert.match(bucketInfoSource, /requestVersionRef/)
Expand Down
Loading