fix(lifecycle): preserve expiration days with marker cleanup (#170)

This commit is contained in:
cxymds
2026-07-17 17:50:39 +08:00
committed by GitHub
parent 8f1ff11c19
commit 7ad5c7761d
17 changed files with 317 additions and 58 deletions
+130 -37
View File
@@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next"
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -16,7 +16,15 @@ import { useTiers, type TierRow } from "@/hooks/use-tiers"
import { useMessage } from "@/lib/feedback/message"
import { randomUUID } from "@/lib/functions"
import { isMissingBucketConfiguration } from "@/lib/bucket-configuration"
import { buildCurrentVersionExpiration, buildLifecycleFilter } from "@/lib/bucket-lifecycle"
import {
buildCurrentVersionExpirationRules,
buildLifecycleFilter,
findIncompleteLifecycleTag,
getBucketVersioningMode,
hasCompleteLifecycleTags,
MAX_LIFECYCLE_RULES,
type BucketVersioningMode,
} from "@/lib/bucket-lifecycle"
interface LifecycleNewFormProps {
open: boolean
@@ -47,13 +55,20 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
const [tiersLoading, setTiersLoading] = useState(false)
const [tiersError, setTiersError] = useState("")
const [tiersReloadVersion, setTiersReloadVersion] = useState(0)
const [versioningStatus, setVersioningStatus] = useState(false)
const [versioningMode, setVersioningMode] = useState<BucketVersioningMode>("unversioned")
const [versioningLoading, setVersioningLoading] = useState(false)
const [versioningError, setVersioningError] = useState("")
const [versioningReloadVersion, setVersioningReloadVersion] = useState(0)
const [submitting, setSubmitting] = useState(false)
const [saveError, setSaveError] = useState("")
const [fieldErrors, setFieldErrors] = useState<{ days?: string; storageType?: string }>({})
const [fieldErrors, setFieldErrors] = useState<{
days?: string
storageType?: string
tags?: string
deleteMarker?: string
}>({})
const hasVersionHistory = versioningMode !== "unversioned"
const versionOptions = useMemo(
() => [
@@ -89,16 +104,16 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
const loadVersioningStatus = useCallback(async () => {
if (!bucketName) {
setVersioningStatus(false)
setVersioningMode("unversioned")
return
}
setVersioningLoading(true)
setVersioningError("")
try {
const resp = await getBucketVersioning(bucketName)
setVersioningStatus(resp.Status === "Enabled")
setVersioningMode(getBucketVersioningMode(resp.Status))
} catch {
setVersioningStatus(false)
setVersioningMode("unversioned")
setVersioningError(t("Unable to load versioning status."))
} finally {
setVersioningLoading(false)
@@ -113,8 +128,15 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
}, [open, loadTiers, loadVersioningStatus, tiersReloadVersion, versioningReloadVersion])
useEffect(() => {
if (versionType !== "current") setExpiredDeleteMark(false)
}, [versionType])
if (activeTab !== "expire" || versionType !== "current") setExpiredDeleteMark(false)
}, [activeTab, versionType])
useEffect(() => {
if (!hasVersionHistory) {
setVersionType("current")
setExpiredDeleteMark(false)
}
}, [hasVersionHistory])
const resetForm = useCallback(() => {
setActiveTab("expire")
@@ -141,20 +163,31 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
const removeTag = (index: number) => {
if (tags.length === 1) return
setTags((prev) => prev.filter((_, i) => i !== index))
setFieldErrors((current) => ({ ...current, tags: undefined, deleteMarker: undefined }))
}
const updateTag = (index: number, field: "key" | "value", value: string) => {
setTags((prev) => prev.map((tag, i) => (i === index ? { ...tag, [field]: value } : tag)))
setFieldErrors((current) => ({ ...current, tags: undefined, deleteMarker: undefined }))
}
const validate = () => {
const errors: { days?: string; storageType?: string } = {}
const errors: typeof fieldErrors = {}
if (!days || days < 1) {
errors.days = t("Please enter valid days")
}
if (activeTab === "transition" && !storageType) {
errors.storageType = t("Please select storage type")
}
const incompleteTagIndex = findIncompleteLifecycleTag(tags)
if (incompleteTagIndex >= 0) {
errors.tags = tags[incompleteTagIndex].key.trim() ? t("Please enter tag value") : t("Please enter key name")
}
if (activeTab === "expire" && versionType === "current" && expiredDeleteMark && hasCompleteLifecycleTags(tags)) {
errors.deleteMarker = `${t("Delete Marker Handling")}: ${t("Tags")} ${t("Unsupported")}`
}
setFieldErrors(errors)
const firstErrorId = errors.days
? activeTab === "transition"
@@ -162,7 +195,13 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
: "lifecycle-expire-days"
: errors.storageType
? "lifecycle-storage-type"
: null
: errors.tags && incompleteTagIndex >= 0
? tags[incompleteTagIndex].key.trim()
? `lifecycle-${activeTab}-tag-value-${incompleteTagIndex}`
: `lifecycle-${activeTab}-tag-key-${incompleteTagIndex}`
: errors.deleteMarker
? "lifecycle-expired-delete-marker"
: null
if (firstErrorId) document.getElementById(firstErrorId)?.focus()
return !firstErrorId
}
@@ -189,30 +228,38 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
}
}
const newRule: Record<string, unknown> = {
ID: randomUUID(),
const filter = buildLifecycleFilter(prefix, tags)
const baseId = randomUUID()
const baseRule: Record<string, unknown> = {
ID: baseId,
Status: "Enabled",
Filter: buildLifecycleFilter(prefix, tags),
Filter: filter,
}
const daysValue = Number(days)
let newRules: Record<string, unknown>[]
if (activeTab === "expire") {
if (versionType === "non-current") {
newRule.NoncurrentVersionExpiration = { NoncurrentDays: daysValue }
baseRule.NoncurrentVersionExpiration = { NoncurrentDays: daysValue }
newRules = [baseRule]
} else {
newRule.Expiration = buildCurrentVersionExpiration(daysValue, expiredDeleteMark)
newRules = buildCurrentVersionExpirationRules(baseId, daysValue, filter, expiredDeleteMark)
}
} else {
if (versionType === "non-current") {
newRule.NoncurrentVersionTransitions = [{ NoncurrentDays: daysValue, StorageClass: storageType }]
baseRule.NoncurrentVersionTransitions = [{ NoncurrentDays: daysValue, StorageClass: storageType }]
} else {
newRule.Transitions = [{ Days: daysValue, StorageClass: storageType }]
baseRule.Transitions = [{ Days: daysValue, StorageClass: storageType }]
}
newRules = [baseRule]
}
const existingRules = currentConfig?.Rules ?? []
const payload = { Rules: [...existingRules, newRule] }
if (existingRules.length + newRules.length > MAX_LIFECYCLE_RULES) {
throw new Error(t("Lifecycle configuration cannot contain more than 1000 rules."))
}
const payload = { Rules: [...existingRules, ...newRules] }
await putBucketLifecycleConfiguration(bucketName, payload)
message.success(t("Create Success"))
@@ -309,7 +356,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
<TabsContent value="expire" className="mt-0 space-y-6">
<div className="space-y-4">
{versioningStatus && (
{hasVersionHistory && (
<Field>
<FieldLabel>{t("Object Version")}</FieldLabel>
<FieldContent>
@@ -378,7 +425,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
</FieldContent>
</Field>
<div className="space-y-3">
<div className="space-y-3" aria-invalid={Boolean(fieldErrors.tags)}>
<div className="flex items-center justify-between">
<FieldLabel className="text-sm font-medium">{t("Tags")}</FieldLabel>
<Button type="button" variant="outline" size="sm" onClick={addTag} disabled={submitting}>
@@ -396,6 +443,10 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
value={tag.key}
onChange={(e) => updateTag(index, "key", e.target.value)}
aria-label={`${t("Tag Name")} ${index + 1}`}
aria-invalid={
Boolean(fieldErrors.tags) && Boolean(tag.key.trim()) !== Boolean(tag.value.trim())
}
aria-describedby={fieldErrors.tags ? "lifecycle-expire-tags-error" : undefined}
autoComplete="off"
placeholder={t("Tag Name")}
spellCheck={false}
@@ -407,6 +458,10 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
value={tag.value}
onChange={(e) => updateTag(index, "value", e.target.value)}
aria-label={`${t("Tag Value")} ${index + 1}`}
aria-invalid={
Boolean(fieldErrors.tags) && Boolean(tag.key.trim()) !== Boolean(tag.value.trim())
}
aria-describedby={fieldErrors.tags ? "lifecycle-expire-tags-error" : undefined}
autoComplete="off"
placeholder={t("Tag Value")}
className="flex-1"
@@ -428,39 +483,68 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
))}
</div>
)}
<FieldError id="lifecycle-expire-tags-error">{fieldErrors.tags}</FieldError>
</div>
</div>
</details>
{versionType === "current" && (
{hasVersionHistory && versionType === "current" && (
<details>
<summary className="cursor-pointer text-sm font-medium text-primary">
{t("Advanced Settings")}
</summary>
<Field className="mt-4">
<div className="flex items-center justify-between">
<div>
<FieldLabel htmlFor="lifecycle-expired-delete-marker" className="text-sm font-medium">
{t("Delete Marker Handling")}
</FieldLabel>
<FieldDescription>
<label
htmlFor="lifecycle-expired-delete-marker"
className="flex cursor-pointer items-start gap-3"
>
<Checkbox
id="lifecycle-expired-delete-marker"
name="lifecycle-expired-delete-marker"
checked={expiredDeleteMark}
aria-invalid={Boolean(fieldErrors.deleteMarker)}
aria-describedby={
fieldErrors.deleteMarker
? "lifecycle-expired-delete-marker-description lifecycle-expired-delete-marker-error"
: "lifecycle-expired-delete-marker-description"
}
onCheckedChange={(checked) => {
setExpiredDeleteMark(checked === true)
setFieldErrors((current) => ({ ...current, deleteMarker: undefined }))
}}
className="mt-1"
/>
<span>
<span className="block text-sm font-medium">{t("Delete Marker Handling")}</span>
<FieldDescription id="lifecycle-expired-delete-marker-description">
{t("If no versions remain, delete references to this object")}
</FieldDescription>
</div>
<Switch
id="lifecycle-expired-delete-marker"
checked={expiredDeleteMark}
onCheckedChange={setExpiredDeleteMark}
/>
</div>
</span>
</label>
<FieldError id="lifecycle-expired-delete-marker-error">{fieldErrors.deleteMarker}</FieldError>
</Field>
</details>
)}
{hasVersionHistory && versionType === "current" ? (
<Alert>
<AlertTitle>{t("Warning")}</AlertTitle>
<AlertDescription>
{t(
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.",
)}
</AlertDescription>
</Alert>
) : null}
{activeTab === "expire" && expiredDeleteMark ? (
<p className="text-sm text-muted-foreground">{t("2 lifecycle rules will be created.")}</p>
) : null}
</TabsContent>
<TabsContent value="transition" className="mt-0 space-y-6">
<div className="space-y-4">
{versioningStatus && (
{hasVersionHistory && (
<Field>
<FieldLabel>{t("Object Version")}</FieldLabel>
<FieldContent>
@@ -561,7 +645,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
</FieldContent>
</Field>
<div className="space-y-3">
<div className="space-y-3" aria-invalid={Boolean(fieldErrors.tags)}>
<div className="flex items-center justify-between">
<FieldLabel className="text-sm font-medium">{t("Tags")}</FieldLabel>
<Button type="button" variant="outline" size="sm" onClick={addTag} disabled={submitting}>
@@ -579,6 +663,10 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
value={tag.key}
onChange={(e) => updateTag(index, "key", e.target.value)}
aria-label={`${t("Tag Name")} ${index + 1}`}
aria-invalid={
Boolean(fieldErrors.tags) && Boolean(tag.key.trim()) !== Boolean(tag.value.trim())
}
aria-describedby={fieldErrors.tags ? "lifecycle-transition-tags-error" : undefined}
autoComplete="off"
placeholder={t("Tag Name")}
spellCheck={false}
@@ -590,6 +678,10 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
value={tag.value}
onChange={(e) => updateTag(index, "value", e.target.value)}
aria-label={`${t("Tag Value")} ${index + 1}`}
aria-invalid={
Boolean(fieldErrors.tags) && Boolean(tag.key.trim()) !== Boolean(tag.value.trim())
}
aria-describedby={fieldErrors.tags ? "lifecycle-transition-tags-error" : undefined}
autoComplete="off"
placeholder={t("Tag Value")}
className="flex-1"
@@ -611,6 +703,7 @@ export function LifecycleNewForm({ open, onOpenChange, bucketName, onSuccess }:
))}
</div>
)}
<FieldError id="lifecycle-transition-tags-error">{fieldErrors.tags}</FieldError>
</div>
</div>
</details>
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "يؤدي انتهاء صلاحية الإصدار الحالي إلى إنشاء علامة حذف. قم بتكوين انتهاء صلاحية الإصدارات غير الحالية لإزالة بيانات الكائن نهائيًا.",
"2 lifecycle rules will be created.": "سيتم إنشاء قاعدتين لدورة الحياة.",
"Lifecycle configuration cannot contain more than 1000 rules.": "لا يمكن أن يحتوي تكوين دورة الحياة على أكثر من 1000 قاعدة."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Beim Ablauf der aktuellen Version wird eine Löschmarkierung erstellt. Konfigurieren Sie den Ablauf nicht aktueller Versionen, um Objektdaten dauerhaft zu entfernen.",
"2 lifecycle rules will be created.": "Es werden 2 Lebenszyklusregeln erstellt.",
"Lifecycle configuration cannot contain more than 1000 rules.": "Die Lebenszykluskonfiguration darf nicht mehr als 1000 Regeln enthalten."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.",
"2 lifecycle rules will be created.": "2 lifecycle rules will be created.",
"Lifecycle configuration cannot contain more than 1000 rules.": "Lifecycle configuration cannot contain more than 1000 rules."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "La expiración de la versión actual crea un marcador de eliminación. Configura la expiración de versiones no actuales para eliminar permanentemente los datos del objeto.",
"2 lifecycle rules will be created.": "Se crearán 2 reglas de ciclo de vida.",
"Lifecycle configuration cannot contain more than 1000 rules.": "La configuración del ciclo de vida no puede contener más de 1000 reglas."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "L’expiration de la version actuelle crée un marqueur de suppression. Configurez l’expiration des versions non actuelles pour supprimer définitivement les données de l’objet.",
"2 lifecycle rules will be created.": "2 règles de cycle de vie seront créées.",
"Lifecycle configuration cannot contain more than 1000 rules.": "La configuration du cycle de vie ne peut pas contenir plus de 1 000 règles."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Kedaluwarsa versi saat ini membuat penanda penghapusan. Konfigurasikan kedaluwarsa versi nonaktif untuk menghapus data objek secara permanen.",
"2 lifecycle rules will be created.": "2 aturan siklus hidup akan dibuat.",
"Lifecycle configuration cannot contain more than 1000 rules.": "Konfigurasi siklus hidup tidak boleh berisi lebih dari 1000 aturan."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "La scadenza della versione corrente crea un indicatore di eliminazione. Configura la scadenza delle versioni non correnti per rimuovere definitivamente i dati dell’oggetto.",
"2 lifecycle rules will be created.": "Verranno create 2 regole del ciclo di vita.",
"Lifecycle configuration cannot contain more than 1000 rules.": "La configurazione del ciclo di vita non può contenere più di 1000 regole."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "現行バージョンの有効期限切れでは削除マーカーが作成されます。オブジェクトデータを完全に削除するには、非現行バージョンの有効期限を設定してください。",
"2 lifecycle rules will be created.": "2 件のライフサイクルルールが作成されます。",
"Lifecycle configuration cannot contain more than 1000 rules.": "ライフサイクル設定に含められるルールは 1000 件までです。"
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "현재 버전 만료 시 삭제 마커가 생성됩니다. 객체 데이터를 영구적으로 삭제하려면 비현재 버전 만료를 구성하세요.",
"2 lifecycle rules will be created.": "수명 주기 규칙 2개가 생성됩니다.",
"Lifecycle configuration cannot contain more than 1000 rules.": "수명 주기 구성에는 1000개를 초과하는 규칙을 포함할 수 없습니다."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "A expiração da versão atual cria um marcador de exclusão. Configure a expiração de versões não atuais para remover permanentemente os dados do objeto.",
"2 lifecycle rules will be created.": "Serão criadas 2 regras de ciclo de vida.",
"Lifecycle configuration cannot contain more than 1000 rules.": "A configuração do ciclo de vida não pode conter mais de 1000 regras."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Загрузить больше объектов",
"Scroll to load more objects": "Прокрутить для загрузки объектов",
"Back to top": "Перейти вверх",
"Go to bottom": "Перейти вниз"
"Go to bottom": "Перейти вниз",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "При истечении срока действия текущей версии создается маркер удаления. Настройте удаление неактуальных версий, чтобы окончательно удалить данные объекта.",
"2 lifecycle rules will be created.": "Будут созданы 2 правила жизненного цикла.",
"Lifecycle configuration cannot contain more than 1000 rules.": "Конфигурация жизненного цикла не может содержать более 1000 правил."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Geçerli sürümün süresi dolduğunda bir silme işareti oluşturulur. Nesne verilerini kalıcı olarak kaldırmak için geçerli olmayan sürümlerin süre sonunu yapılandırın.",
"2 lifecycle rules will be created.": "2 yaşam döngüsü kuralı oluşturulacak.",
"Lifecycle configuration cannot contain more than 1000 rules.": "Yaşam döngüsü yapılandırması 1000'den fazla kural içeremez."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "Loading more objects",
"Scroll to load more objects": "Scroll to load more objects",
"Back to top": "Back to top",
"Go to bottom": "Go to bottom"
"Go to bottom": "Go to bottom",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "Việc hết hạn phiên bản hiện tại sẽ tạo dấu xóa. Hãy cấu hình hết hạn phiên bản không hiện tại để xóa vĩnh viễn dữ liệu đối tượng.",
"2 lifecycle rules will be created.": "Sẽ tạo 2 quy tắc vòng đời.",
"Lifecycle configuration cannot contain more than 1000 rules.": "Cấu hình vòng đời không được chứa quá 1000 quy tắc."
}
+4 -1
View File
@@ -1501,5 +1501,8 @@
"Loading more objects": "正在加载更多对象",
"Scroll to load more objects": "滚动以加载更多对象",
"Back to top": "回到顶部",
"Go to bottom": "直达底部"
"Go to bottom": "直达底部",
"Current version expiration creates a delete marker. Configure non-current version expiration to permanently remove object data.": "当前版本过期时会创建删除标记。请配置非当前版本过期规则,以永久删除对象数据。",
"2 lifecycle rules will be created.": "将创建 2 条生命周期规则。",
"Lifecycle configuration cannot contain more than 1000 rules.": "生命周期配置不能包含超过 1000 条规则。"
}
+44 -3
View File
@@ -3,8 +3,26 @@ export interface LifecycleFilterTag {
value: string
}
export type BucketVersioningMode = "unversioned" | "enabled" | "suspended"
export const MAX_LIFECYCLE_RULES = 1000
export function getBucketVersioningMode(status?: string): BucketVersioningMode {
if (status === "Enabled") return "enabled"
if (status === "Suspended") return "suspended"
return "unversioned"
}
export function findIncompleteLifecycleTag(tags: LifecycleFilterTag[]): number {
return tags.findIndex((tag) => Boolean(tag.key.trim()) !== Boolean(tag.value.trim()))
}
export function hasCompleteLifecycleTags(tags: LifecycleFilterTag[]): boolean {
return tags.some((tag) => Boolean(tag.key.trim()) && Boolean(tag.value.trim()))
}
export function buildLifecycleFilter(prefix: string, tags: LifecycleFilterTag[]): Record<string, unknown> {
const validTags = tags.filter((tag) => tag.key && tag.value)
const validTags = tags.filter((tag) => tag.key.trim() && tag.value.trim())
if (!prefix && validTags.length === 0) return { Prefix: "" }
const [firstTag] = validTags
@@ -24,6 +42,29 @@ export function buildLifecycleFilter(prefix: string, tags: LifecycleFilterTag[])
return { Prefix: prefix }
}
export function buildCurrentVersionExpiration(days: number, expiredDeleteMarker: boolean): Record<string, unknown> {
return expiredDeleteMarker ? { ExpiredObjectDeleteMarker: true } : { Days: days }
export function buildCurrentVersionExpirationRules(
baseId: string,
days: number,
filter: Record<string, unknown>,
cleanupExpiredDeleteMarkers: boolean,
): Record<string, unknown>[] {
const rules: Record<string, unknown>[] = [
{
ID: `${baseId}-expiration`,
Status: "Enabled",
Filter: filter,
Expiration: { Days: days },
},
]
if (cleanupExpiredDeleteMarkers) {
rules.push({
ID: `${baseId}-delete-marker`,
Status: "Enabled",
Filter: filter,
Expiration: { ExpiredObjectDeleteMarker: true },
})
}
return rules
}
+87 -4
View File
@@ -1,13 +1,21 @@
import test from "node:test"
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import { PutBucketLifecycleConfigurationCommand, S3Client } from "@aws-sdk/client-s3"
import {
createLatestRequestGate,
isMissingBucketConfiguration,
normalizeReplicationRulesForRolelessConfig,
removeMatchingBucketRule,
} from "../../lib/bucket-configuration.ts"
import { buildCurrentVersionExpiration, buildLifecycleFilter } from "../../lib/bucket-lifecycle.ts"
import {
buildCurrentVersionExpirationRules,
buildLifecycleFilter,
findIncompleteLifecycleTag,
getBucketVersioningMode,
hasCompleteLifecycleTags,
MAX_LIFECYCLE_RULES,
} from "../../lib/bucket-lifecycle.ts"
import { getXmlErrorCode } from "../../lib/error-handler.ts"
const bucketInfoSource = await readFile(new URL("../../components/buckets/info.tsx", import.meta.url), "utf8")
@@ -84,11 +92,86 @@ test("latest request gate invalidates stale and unmounted responses", () => {
assert.equal(gate.isCurrent(second), false)
})
test("lifecycle payload helpers always emit a valid all-bucket filter and exclusive expiration fields", () => {
test("lifecycle payload helpers keep current expiration and delete-marker cleanup in separate rules", () => {
assert.deepEqual(buildLifecycleFilter("", [{ key: "", value: "" }]), { Prefix: "" })
assert.deepEqual(buildLifecycleFilter("logs/", []), { Prefix: "logs/" })
assert.deepEqual(buildCurrentVersionExpiration(30, false), { Days: 30 })
assert.deepEqual(buildCurrentVersionExpiration(30, true), { ExpiredObjectDeleteMarker: true })
const filter = { Prefix: "logs/" }
assert.deepEqual(buildCurrentVersionExpirationRules("rule", 30, filter, false), [
{
ID: "rule-expiration",
Status: "Enabled",
Filter: filter,
Expiration: { Days: 30 },
},
])
assert.deepEqual(buildCurrentVersionExpirationRules("rule", 30, filter, true), [
{
ID: "rule-expiration",
Status: "Enabled",
Filter: filter,
Expiration: { Days: 30 },
},
{
ID: "rule-delete-marker",
Status: "Enabled",
Filter: filter,
Expiration: { ExpiredObjectDeleteMarker: true },
},
])
})
test("AWS SDK serializes expiration days and delete-marker cleanup as two valid rules", async () => {
let requestBody = ""
const client = new S3Client({
region: "us-east-1",
endpoint: "http://127.0.0.1:9000",
credentials: { accessKeyId: "test", secretAccessKey: "test" },
requestHandler: {
async handle(request) {
requestBody = typeof request.body === "string" ? request.body : new TextDecoder().decode(request.body)
return { response: { statusCode: 200, headers: {}, body: new Uint8Array() } }
},
},
})
const filter = { Prefix: "logs/" }
await client.send(
new PutBucketLifecycleConfigurationCommand({
Bucket: "test-bucket",
LifecycleConfiguration: {
Rules: buildCurrentVersionExpirationRules("rule", 30, filter, true),
},
}),
)
client.destroy()
const expirations = [...requestBody.matchAll(/<Expiration>(.*?)<\/Expiration>/g)].map((match) => match[1])
assert.equal((requestBody.match(/<Rule>/g) ?? []).length, 2)
assert.deepEqual(expirations, ["<Days>30</Days>", "<ExpiredObjectDeleteMarker>true</ExpiredObjectDeleteMarker>"])
})
test("lifecycle helpers preserve suspended versioning and reject partial tag pairs", () => {
assert.equal(MAX_LIFECYCLE_RULES, 1000)
assert.equal(getBucketVersioningMode("Enabled"), "enabled")
assert.equal(getBucketVersioningMode("Suspended"), "suspended")
assert.equal(getBucketVersioningMode(undefined), "unversioned")
assert.equal(findIncompleteLifecycleTag([{ key: "", value: "" }]), -1)
assert.equal(findIncompleteLifecycleTag([{ key: "env", value: "" }]), 0)
assert.equal(findIncompleteLifecycleTag([{ key: "", value: "prod" }]), 0)
assert.equal(hasCompleteLifecycleTags([{ key: "env", value: "prod" }]), true)
assert.equal(hasCompleteLifecycleTags([{ key: "", value: "" }]), false)
assert.match(
lifecycleFormSource,
/buildCurrentVersionExpirationRules\(baseId, daysValue, filter, expiredDeleteMark\)/,
)
assert.match(lifecycleFormSource, /Rules: \[\.\.\.existingRules, \.\.\.newRules\]/)
assert.match(lifecycleFormSource, /existingRules\.length \+ newRules\.length > MAX_LIFECYCLE_RULES/)
assert.match(lifecycleFormSource, /const hasVersionHistory = versioningMode !== "unversioned"/)
assert.match(lifecycleFormSource, /expiredDeleteMark && hasCompleteLifecycleTags\(tags\)/)
assert.match(lifecycleFormSource, /setExpiredDeleteMark\(checked === true\)/)
})
test("bucket reads fail closed and stale responses cannot update the active bucket", () => {