mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-07 07:06:33 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e701606fbe | ||
|
|
1e650d8ee3 | ||
|
|
e1cf678655 | ||
|
|
fa7da0f57e | ||
|
|
961c270aba | ||
|
|
e25558e4d8 | ||
|
|
587c24ec89 | ||
|
|
f249fb7e63 | ||
|
|
72c2c7ef8b | ||
|
|
d89eb8267f | ||
|
|
3f946fc0c0 | ||
|
|
af4c3fcb31 | ||
|
|
bfc430afbd | ||
|
|
540fc97e00 | ||
|
|
14cd0f53ba | ||
|
|
f9311a3422 | ||
|
|
338be16254 | ||
|
|
1b6e96614d | ||
|
|
4eb45ecc5e | ||
|
|
1f3df6e9ef | ||
|
|
fcd5de9710 | ||
|
|
b6f6f0187e |
@@ -60,16 +60,16 @@ jobs:
|
||||
build_args: TAGS=5BytesOffset
|
||||
tag_suffix: _large_disk
|
||||
|
||||
# Full tags - amd64 only
|
||||
# Full tags - multi-arch
|
||||
- variant: full
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
dockerfile: ./docker/Dockerfile.go_build
|
||||
build_args: TAGS=elastic,gocdk,rclone,sqlite,tarantool,tikv,ydb
|
||||
tag_suffix: _full
|
||||
|
||||
# Large disk + full tags - amd64 only
|
||||
|
||||
# Large disk + full tags - multi-arch
|
||||
- variant: large_disk_full
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
dockerfile: ./docker/Dockerfile.go_build
|
||||
build_args: TAGS=5BytesOffset,elastic,gocdk,rclone,sqlite,tarantool,tikv,ydb
|
||||
tag_suffix: _large_disk_full
|
||||
|
||||
@@ -23,8 +23,15 @@
|
||||
#
|
||||
# Adjust storageClass and sizes to match your cluster's available StorageClasses.
|
||||
# On OpenShift you can discover them with: oc get storageclass
|
||||
|
||||
global:
|
||||
enableReplication: true
|
||||
# replication type is XYZ:
|
||||
# X number of replica in other data centers
|
||||
# Y number of replica in other racks in the same data center
|
||||
# Z number of replica in other servers in the same rack
|
||||
replicationPlacement: "000" # no data replica
|
||||
master:
|
||||
replicas: 1
|
||||
data:
|
||||
type: "persistentVolumeClaim"
|
||||
size: "10Gi"
|
||||
@@ -49,6 +56,7 @@ master:
|
||||
type: RuntimeDefault
|
||||
|
||||
volume:
|
||||
replicas: 1
|
||||
dataDirs:
|
||||
- name: data1
|
||||
type: "persistentVolumeClaim"
|
||||
@@ -75,6 +83,7 @@ volume:
|
||||
type: RuntimeDefault
|
||||
|
||||
filer:
|
||||
replicas: 1
|
||||
data:
|
||||
type: "persistentVolumeClaim"
|
||||
size: "25Gi"
|
||||
|
||||
@@ -51,7 +51,7 @@ metadata:
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "-5"
|
||||
"helm.sh/hook-delete-policy": hook-succeeded
|
||||
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
@@ -92,6 +92,7 @@ spec:
|
||||
- "/bin/sh"
|
||||
- "-ec"
|
||||
- |
|
||||
set -o pipefail
|
||||
wait_for_service() {
|
||||
local url=$1
|
||||
local max_attempts=60 # 5 minutes total (5s * 60)
|
||||
@@ -117,8 +118,7 @@ spec:
|
||||
wait_for_service "http://$WEED_CLUSTER_SW_MASTER{{ .Values.master.readinessProbe.httpGet.path }}"
|
||||
wait_for_service "http://$WEED_CLUSTER_SW_FILER{{ .Values.filer.readinessProbe.httpGet.path }}"
|
||||
{{- end }}
|
||||
set -o pipefail
|
||||
{{- range $createBuckets }}
|
||||
{{- range $createBuckets }}
|
||||
{{- $bucketName := .name }}
|
||||
{{- $bucketLock := or .lock .objectLock .withLock }}
|
||||
bucket_list=$(/bin/echo 's3.bucket.list' | /usr/bin/weed shell) || { echo "Error listing s3 buckets"; exit 1; }
|
||||
|
||||
@@ -100,10 +100,12 @@ message ListEntriesRequest {
|
||||
string startFromFileName = 3;
|
||||
bool inclusiveStartFrom = 4;
|
||||
uint32 limit = 5;
|
||||
int64 snapshot_ts_ns = 6;
|
||||
}
|
||||
|
||||
message ListEntriesResponse {
|
||||
Entry entry = 1;
|
||||
int64 snapshot_ts_ns = 2;
|
||||
}
|
||||
|
||||
message RemoteEntry {
|
||||
@@ -203,6 +205,7 @@ message CreateEntryRequest {
|
||||
|
||||
message CreateEntryResponse {
|
||||
string error = 1;
|
||||
SubscribeMetadataResponse metadata_event = 2;
|
||||
}
|
||||
|
||||
message UpdateEntryRequest {
|
||||
@@ -212,6 +215,7 @@ message UpdateEntryRequest {
|
||||
repeated int32 signatures = 4;
|
||||
}
|
||||
message UpdateEntryResponse {
|
||||
SubscribeMetadataResponse metadata_event = 1;
|
||||
}
|
||||
|
||||
message AppendToEntryRequest {
|
||||
@@ -236,6 +240,7 @@ message DeleteEntryRequest {
|
||||
|
||||
message DeleteEntryResponse {
|
||||
string error = 1;
|
||||
SubscribeMetadataResponse metadata_event = 2;
|
||||
}
|
||||
|
||||
message AtomicRenameEntryRequest {
|
||||
@@ -469,6 +474,7 @@ message CacheRemoteObjectToLocalClusterRequest {
|
||||
}
|
||||
message CacheRemoteObjectToLocalClusterResponse {
|
||||
Entry entry = 1;
|
||||
SubscribeMetadataResponse metadata_event = 2;
|
||||
}
|
||||
|
||||
/////////////////////////
|
||||
|
||||
@@ -23,8 +23,6 @@ func TestAdminStatusAndHealthz(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create status request: %v", err)
|
||||
}
|
||||
statusReq.Header.Set(request_id.AmzRequestIDHeader, "test-request-id-1")
|
||||
|
||||
statusResp := framework.DoRequest(t, client, statusReq)
|
||||
statusBody := framework.ReadAllAndClose(t, statusResp)
|
||||
|
||||
@@ -34,8 +32,8 @@ func TestAdminStatusAndHealthz(t *testing.T) {
|
||||
if got := statusResp.Header.Get("Server"); !strings.Contains(got, "SeaweedFS Volume") {
|
||||
t.Fatalf("expected /status Server header to contain SeaweedFS Volume, got %q", got)
|
||||
}
|
||||
if got := statusResp.Header.Get(request_id.AmzRequestIDHeader); got != "test-request-id-1" {
|
||||
t.Fatalf("expected echoed request id, got %q", got)
|
||||
if got := statusResp.Header.Get(request_id.AmzRequestIDHeader); got == "" {
|
||||
t.Fatal("expected server-generated request id in response header")
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
@@ -49,7 +47,6 @@ func TestAdminStatusAndHealthz(t *testing.T) {
|
||||
}
|
||||
|
||||
healthReq := mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/healthz")
|
||||
healthReq.Header.Set(request_id.AmzRequestIDHeader, "test-request-id-2")
|
||||
healthResp := framework.DoRequest(t, client, healthReq)
|
||||
_ = framework.ReadAllAndClose(t, healthResp)
|
||||
if healthResp.StatusCode != http.StatusOK {
|
||||
@@ -58,8 +55,8 @@ func TestAdminStatusAndHealthz(t *testing.T) {
|
||||
if got := healthResp.Header.Get("Server"); !strings.Contains(got, "SeaweedFS Volume") {
|
||||
t.Fatalf("expected /healthz Server header to contain SeaweedFS Volume, got %q", got)
|
||||
}
|
||||
if got := healthResp.Header.Get(request_id.AmzRequestIDHeader); got != "test-request-id-2" {
|
||||
t.Fatalf("expected /healthz echoed request id, got %q", got)
|
||||
if got := healthResp.Header.Get(request_id.AmzRequestIDHeader); got == "" {
|
||||
t.Fatal("expected /healthz server-generated request id in response header")
|
||||
}
|
||||
|
||||
uiResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/ui/index.html"))
|
||||
|
||||
@@ -427,7 +427,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.Entry.IsDirectory {
|
||||
if resp.Entry != nil && resp.Entry.IsDirectory {
|
||||
bucketName := resp.Entry.Name
|
||||
if strings.HasPrefix(bucketName, ".") {
|
||||
// Skip internal/system directories from Object Store bucket listing.
|
||||
@@ -480,13 +480,18 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
|
||||
}
|
||||
}
|
||||
|
||||
var createdAt, lastModified time.Time
|
||||
if resp.Entry.Attributes != nil {
|
||||
createdAt = time.Unix(resp.Entry.Attributes.Crtime, 0)
|
||||
lastModified = time.Unix(resp.Entry.Attributes.Mtime, 0)
|
||||
}
|
||||
bucket := S3Bucket{
|
||||
Name: bucketName,
|
||||
CreatedAt: time.Unix(resp.Entry.Attributes.Crtime, 0),
|
||||
CreatedAt: createdAt,
|
||||
LogicalSize: logicalSize,
|
||||
PhysicalSize: physicalSize,
|
||||
ObjectCount: objectCount,
|
||||
LastModified: time.Unix(resp.Entry.Attributes.Mtime, 0),
|
||||
LastModified: lastModified,
|
||||
Quota: quota,
|
||||
QuotaEnabled: quotaEnabled,
|
||||
VersioningStatus: versioningStatus,
|
||||
|
||||
@@ -324,7 +324,7 @@ func (s *AdminServer) GetConsumerGroupOffsets(namespace, topicName string) ([]Co
|
||||
}
|
||||
|
||||
// Only process directories that are versions (start with "v")
|
||||
if versionResp.Entry.IsDirectory && strings.HasPrefix(versionResp.Entry.Name, "v") {
|
||||
if versionResp.Entry != nil && versionResp.Entry.IsDirectory && strings.HasPrefix(versionResp.Entry.Name, "v") {
|
||||
versionDir := filepath.Join(topicDir, versionResp.Entry.Name)
|
||||
|
||||
// List all partition directories under the version directory (e.g., 0315-0630)
|
||||
@@ -352,7 +352,7 @@ func (s *AdminServer) GetConsumerGroupOffsets(namespace, topicName string) ([]Co
|
||||
}
|
||||
|
||||
// Only process directories that are partitions (format: NNNN-NNNN)
|
||||
if partitionResp.Entry.IsDirectory {
|
||||
if partitionResp.Entry != nil && partitionResp.Entry.IsDirectory {
|
||||
// Parse partition range to get partition start ID (e.g., "0315-0630" -> 315)
|
||||
var partitionStart, partitionStop int32
|
||||
if n, err := fmt.Sscanf(partitionResp.Entry.Name, "%04d-%04d", &partitionStart, &partitionStop); n != 2 || err != nil {
|
||||
@@ -387,11 +387,11 @@ func (s *AdminServer) GetConsumerGroupOffsets(namespace, topicName string) ([]Co
|
||||
}
|
||||
|
||||
// Only process .offset files
|
||||
if !offsetResp.Entry.IsDirectory && strings.HasSuffix(offsetResp.Entry.Name, ".offset") {
|
||||
if offsetResp.Entry != nil && !offsetResp.Entry.IsDirectory && strings.HasSuffix(offsetResp.Entry.Name, ".offset") {
|
||||
consumerGroup := strings.TrimSuffix(offsetResp.Entry.Name, ".offset")
|
||||
|
||||
// Read the offset value from the file
|
||||
offsetData, err := filer.ReadInsideFiler(client, partitionDir, offsetResp.Entry.Name)
|
||||
offsetData, err := filer.ReadInsideFiler(context.Background(), client, partitionDir, offsetResp.Entry.Name)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to read offset file %s: %v", offsetResp.Entry.Name, err)
|
||||
continue
|
||||
@@ -401,7 +401,10 @@ func (s *AdminServer) GetConsumerGroupOffsets(namespace, topicName string) ([]Co
|
||||
offset := int64(util.BytesToUint64(offsetData))
|
||||
|
||||
// Get the file modification time
|
||||
lastUpdated := time.Unix(offsetResp.Entry.Attributes.Mtime, 0)
|
||||
var lastUpdated time.Time
|
||||
if offsetResp.Entry.Attributes != nil {
|
||||
lastUpdated = time.Unix(offsetResp.Entry.Attributes.Mtime, 0)
|
||||
}
|
||||
|
||||
offsets = append(offsets, ConsumerGroupOffsetInfo{
|
||||
ConsumerGroup: consumerGroup,
|
||||
|
||||
@@ -151,17 +151,21 @@ func (p *TopicRetentionPurger) purgeTopicData(topicRetention TopicRetentionConfi
|
||||
}
|
||||
|
||||
// Only process directories that are versions (start with "v")
|
||||
if versionResp.Entry.IsDirectory && strings.HasPrefix(versionResp.Entry.Name, "v") {
|
||||
if versionResp.Entry != nil && versionResp.Entry.IsDirectory && strings.HasPrefix(versionResp.Entry.Name, "v") {
|
||||
versionTime, err := p.parseVersionTime(versionResp.Entry.Name)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to parse version time from %s: %v", versionResp.Entry.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var modTime time.Time
|
||||
if versionResp.Entry.Attributes != nil {
|
||||
modTime = time.Unix(versionResp.Entry.Attributes.Mtime, 0)
|
||||
}
|
||||
versionDirs = append(versionDirs, VersionDirInfo{
|
||||
Name: versionResp.Entry.Name,
|
||||
VersionTime: versionTime,
|
||||
ModTime: time.Unix(versionResp.Entry.Attributes.Mtime, 0),
|
||||
ModTime: modTime,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -260,6 +264,9 @@ func (p *TopicRetentionPurger) deleteDirectoryRecursively(client filer_pb.Seawee
|
||||
return fmt.Errorf("failed to receive entries: %w", err)
|
||||
}
|
||||
|
||||
if resp.Entry == nil {
|
||||
continue
|
||||
}
|
||||
entryPath := filepath.Join(dirPath, resp.Entry.Name)
|
||||
|
||||
if resp.Entry.IsDirectory {
|
||||
|
||||
@@ -112,7 +112,6 @@ func (r *Plugin) runSchedulerIteration() bool {
|
||||
}
|
||||
|
||||
active := make(map[string]struct{}, len(jobTypes))
|
||||
schedulerIdleSleep := r.GetSchedulerConfig().IdleSleepDuration()
|
||||
hadJobs := false
|
||||
|
||||
for _, jobType := range jobTypes {
|
||||
@@ -129,7 +128,7 @@ func (r *Plugin) runSchedulerIteration() bool {
|
||||
}
|
||||
initialDelay := time.Duration(0)
|
||||
if runInfo := r.snapshotSchedulerRun(jobType); runInfo.lastRunStartedAt.IsZero() {
|
||||
initialDelay = schedulerIdleSleep / 2
|
||||
initialDelay = 5 * time.Second
|
||||
}
|
||||
if !r.markDetectionDue(jobType, policy.DetectionInterval, initialDelay) {
|
||||
continue
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* SeaweedFS Dashboard Custom Styles */
|
||||
|
||||
:root {
|
||||
--navbar-height: 56px;
|
||||
}
|
||||
|
||||
/* Link colors - muted */
|
||||
a {
|
||||
color: #5b7c99;
|
||||
@@ -12,7 +16,7 @@ a:hover {
|
||||
/* Sidebar Styles */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
top: var(--navbar-height);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
@@ -51,13 +55,32 @@ main {
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.sidebar {
|
||||
top: 5rem;
|
||||
top: var(--navbar-height);
|
||||
padding-top: 0;
|
||||
width: 240px;
|
||||
background-color: #f8f9fa !important;
|
||||
z-index: 1050;
|
||||
}
|
||||
.sidebar.show ~ .sidebar-backdrop {
|
||||
display: block;
|
||||
}
|
||||
main {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar backdrop for mobile overlay */
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: var(--navbar-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1040;
|
||||
}
|
||||
|
||||
/* Custom card styles */
|
||||
.border-left-primary {
|
||||
border-left: 0.25rem solid #6b8caf !important;
|
||||
@@ -262,6 +285,11 @@ main {
|
||||
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Navbar user icon color */
|
||||
.navbar-toggler .fa-user {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
/* Collapsible menu styles */
|
||||
.nav-link[data-bs-toggle="collapse"] {
|
||||
position: relative;
|
||||
|
||||
@@ -31,6 +31,9 @@ function initializeDashboard() {
|
||||
|
||||
// Set up submenu behavior
|
||||
setupSubmenuBehavior();
|
||||
|
||||
// Set up mobile sidebar behavior
|
||||
setupMobileSidebar();
|
||||
}
|
||||
|
||||
// HTMX event listeners
|
||||
@@ -194,6 +197,33 @@ function setupSubmenuBehavior() {
|
||||
|
||||
}
|
||||
|
||||
// Mobile sidebar toggle and backdrop behavior
|
||||
function setupMobileSidebar() {
|
||||
const sidebar = document.getElementById('sidebarMenu');
|
||||
const backdrop = document.getElementById('sidebarBackdrop');
|
||||
if (!sidebar || !backdrop) return;
|
||||
|
||||
const hideSidebar = () => {
|
||||
const bsCollapse = bootstrap.Collapse.getInstance(sidebar);
|
||||
if (bsCollapse) {
|
||||
bsCollapse.hide();
|
||||
}
|
||||
};
|
||||
|
||||
// Close sidebar when backdrop is clicked
|
||||
backdrop.addEventListener('click', hideSidebar);
|
||||
|
||||
// Close sidebar when a nav link is clicked (on mobile)
|
||||
const sidebarToggler = document.querySelector("button[data-bs-target='#sidebarMenu']");
|
||||
sidebar.querySelectorAll('a.nav-link:not([data-bs-toggle="collapse"])').forEach(function (link) {
|
||||
link.addEventListener('click', function () {
|
||||
if (sidebarToggler && getComputedStyle(sidebarToggler).display !== 'none') {
|
||||
hideSidebar();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Loading indicator functions
|
||||
function showLoadingIndicator() {
|
||||
const indicator = document.getElementById('loading-indicator');
|
||||
|
||||
@@ -65,7 +65,7 @@ templ Plugin(page string) {
|
||||
|
||||
<div class="plugin-section plugin-section-overview">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-info h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-uppercase text-muted small mb-1">Workers</div>
|
||||
@@ -73,7 +73,7 @@ templ Plugin(page string) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-warning h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-uppercase text-muted small mb-1">Active Jobs</div>
|
||||
@@ -81,7 +81,7 @@ templ Plugin(page string) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-success h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-uppercase text-muted small mb-1">Activities (recent)</div>
|
||||
@@ -89,6 +89,15 @@ templ Plugin(page string) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-secondary h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-uppercase text-muted small mb-1">Next Run</div>
|
||||
<h4 class="mb-0 plugin-scheduler-next-run">-</h4>
|
||||
<div class="text-muted small plugin-scheduler-next-run-meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
@@ -148,38 +157,6 @@ templ Plugin(page string) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-sliders-h me-2"></i>Scheduler Settings</h5>
|
||||
<small class="text-muted">Global</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="plugin-scheduler-idle-sleep-overview">Sleep Between Iterations (s)</label>
|
||||
<input type="number" class="form-control" id="plugin-scheduler-idle-sleep-overview" min="0"/>
|
||||
<div class="form-text">Used when no jobs are detected.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary" id="plugin-save-scheduler-btn-overview">
|
||||
<i class="fas fa-save me-1"></i>Save Scheduler Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-hourglass-half me-2"></i>Next Run</h5>
|
||||
<small class="text-muted">Scheduler</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="h5 mb-1 plugin-scheduler-next-run">-</div>
|
||||
<div class="text-muted small plugin-scheduler-next-run-meta">Not scheduled</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
@@ -274,6 +251,11 @@ templ Plugin(page string) {
|
||||
<input class="form-check-input" type="checkbox" id="plugin-admin-enabled"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="plugin-admin-detection-interval">Detection Interval (s)</label>
|
||||
<input type="number" class="form-control" id="plugin-admin-detection-interval" min="0"/>
|
||||
<div class="form-text">How often to check for new work.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="plugin-admin-detection-timeout">Detection Timeout (s)</label>
|
||||
<input type="number" class="form-control" id="plugin-admin-detection-timeout" min="0"/>
|
||||
@@ -306,22 +288,6 @@ templ Plugin(page string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-clock me-2"></i>Scheduler Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="plugin-scheduler-idle-sleep">Sleep Between Iterations (s)</label>
|
||||
<input type="number" class="form-control" id="plugin-scheduler-idle-sleep" min="0"/>
|
||||
<div class="form-text">Used when no jobs are detected.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary" id="plugin-save-scheduler-btn">
|
||||
<i class="fas fa-save me-1"></i>Save Scheduler Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-hourglass-half me-2"></i>Next Run</h5>
|
||||
@@ -632,8 +598,6 @@ templ Plugin(page string) {
|
||||
activities: [],
|
||||
schedulerStates: [],
|
||||
schedulerStatus: null,
|
||||
schedulerConfig: null,
|
||||
schedulerConfigLoaded: false,
|
||||
allJobs: [],
|
||||
allActivities: [],
|
||||
loadedJobType: '',
|
||||
@@ -2461,6 +2425,7 @@ templ Plugin(page string) {
|
||||
}
|
||||
|
||||
document.getElementById('plugin-admin-enabled').checked = pickBool('enabled');
|
||||
document.getElementById('plugin-admin-detection-interval').value = String(pickNumber('detection_interval_seconds'));
|
||||
document.getElementById('plugin-admin-detection-timeout').value = String(pickNumber('detection_timeout_seconds'));
|
||||
document.getElementById('plugin-admin-max-runtime').value = String(pickNumber('job_type_max_runtime_seconds'));
|
||||
document.getElementById('plugin-admin-max-results').value = String(pickNumber('max_jobs_per_detection'));
|
||||
@@ -2471,9 +2436,6 @@ templ Plugin(page string) {
|
||||
}
|
||||
|
||||
function collectAdminSettings() {
|
||||
var existingRuntime = (state.config && state.config.admin_runtime) ? state.config.admin_runtime : {};
|
||||
var existingDetectionInterval = Number(existingRuntime.detection_interval_seconds || 0);
|
||||
|
||||
function getInt(id) {
|
||||
var raw = String(document.getElementById(id).value || '').trim();
|
||||
if (!raw) {
|
||||
@@ -2488,7 +2450,7 @@ templ Plugin(page string) {
|
||||
|
||||
return {
|
||||
enabled: !!document.getElementById('plugin-admin-enabled').checked,
|
||||
detection_interval_seconds: existingDetectionInterval,
|
||||
detection_interval_seconds: getInt('plugin-admin-detection-interval'),
|
||||
detection_timeout_seconds: getInt('plugin-admin-detection-timeout'),
|
||||
job_type_max_runtime_seconds: getInt('plugin-admin-max-runtime'),
|
||||
max_jobs_per_detection: getInt('plugin-admin-max-results'),
|
||||
@@ -2806,75 +2768,6 @@ templ Plugin(page string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedulerConfig(forceRefresh) {
|
||||
if (state.schedulerConfigLoaded && !forceRefresh) {
|
||||
return;
|
||||
}
|
||||
var idleInputs = [
|
||||
document.getElementById('plugin-scheduler-idle-sleep'),
|
||||
document.getElementById('plugin-scheduler-idle-sleep-overview'),
|
||||
].filter(Boolean);
|
||||
if (idleInputs.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var cfg = await pluginRequest('GET', '/api/plugin/scheduler-config');
|
||||
state.schedulerConfig = cfg || {};
|
||||
state.schedulerConfigLoaded = true;
|
||||
var idleSeconds = Number((cfg && cfg.idle_sleep_seconds) || 0);
|
||||
idleInputs.forEach(function(input) {
|
||||
input.value = idleSeconds > 0 ? String(idleSeconds) : '';
|
||||
});
|
||||
} catch (e) {
|
||||
notify('Failed to load scheduler config: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchedulerConfig(sourceInput) {
|
||||
var idleInputs = [
|
||||
document.getElementById('plugin-scheduler-idle-sleep'),
|
||||
document.getElementById('plugin-scheduler-idle-sleep-overview'),
|
||||
].filter(Boolean);
|
||||
if (idleInputs.length === 0) {
|
||||
return;
|
||||
}
|
||||
var raw = '';
|
||||
if (sourceInput) {
|
||||
raw = String(sourceInput.value || '').trim();
|
||||
}
|
||||
if (!raw) {
|
||||
for (var i = 0; i < idleInputs.length; i++) {
|
||||
if (idleInputs[i] === sourceInput) {
|
||||
continue;
|
||||
}
|
||||
var candidate = String(idleInputs[i].value || '').trim();
|
||||
if (candidate) {
|
||||
raw = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var parsed = raw ? parseInt(raw, 10) : 0;
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
notify('Invalid idle sleep value', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var updated = await pluginRequest('PUT', '/api/plugin/scheduler-config', {
|
||||
idle_sleep_seconds: parsed,
|
||||
});
|
||||
state.schedulerConfig = updated || {};
|
||||
state.schedulerConfigLoaded = true;
|
||||
var idleSeconds = Number((updated && updated.idle_sleep_seconds) || 0);
|
||||
idleInputs.forEach(function(input) {
|
||||
input.value = idleSeconds > 0 ? String(idleSeconds) : '';
|
||||
});
|
||||
notify('Scheduler settings saved', 'success');
|
||||
} catch (e) {
|
||||
notify('Failed to save scheduler config: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxResults() {
|
||||
var raw = String(document.getElementById('plugin-admin-max-results').value || '').trim();
|
||||
if (!raw) {
|
||||
@@ -3051,19 +2944,6 @@ templ Plugin(page string) {
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
var saveSchedulerBtn = document.getElementById('plugin-save-scheduler-btn');
|
||||
if (saveSchedulerBtn) {
|
||||
saveSchedulerBtn.addEventListener('click', function() {
|
||||
saveSchedulerConfig(document.getElementById('plugin-scheduler-idle-sleep'));
|
||||
});
|
||||
}
|
||||
var saveSchedulerBtnOverview = document.getElementById('plugin-save-scheduler-btn-overview');
|
||||
if (saveSchedulerBtnOverview) {
|
||||
saveSchedulerBtnOverview.addEventListener('click', function() {
|
||||
saveSchedulerConfig(document.getElementById('plugin-scheduler-idle-sleep-overview'));
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('plugin-trigger-detection-btn').addEventListener('click', function() {
|
||||
runDetection();
|
||||
});
|
||||
@@ -3148,7 +3028,6 @@ templ Plugin(page string) {
|
||||
ensureActiveNavigation();
|
||||
renderNavigationState();
|
||||
await refreshAll();
|
||||
await loadSchedulerConfig(false);
|
||||
|
||||
state.refreshTimer = setInterval(function() {
|
||||
refreshAll();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -58,12 +58,14 @@ templ Layout(view ViewContext, content templ.Component) {
|
||||
<a class="navbar-brand fw-bold" href="/admin">
|
||||
<i class="fas fa-server me-2"></i>
|
||||
SeaweedFS Admin
|
||||
<span class="badge bg-warning text-dark ms-2">ALPHA</span>
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<button class="navbar-toggler d-md-none me-2" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation menu">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="User menu">
|
||||
<i class="fas fa-user"></i>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
@@ -84,7 +86,7 @@ templ Layout(view ViewContext, content templ.Component) {
|
||||
|
||||
<div class="row g-0">
|
||||
<!-- Sidebar -->
|
||||
<div class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
|
||||
<div class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse" id="sidebarMenu">
|
||||
<div class="position-sticky pt-3">
|
||||
<h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted">
|
||||
<span>MAIN</span>
|
||||
@@ -245,19 +247,6 @@ templ Layout(view ViewContext, content templ.Component) {
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
<!-- Commented out for later -->
|
||||
<!--
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/metrics">
|
||||
<i class="fas fa-chart-line me-2"></i>Metrics
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/logs">
|
||||
<i class="fas fa-file-alt me-2"></i>Logs
|
||||
</a>
|
||||
</li>
|
||||
-->
|
||||
</ul>
|
||||
|
||||
<h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted">
|
||||
@@ -323,6 +312,9 @@ templ Layout(view ViewContext, content templ.Component) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar backdrop for mobile -->
|
||||
<div class="sidebar-backdrop" id="sidebarBackdrop"></div>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-3 px-md-4">
|
||||
<div class="pt-3">
|
||||
|
||||
@@ -71,20 +71,20 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><link rel=\"icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\"><!-- Bootstrap CSS --><link href=\"/static/css/bootstrap.min.css\" rel=\"stylesheet\"><!-- Font Awesome CSS --><link href=\"/static/css/fontawesome.min.css\" rel=\"stylesheet\"><!-- HTMX --><script src=\"/static/js/htmx.min.js\"></script><!-- Custom CSS --><link rel=\"stylesheet\" href=\"/static/css/admin.css\"></head><body><div class=\"container-fluid p-0\"><!-- Header --><header class=\"navbar navbar-expand-lg navbar-dark bg-primary sticky-top\"><div class=\"container-fluid\"><a class=\"navbar-brand fw-bold\" href=\"/admin\"><i class=\"fas fa-server me-2\"></i> SeaweedFS Admin <span class=\"badge bg-warning text-dark ms-2\">ALPHA</span></a> <button class=\"navbar-toggler\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#navbarNav\"><span class=\"navbar-toggler-icon\"></span></button><div class=\"collapse navbar-collapse\" id=\"navbarNav\"><ul class=\"navbar-nav ms-auto\"><li class=\"nav-item dropdown\"><a class=\"nav-link dropdown-toggle\" href=\"#\" role=\"button\" data-bs-toggle=\"dropdown\"><i class=\"fas fa-user me-1\"></i>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><link rel=\"icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\"><!-- Bootstrap CSS --><link href=\"/static/css/bootstrap.min.css\" rel=\"stylesheet\"><!-- Font Awesome CSS --><link href=\"/static/css/fontawesome.min.css\" rel=\"stylesheet\"><!-- HTMX --><script src=\"/static/js/htmx.min.js\"></script><!-- Custom CSS --><link rel=\"stylesheet\" href=\"/static/css/admin.css\"></head><body><div class=\"container-fluid p-0\"><!-- Header --><header class=\"navbar navbar-expand-lg navbar-dark bg-primary sticky-top\"><div class=\"container-fluid\"><a class=\"navbar-brand fw-bold\" href=\"/admin\"><i class=\"fas fa-server me-2\"></i> SeaweedFS Admin</a> <button class=\"navbar-toggler d-md-none me-2\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#sidebarMenu\" aria-controls=\"sidebarMenu\" aria-expanded=\"false\" aria-label=\"Toggle navigation menu\"><span class=\"navbar-toggler-icon\"></span></button> <button class=\"navbar-toggler\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#navbarNav\" aria-controls=\"navbarNav\" aria-expanded=\"false\" aria-label=\"User menu\"><i class=\"fas fa-user\"></i></button><div class=\"collapse navbar-collapse\" id=\"navbarNav\"><ul class=\"navbar-nav ms-auto\"><li class=\"nav-item dropdown\"><a class=\"nav-link dropdown-toggle\" href=\"#\" role=\"button\" data-bs-toggle=\"dropdown\"><i class=\"fas fa-user me-1\"></i>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 72, Col: 73}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 74, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</a><ul class=\"dropdown-menu\"><li><a class=\"dropdown-item\" href=\"/logout\"><i class=\"fas fa-sign-out-alt me-2\"></i>Logout</a></li></ul></li></ul></div></div></header><div class=\"row g-0\"><!-- Sidebar --><div class=\"col-md-3 col-lg-2 d-md-block bg-light sidebar collapse\"><div class=\"position-sticky pt-3\"><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MAIN</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/admin\"><i class=\"fas fa-tachometer-alt me-2\"></i>Dashboard</a></li><li class=\"nav-item\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</a><ul class=\"dropdown-menu\"><li><a class=\"dropdown-item\" href=\"/logout\"><i class=\"fas fa-sign-out-alt me-2\"></i>Logout</a></li></ul></li></ul></div></div></header><div class=\"row g-0\"><!-- Sidebar --><div class=\"col-md-3 col-lg-2 d-md-block bg-light sidebar collapse\" id=\"sidebarMenu\"><div class=\"position-sticky pt-3\"><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>MAIN</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\"><a class=\"nav-link\" href=\"/admin\"><i class=\"fas fa-tachometer-alt me-2\"></i>Dashboard</a></li><li class=\"nav-item\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", isClusterPage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 99, Col: 207}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 101, Col: 207}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -170,7 +170,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", isStoragePage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 124, Col: 207}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 126, Col: 207}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -254,7 +254,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</li><!-- Commented out for later --><!--\n <li class=\"nav-item\">\n <a class=\"nav-link\" href=\"/metrics\">\n <i class=\"fas fa-chart-line me-2\"></i>Metrics\n </a>\n </li>\n <li class=\"nav-item\">\n <a class=\"nav-link\" href=\"/logs\">\n <i class=\"fas fa-file-alt me-2\"></i>Logs\n </a>\n </li>\n --></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>WORKERS</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</li></ul><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>WORKERS</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -329,7 +329,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</li></ul></div></div><!-- Main content --><main class=\"col-md-9 ms-sm-auto col-lg-10 px-3 px-md-4\"><div class=\"pt-3\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</li></ul></div></div><!-- Sidebar backdrop for mobile --><div class=\"sidebar-backdrop\" id=\"sidebarBackdrop\"></div><!-- Main content --><main class=\"col-md-9 ms-sm-auto col-lg-10 px-3 px-md-4\"><div class=\"pt-3\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 339, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 331, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -357,7 +357,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 339, Col: 102}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 331, Col: 102}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -409,7 +409,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 367, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 359, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -422,7 +422,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 381, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 373, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -440,7 +440,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 388, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 380, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -458,7 +458,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(csrfToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 393, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 385, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package command
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func TestMiniDefaultPluginJobTypes(t *testing.T) {
|
||||
jobTypes, err := parsePluginWorkerJobTypes(defaultMiniPluginJobTypes)
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
// defaultMiniPluginJobTypes is an explicit list: "vacuum,volume_balance,erasure_coding,admin_script"
|
||||
handlers, err := buildPluginWorkerHandlers(defaultMiniPluginJobTypes, dialOption, int(pluginworker.DefaultMaxExecutionConcurrency), "")
|
||||
if err != nil {
|
||||
t.Fatalf("parsePluginWorkerJobTypes(mini default) err = %v", err)
|
||||
t.Fatalf("buildPluginWorkerHandlers(mini default) err = %v", err)
|
||||
}
|
||||
if len(jobTypes) != 4 {
|
||||
t.Fatalf("expected mini default job types to include 4 handlers, got %v", jobTypes)
|
||||
if len(handlers) != 4 {
|
||||
t.Fatalf("expected mini default job types to include 4 handlers, got %d", len(handlers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,68 +14,46 @@ import (
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func TestBuildPluginWorkerHandler(t *testing.T) {
|
||||
func TestBuildPluginWorkerHandlerExplicitTypes(t *testing.T) {
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
|
||||
testMaxConcurrency := int(pluginworker.DefaultMaxExecutionConcurrency)
|
||||
|
||||
handler, err := buildPluginWorkerHandler("vacuum", dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandler(vacuum) err = %v", err)
|
||||
}
|
||||
if handler == nil {
|
||||
t.Fatalf("expected non-nil handler")
|
||||
for _, jobType := range []string{"vacuum", "volume_balance", "erasure_coding", "admin_script", "iceberg_maintenance"} {
|
||||
handlers, err := buildPluginWorkerHandlers(jobType, dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandlers(%s) err = %v", jobType, err)
|
||||
}
|
||||
if len(handlers) != 1 {
|
||||
t.Fatalf("expected 1 handler for %s, got %d", jobType, len(handlers))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handler, err = buildPluginWorkerHandler("", dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandler(default) err = %v", err)
|
||||
}
|
||||
if handler == nil {
|
||||
t.Fatalf("expected non-nil default handler")
|
||||
}
|
||||
func TestBuildPluginWorkerHandlerAliases(t *testing.T) {
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
testMaxConcurrency := int(pluginworker.DefaultMaxExecutionConcurrency)
|
||||
|
||||
handler, err = buildPluginWorkerHandler("volume_balance", dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandler(volume_balance) err = %v", err)
|
||||
}
|
||||
if handler == nil {
|
||||
t.Fatalf("expected non-nil volume_balance handler")
|
||||
for _, alias := range []string{"balance", "ec", "iceberg", "admin", "script"} {
|
||||
handlers, err := buildPluginWorkerHandlers(alias, dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandlers(%s) err = %v", alias, err)
|
||||
}
|
||||
if len(handlers) != 1 {
|
||||
t.Fatalf("expected 1 handler for alias %s, got %d", alias, len(handlers))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handler, err = buildPluginWorkerHandler("balance", dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandler(balance alias) err = %v", err)
|
||||
}
|
||||
if handler == nil {
|
||||
t.Fatalf("expected non-nil balance alias handler")
|
||||
}
|
||||
|
||||
handler, err = buildPluginWorkerHandler("erasure_coding", dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandler(erasure_coding) err = %v", err)
|
||||
}
|
||||
if handler == nil {
|
||||
t.Fatalf("expected non-nil erasure_coding handler")
|
||||
}
|
||||
|
||||
handler, err = buildPluginWorkerHandler("ec", dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandler(ec alias) err = %v", err)
|
||||
}
|
||||
if handler == nil {
|
||||
t.Fatalf("expected non-nil ec alias handler")
|
||||
}
|
||||
|
||||
_, err = buildPluginWorkerHandler("unknown", dialOption, testMaxConcurrency, "")
|
||||
func TestBuildPluginWorkerHandlerUnknown(t *testing.T) {
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
_, err := buildPluginWorkerHandlers("unknown", dialOption, 1, "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected unsupported job type error")
|
||||
t.Fatalf("expected error for unknown job type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPluginWorkerHandlers(t *testing.T) {
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
|
||||
testMaxConcurrency := int(pluginworker.DefaultMaxExecutionConcurrency)
|
||||
|
||||
handlers, err := buildPluginWorkerHandlers("vacuum,volume_balance,erasure_coding", dialOption, testMaxConcurrency, "")
|
||||
@@ -100,47 +78,97 @@ func TestBuildPluginWorkerHandlers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginWorkerJobTypes(t *testing.T) {
|
||||
jobTypes, err := parsePluginWorkerJobTypes("")
|
||||
func TestBuildPluginWorkerHandlersCategories(t *testing.T) {
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
testMaxConcurrency := int(pluginworker.DefaultMaxExecutionConcurrency)
|
||||
|
||||
allHandlers, err := buildPluginWorkerHandlers("all", dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("parsePluginWorkerJobTypes(default) err = %v", err)
|
||||
t.Fatalf("buildPluginWorkerHandlers(all) err = %v", err)
|
||||
}
|
||||
if len(jobTypes) != 1 || jobTypes[0] != "vacuum" {
|
||||
t.Fatalf("expected default [vacuum], got %v", jobTypes)
|
||||
// "all" must include at least vacuum and erasure_coding (one default, one heavy)
|
||||
allNames := handlerJobTypes(allHandlers)
|
||||
for _, required := range []string{"vacuum", "erasure_coding", "iceberg_maintenance"} {
|
||||
if !allNames[required] {
|
||||
t.Fatalf("'all' missing expected job type %q, got %v", required, allNames)
|
||||
}
|
||||
}
|
||||
|
||||
jobTypes, err = parsePluginWorkerJobTypes(" volume_balance , ec , vacuum , volume_balance ")
|
||||
defaultHandlers, err := buildPluginWorkerHandlers("default", dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("parsePluginWorkerJobTypes(list) err = %v", err)
|
||||
t.Fatalf("buildPluginWorkerHandlers(default) err = %v", err)
|
||||
}
|
||||
if len(jobTypes) != 3 {
|
||||
t.Fatalf("expected 3 deduped job types, got %d (%v)", len(jobTypes), jobTypes)
|
||||
defaultNames := handlerJobTypes(defaultHandlers)
|
||||
|
||||
heavyHandlers, err := buildPluginWorkerHandlers("heavy", dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandlers(heavy) err = %v", err)
|
||||
}
|
||||
if jobTypes[0] != "volume_balance" || jobTypes[1] != "erasure_coding" || jobTypes[2] != "vacuum" {
|
||||
t.Fatalf("unexpected parsed order %v", jobTypes)
|
||||
heavyNames := handlerJobTypes(heavyHandlers)
|
||||
|
||||
// default and heavy must both be non-empty subsets of all
|
||||
if len(defaultNames) == 0 {
|
||||
t.Fatalf("'default' resolved no handlers")
|
||||
}
|
||||
if len(heavyNames) == 0 {
|
||||
t.Fatalf("'heavy' resolved no handlers")
|
||||
}
|
||||
for name := range defaultNames {
|
||||
if !allNames[name] {
|
||||
t.Fatalf("default handler %q not in 'all'", name)
|
||||
}
|
||||
}
|
||||
for name := range heavyNames {
|
||||
if !allNames[name] {
|
||||
t.Fatalf("heavy handler %q not in 'all'", name)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err = parsePluginWorkerJobTypes(" , "); err != nil {
|
||||
t.Fatalf("expected empty list to resolve to default vacuum: %v", err)
|
||||
// default and heavy must be disjoint and their union must equal all
|
||||
for name := range defaultNames {
|
||||
if heavyNames[name] {
|
||||
t.Fatalf("handler %q appears in both default and heavy", name)
|
||||
}
|
||||
}
|
||||
if len(defaultNames)+len(heavyNames) != len(allNames) {
|
||||
t.Fatalf("union(default=%d, heavy=%d) != all(%d)", len(defaultNames), len(heavyNames), len(allNames))
|
||||
}
|
||||
|
||||
jobTypes, err = parsePluginWorkerJobTypes("admin-script,script,admin_script")
|
||||
// mix category + explicit: "default,iceberg" adds one heavy to default set
|
||||
mixedHandlers, err := buildPluginWorkerHandlers("default,iceberg", dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("parsePluginWorkerJobTypes(admin script aliases) err = %v", err)
|
||||
t.Fatalf("buildPluginWorkerHandlers(default,iceberg) err = %v", err)
|
||||
}
|
||||
if len(jobTypes) != 1 || jobTypes[0] != "admin_script" {
|
||||
t.Fatalf("expected admin_script alias to resolve, got %v", jobTypes)
|
||||
if len(mixedHandlers) != len(defaultHandlers)+1 {
|
||||
t.Fatalf("expected default+1 handlers for 'default,iceberg', got %d (default=%d)", len(mixedHandlers), len(defaultHandlers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginWorkerDefaultJobTypes(t *testing.T) {
|
||||
jobTypes, err := parsePluginWorkerJobTypes(defaultPluginWorkerJobTypes)
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
testMaxConcurrency := int(pluginworker.DefaultMaxExecutionConcurrency)
|
||||
|
||||
// defaultPluginWorkerJobTypes is "all", so it should match the "all" category exactly
|
||||
defaultHandlers, err := buildPluginWorkerHandlers(defaultPluginWorkerJobTypes, dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("parsePluginWorkerJobTypes(default setting) err = %v", err)
|
||||
t.Fatalf("buildPluginWorkerHandlers(default setting) err = %v", err)
|
||||
}
|
||||
if len(jobTypes) != 4 {
|
||||
t.Fatalf("expected default job types to include 4 handlers, got %v", jobTypes)
|
||||
allHandlers, err := buildPluginWorkerHandlers("all", dialOption, testMaxConcurrency, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandlers(all) err = %v", err)
|
||||
}
|
||||
if len(defaultHandlers) != len(allHandlers) {
|
||||
t.Fatalf("default setting resolved %d handlers, 'all' resolved %d", len(defaultHandlers), len(allHandlers))
|
||||
}
|
||||
}
|
||||
|
||||
// handlerJobTypes returns the set of job type names from a slice of handlers.
|
||||
func handlerJobTypes(handlers []pluginworker.JobHandler) map[string]bool {
|
||||
m := make(map[string]bool, len(handlers))
|
||||
for _, h := range handlers {
|
||||
m[h.Capability().JobType] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestResolvePluginWorkerID(t *testing.T) {
|
||||
|
||||
+13
-8
@@ -7,25 +7,30 @@ import (
|
||||
)
|
||||
|
||||
var cmdWorker = &Command{
|
||||
UsageLine: "worker -admin=<admin_server> [-id=<worker_id>] [-jobType=vacuum,volume_balance,erasure_coding,admin_script] [-workingDir=<path>] [-heartbeat=15s] [-reconnect=5s] [-maxDetect=1] [-maxExecute=4] [-metricsPort=<port>] [-metricsIp=<ip>] [-debug]",
|
||||
UsageLine: "worker -admin=<admin_server> [-id=<worker_id>] [-jobType=all] [-workingDir=<path>] [-heartbeat=15s] [-reconnect=5s] [-maxDetect=1] [-maxExecute=4] [-metricsPort=<port>] [-metricsIp=<ip>] [-debug]",
|
||||
Short: "start a plugin.proto worker process",
|
||||
Long: `Start an external plugin worker using weed/pb/plugin.proto over gRPC.
|
||||
|
||||
This command provides vacuum, volume_balance, erasure_coding, and admin_script job type
|
||||
contracts with the plugin stream runtime, including descriptor delivery,
|
||||
heartbeat/load reporting, detection, and execution.
|
||||
This command provides plugin job type handlers for cluster maintenance,
|
||||
including descriptor delivery, heartbeat/load reporting, detection, and execution.
|
||||
|
||||
Behavior:
|
||||
- Use -jobType to choose one or more plugin job handlers (comma-separated list)
|
||||
- Use -jobType to choose handlers by category or explicit name (comma-separated)
|
||||
- Categories: "all" (every registered handler), "default" (lightweight jobs),
|
||||
"heavy" (resource-intensive jobs like erasure coding)
|
||||
- Explicit job type names and aliases are still supported (e.g. "vacuum", "ec")
|
||||
- Categories and explicit names can be mixed (e.g. "default,iceberg")
|
||||
- Use -workingDir to persist worker.id for stable worker identity across restarts
|
||||
- Use -metricsPort/-metricsIp to expose /health, /ready, and /metrics
|
||||
|
||||
Examples:
|
||||
weed worker -admin=localhost:23646
|
||||
weed worker -admin=localhost:23646 -jobType=volume_balance
|
||||
weed worker -admin=localhost:23646 -jobType=all
|
||||
weed worker -admin=localhost:23646 -jobType=default
|
||||
weed worker -admin=localhost:23646 -jobType=heavy
|
||||
weed worker -admin=localhost:23646 -jobType=default,iceberg
|
||||
weed worker -admin=localhost:23646 -jobType=vacuum,volume_balance
|
||||
weed worker -admin=localhost:23646 -jobType=erasure_coding
|
||||
weed worker -admin=localhost:23646 -jobType=admin_script
|
||||
weed worker -admin=admin.example.com:23646 -id=plugin-vacuum-a -heartbeat=10s
|
||||
weed worker -admin=localhost:23646 -workingDir=/var/lib/seaweedfs-plugin
|
||||
weed worker -admin=localhost:23646 -metricsPort=9327 -metricsIp=0.0.0.0
|
||||
@@ -36,7 +41,7 @@ var (
|
||||
workerAdminServer = cmdWorker.Flag.String("admin", "localhost:23646", "admin server address")
|
||||
workerID = cmdWorker.Flag.String("id", "", "worker ID (auto-generated when empty)")
|
||||
workerWorkingDir = cmdWorker.Flag.String("workingDir", "", "working directory for persistent worker state")
|
||||
workerJobType = cmdWorker.Flag.String("jobType", defaultPluginWorkerJobTypes, "job types to serve (comma-separated list)")
|
||||
workerJobType = cmdWorker.Flag.String("jobType", defaultPluginWorkerJobTypes, "job types or categories to serve: all, default, heavy, or explicit names/aliases such as ec, balance, iceberg (comma-separated)")
|
||||
workerHeartbeat = cmdWorker.Flag.Duration("heartbeat", 15*time.Second, "heartbeat interval")
|
||||
workerReconnect = cmdWorker.Flag.Duration("reconnect", 5*time.Second, "reconnect delay")
|
||||
workerMaxDetect = cmdWorker.Flag.Int("maxDetect", 1, "max concurrent detection requests")
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/plugin/worker/handlers" // register all handler subpackages
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
statsCollect "github.com/seaweedfs/seaweedfs/weed/stats"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
@@ -23,7 +24,7 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const defaultPluginWorkerJobTypes = "vacuum,volume_balance,erasure_coding,admin_script"
|
||||
const defaultPluginWorkerJobTypes = "all"
|
||||
|
||||
type pluginWorkerRunOptions struct {
|
||||
AdminServer string
|
||||
@@ -137,98 +138,37 @@ func resolvePluginWorkerID(explicitID string, workingDir string) (string, error)
|
||||
return worker.GenerateOrLoadWorkerID(workingDir)
|
||||
}
|
||||
|
||||
// buildPluginWorkerHandler constructs the JobHandler for the given job type.
|
||||
// maxExecute is forwarded to handlers that use it to report their own
|
||||
// MaxExecutionConcurrency in Capability for consistency and future-proofing.
|
||||
// The scheduler's effective per-worker MaxExecutionConcurrency is derived from
|
||||
// the worker-level configuration (e.g. WorkerOptions.MaxExecutionConcurrency),
|
||||
// not directly from the handler's Capability.
|
||||
func buildPluginWorkerHandler(jobType string, dialOption grpc.DialOption, maxExecute int, workingDir string) (pluginworker.JobHandler, error) {
|
||||
canonicalJobType, err := canonicalPluginWorkerJobType(jobType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch canonicalJobType {
|
||||
case "vacuum":
|
||||
return pluginworker.NewVacuumHandler(dialOption, int32(maxExecute)), nil
|
||||
case "volume_balance":
|
||||
return pluginworker.NewVolumeBalanceHandler(dialOption), nil
|
||||
case "erasure_coding":
|
||||
return pluginworker.NewErasureCodingHandler(dialOption, workingDir), nil
|
||||
case "admin_script":
|
||||
return pluginworker.NewAdminScriptHandler(dialOption), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported plugin job type %q", canonicalJobType)
|
||||
}
|
||||
}
|
||||
|
||||
// buildPluginWorkerHandlers constructs a deduplicated slice of JobHandlers for
|
||||
// the comma-separated jobTypes string, forwarding maxExecute to each handler.
|
||||
// buildPluginWorkerHandlers resolves the comma-separated jobTypes string
|
||||
// (which may contain category names like "all", "default", "heavy" and/or
|
||||
// explicit job type names/aliases) into a deduplicated slice of JobHandlers.
|
||||
func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption, maxExecute int, workingDir string) ([]pluginworker.JobHandler, error) {
|
||||
parsedJobTypes, err := parsePluginWorkerJobTypes(jobTypes)
|
||||
jobTypes = strings.TrimSpace(jobTypes)
|
||||
if jobTypes == "" {
|
||||
jobTypes = defaultPluginWorkerJobTypes
|
||||
}
|
||||
|
||||
factories, err := pluginworker.ResolveHandlerFactories(jobTypes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
handlers := make([]pluginworker.JobHandler, 0, len(parsedJobTypes))
|
||||
for _, jobType := range parsedJobTypes {
|
||||
handler, buildErr := buildPluginWorkerHandler(jobType, dialOption, maxExecute, workingDir)
|
||||
opts := pluginworker.HandlerBuildOptions{
|
||||
GrpcDialOption: dialOption,
|
||||
MaxExecute: maxExecute,
|
||||
WorkingDir: workingDir,
|
||||
}
|
||||
|
||||
handlers := make([]pluginworker.JobHandler, 0, len(factories))
|
||||
for _, f := range factories {
|
||||
handler, buildErr := f.Build(opts)
|
||||
if buildErr != nil {
|
||||
return nil, buildErr
|
||||
return nil, fmt.Errorf("building handler for %q: %w", f.JobType, buildErr)
|
||||
}
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
return handlers, nil
|
||||
}
|
||||
|
||||
func parsePluginWorkerJobTypes(jobTypes string) ([]string, error) {
|
||||
jobTypes = strings.TrimSpace(jobTypes)
|
||||
if jobTypes == "" {
|
||||
return []string{"vacuum"}, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(jobTypes, ",")
|
||||
parsed := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
canonical, err := canonicalPluginWorkerJobType(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, found := seen[canonical]; found {
|
||||
continue
|
||||
}
|
||||
seen[canonical] = struct{}{}
|
||||
parsed = append(parsed, canonical)
|
||||
}
|
||||
|
||||
if len(parsed) == 0 {
|
||||
return []string{"vacuum"}, nil
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func canonicalPluginWorkerJobType(jobType string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(jobType)) {
|
||||
case "", "vacuum":
|
||||
return "vacuum", nil
|
||||
case "volume_balance", "balance", "volume.balance", "volume-balance":
|
||||
return "volume_balance", nil
|
||||
case "erasure_coding", "erasure-coding", "erasure.coding", "ec":
|
||||
return "erasure_coding", nil
|
||||
case "admin_script", "admin-script", "admin.script", "script", "admin":
|
||||
return "admin_script", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported plugin job type %q", jobType)
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePluginWorkerAdminServer(adminServer string) string {
|
||||
adminServer = strings.TrimSpace(adminServer)
|
||||
host, httpPort, hasExplicitGrpcPort, err := parsePluginWorkerAdminAddress(adminServer)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package command
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func TestWorkerDefaultJobTypes(t *testing.T) {
|
||||
jobTypes, err := parsePluginWorkerJobTypes(*workerJobType)
|
||||
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
handlers, err := buildPluginWorkerHandlers(*workerJobType, dialOption, int(pluginworker.DefaultMaxExecutionConcurrency), "")
|
||||
if err != nil {
|
||||
t.Fatalf("parsePluginWorkerJobTypes(default worker flag) err = %v", err)
|
||||
t.Fatalf("buildPluginWorkerHandlers(default worker flag) err = %v", err)
|
||||
}
|
||||
if len(jobTypes) != 4 {
|
||||
t.Fatalf("expected default worker job types to include 4 handlers, got %v", jobTypes)
|
||||
// Expected: vacuum, volume_balance, admin_script, erasure_coding, iceberg_maintenance
|
||||
if len(handlers) != 5 {
|
||||
t.Fatalf("expected default worker job types to include 5 handlers, got %d", len(handlers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func (store *FilerEtcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3Ap
|
||||
s3cfg := &iam_pb.S3ApiConfiguration{}
|
||||
|
||||
// 1. Load from legacy single file (low priority)
|
||||
content, foundLegacy, err := store.readInsideFiler(filer.IamConfigDirectory, IamLegacyIdentityFile)
|
||||
content, foundLegacy, err := store.readInsideFiler(ctx, filer.IamConfigDirectory, IamLegacyIdentityFile)
|
||||
if err != nil {
|
||||
return s3cfg, err
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func (store *FilerEtcStore) loadFromMultiFile(ctx context.Context, s3cfg *iam_pb
|
||||
if len(entry.Content) > 0 {
|
||||
content = entry.Content
|
||||
} else {
|
||||
c, err := filer.ReadInsideFiler(client, dir, entry.Name)
|
||||
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to read identity file %s: %v", entry.Name, err)
|
||||
continue
|
||||
@@ -249,7 +249,7 @@ func (store *FilerEtcStore) CreateUser(ctx context.Context, identity *iam_pb.Ide
|
||||
func (store *FilerEtcStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
|
||||
var identity *iam_pb.Identity
|
||||
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := filer.ReadInsideFiler(client, filer.IamConfigDirectory+"/"+IamIdentitiesDirectory, username+".json")
|
||||
data, err := filer.ReadInsideFiler(ctx, client, filer.IamConfigDirectory+"/"+IamIdentitiesDirectory, username+".json")
|
||||
if err != nil {
|
||||
if err == filer_pb.ErrNotFound {
|
||||
return credential.ErrUserNotFound
|
||||
@@ -350,7 +350,7 @@ func (store *FilerEtcStore) GetUserByAccessKey(ctx context.Context, accessKey st
|
||||
if len(entry.Content) > 0 {
|
||||
content = entry.Content
|
||||
} else {
|
||||
c, err := filer.ReadInsideFiler(client, dir, entry.Name)
|
||||
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -435,11 +435,11 @@ func (store *FilerEtcStore) saveIdentity(ctx context.Context, identity *iam_pb.I
|
||||
})
|
||||
}
|
||||
|
||||
func (store *FilerEtcStore) readInsideFiler(dir string, name string) ([]byte, bool, error) {
|
||||
func (store *FilerEtcStore) readInsideFiler(ctx context.Context, dir string, name string) ([]byte, bool, error) {
|
||||
var content []byte
|
||||
found := false
|
||||
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
c, err := filer.ReadInsideFiler(client, dir, name)
|
||||
c, err := filer.ReadInsideFiler(ctx, client, dir, name)
|
||||
if err != nil {
|
||||
if err == filer_pb.ErrNotFound {
|
||||
return nil
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
)
|
||||
|
||||
@@ -18,13 +19,113 @@ const (
|
||||
)
|
||||
|
||||
type PoliciesCollection struct {
|
||||
Policies map[string]policy_engine.PolicyDocument `json:"policies"`
|
||||
Policies map[string]policy_engine.PolicyDocument `json:"policies"`
|
||||
InlinePolicies map[string]map[string]policy_engine.PolicyDocument `json:"inlinePolicies"`
|
||||
}
|
||||
|
||||
func validatePolicyName(name string) error {
|
||||
return credential.ValidatePolicyName(name)
|
||||
}
|
||||
|
||||
func newPoliciesCollection() *PoliciesCollection {
|
||||
return &PoliciesCollection{
|
||||
Policies: make(map[string]policy_engine.PolicyDocument),
|
||||
InlinePolicies: make(map[string]map[string]policy_engine.PolicyDocument),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *FilerEtcStore) loadLegacyPoliciesCollection(ctx context.Context) (*PoliciesCollection, bool, error) {
|
||||
policiesCollection := newPoliciesCollection()
|
||||
|
||||
content, foundLegacy, err := store.readInsideFiler(ctx, filer.IamConfigDirectory, filer.IamPoliciesFile)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !foundLegacy || len(content) == 0 {
|
||||
return policiesCollection, foundLegacy, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(content, policiesCollection); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if policiesCollection.Policies == nil {
|
||||
policiesCollection.Policies = make(map[string]policy_engine.PolicyDocument)
|
||||
}
|
||||
if policiesCollection.InlinePolicies == nil {
|
||||
policiesCollection.InlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument)
|
||||
}
|
||||
|
||||
return policiesCollection, true, nil
|
||||
}
|
||||
|
||||
func (store *FilerEtcStore) saveLegacyPoliciesCollection(ctx context.Context, policiesCollection *PoliciesCollection) error {
|
||||
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
content, err := json.MarshalIndent(policiesCollection, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamPoliciesFile, content)
|
||||
})
|
||||
}
|
||||
|
||||
func policyDocumentToPbPolicy(name string, policy policy_engine.PolicyDocument) (*iam_pb.Policy, error) {
|
||||
content, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &iam_pb.Policy{Name: name, Content: string(content)}, nil
|
||||
}
|
||||
|
||||
// LoadManagedPolicies loads managed policies for the S3 runtime without
|
||||
// triggering legacy-to-multifile migration. This lets the runtime hydrate
|
||||
// policies while preserving any legacy inline policy data stored alongside
|
||||
// managed policies.
|
||||
func (store *FilerEtcStore) LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error) {
|
||||
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policies := make(map[string]policy_engine.PolicyDocument, len(policiesCollection.Policies))
|
||||
for name, policy := range policiesCollection.Policies {
|
||||
policies[name] = policy
|
||||
}
|
||||
|
||||
if err := store.loadPoliciesFromMultiFile(ctx, policies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
managedPolicies := make([]*iam_pb.Policy, 0, len(policies))
|
||||
for name, policy := range policies {
|
||||
pbPolicy, err := policyDocumentToPbPolicy(name, policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
managedPolicies = append(managedPolicies, pbPolicy)
|
||||
}
|
||||
|
||||
return managedPolicies, nil
|
||||
}
|
||||
|
||||
// LoadInlinePolicies loads legacy inline policies keyed by user name. Inline
|
||||
// policies are still stored in the legacy shared policies file.
|
||||
func (store *FilerEtcStore) LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
|
||||
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inlinePolicies := make(map[string]map[string]policy_engine.PolicyDocument, len(policiesCollection.InlinePolicies))
|
||||
for userName, userPolicies := range policiesCollection.InlinePolicies {
|
||||
inlinePolicies[userName] = make(map[string]policy_engine.PolicyDocument, len(userPolicies))
|
||||
for policyName, policy := range userPolicies {
|
||||
inlinePolicies[userName][policyName] = policy
|
||||
}
|
||||
}
|
||||
|
||||
return inlinePolicies, nil
|
||||
}
|
||||
|
||||
// GetPolicies retrieves all IAM policies from the filer
|
||||
func (store *FilerEtcStore) GetPolicies(ctx context.Context) (map[string]policy_engine.PolicyDocument, error) {
|
||||
policies := make(map[string]policy_engine.PolicyDocument)
|
||||
@@ -43,23 +144,12 @@ func (store *FilerEtcStore) GetPolicies(ctx context.Context) (map[string]policy_
|
||||
filer.IamConfigDirectory, filer.IamPoliciesFile)
|
||||
|
||||
// 1. Load from legacy single file (low priority)
|
||||
content, foundLegacy, err := store.readInsideFiler(filer.IamConfigDirectory, filer.IamPoliciesFile)
|
||||
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if foundLegacy && len(content) > 0 {
|
||||
policiesCollection := &PoliciesCollection{
|
||||
Policies: make(map[string]policy_engine.PolicyDocument),
|
||||
}
|
||||
if err := json.Unmarshal(content, policiesCollection); err != nil {
|
||||
glog.Errorf("Failed to parse legacy IAM policies from %s/%s: %v",
|
||||
filer.IamConfigDirectory, filer.IamPoliciesFile, err)
|
||||
} else {
|
||||
for name, policy := range policiesCollection.Policies {
|
||||
policies[name] = policy
|
||||
}
|
||||
}
|
||||
for name, policy := range policiesCollection.Policies {
|
||||
policies[name] = policy
|
||||
}
|
||||
|
||||
// 2. Load from multi-file structure (high priority, overrides legacy)
|
||||
@@ -67,14 +157,6 @@ func (store *FilerEtcStore) GetPolicies(ctx context.Context) (map[string]policy_
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Perform migration if we loaded legacy config
|
||||
if foundLegacy {
|
||||
if err := store.migratePoliciesToMultiFile(ctx, policies); err != nil {
|
||||
glog.Errorf("Failed to migrate IAM policies to multi-file layout: %v", err)
|
||||
return policies, err
|
||||
}
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
@@ -98,7 +180,7 @@ func (store *FilerEtcStore) loadPoliciesFromMultiFile(ctx context.Context, polic
|
||||
if len(entry.Content) > 0 {
|
||||
content = entry.Content
|
||||
} else {
|
||||
c, err := filer.ReadInsideFiler(client, dir, entry.Name)
|
||||
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to read policy file %s: %v", entry.Name, err)
|
||||
continue
|
||||
@@ -115,7 +197,7 @@ func (store *FilerEtcStore) loadPoliciesFromMultiFile(ctx context.Context, polic
|
||||
|
||||
// The file name is "policyName.json"
|
||||
policyName := entry.Name
|
||||
if len(policyName) > 5 && policyName[len(policyName)-5:] == ".json" {
|
||||
if strings.HasSuffix(policyName, ".json") {
|
||||
policyName = policyName[:len(policyName)-5]
|
||||
policies[policyName] = policy
|
||||
}
|
||||
@@ -184,7 +266,23 @@ func (store *FilerEtcStore) DeletePolicy(ctx context.Context, name string) error
|
||||
if err := validatePolicyName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
store.policyMu.Lock()
|
||||
defer store.policyMu.Unlock()
|
||||
|
||||
policiesCollection, foundLegacy, err := store.loadLegacyPoliciesCollection(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deleteLegacyPolicy := false
|
||||
if foundLegacy {
|
||||
if _, exists := policiesCollection.Policies[name]; exists {
|
||||
delete(policiesCollection.Policies, name)
|
||||
deleteLegacyPolicy = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
|
||||
Directory: filer.IamConfigDirectory + "/" + IamPoliciesDirectory,
|
||||
Name: name + ".json",
|
||||
@@ -193,7 +291,15 @@ func (store *FilerEtcStore) DeletePolicy(ctx context.Context, name string) error
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deleteLegacyPolicy {
|
||||
return store.saveLegacyPoliciesCollection(ctx, policiesCollection)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPolicy retrieves a specific IAM policy by name from the filer
|
||||
@@ -204,7 +310,7 @@ func (store *FilerEtcStore) GetPolicy(ctx context.Context, name string) (*policy
|
||||
|
||||
var policy *policy_engine.PolicyDocument
|
||||
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := filer.ReadInsideFiler(client, filer.IamConfigDirectory+"/"+IamPoliciesDirectory, name+".json")
|
||||
data, err := filer.ReadInsideFiler(ctx, client, filer.IamConfigDirectory+"/"+IamPoliciesDirectory, name+".json")
|
||||
if err != nil {
|
||||
if err == filer_pb.ErrNotFound {
|
||||
return nil
|
||||
@@ -239,6 +345,7 @@ func (store *FilerEtcStore) GetPolicy(ctx context.Context, name string) (*policy
|
||||
// ListPolicyNames returns all managed policy names stored in the filer.
|
||||
func (store *FilerEtcStore) ListPolicyNames(ctx context.Context) ([]string, error) {
|
||||
names := make([]string, 0)
|
||||
seenNames := make(map[string]struct{})
|
||||
|
||||
store.mu.RLock()
|
||||
configured := store.filerAddressFunc != nil
|
||||
@@ -248,7 +355,19 @@ func (store *FilerEtcStore) ListPolicyNames(ctx context.Context) ([]string, erro
|
||||
return names, nil
|
||||
}
|
||||
|
||||
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name := range policiesCollection.Policies {
|
||||
if _, found := seenNames[name]; found {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
seenNames[name] = struct{}{}
|
||||
}
|
||||
|
||||
err = store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
dir := filer.IamConfigDirectory + "/" + IamPoliciesDirectory
|
||||
entries, err := listEntries(ctx, client, dir)
|
||||
if err != nil {
|
||||
@@ -266,7 +385,11 @@ func (store *FilerEtcStore) ListPolicyNames(ctx context.Context) ([]string, erro
|
||||
if strings.HasSuffix(name, ".json") {
|
||||
name = name[:len(name)-5]
|
||||
}
|
||||
if _, found := seenNames[name]; found {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
seenNames[name] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package filer_etc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type policyTestFilerServer struct {
|
||||
filer_pb.UnimplementedSeaweedFilerServer
|
||||
mu sync.RWMutex
|
||||
entries map[string]*filer_pb.Entry
|
||||
contentlessListEntry map[string]struct{}
|
||||
beforeLookup func(context.Context, string, string) error
|
||||
afterListEntry func(string, string)
|
||||
beforeDelete func(string, string) error
|
||||
beforeUpdate func(string, string) error
|
||||
}
|
||||
|
||||
func newPolicyTestFilerServer() *policyTestFilerServer {
|
||||
return &policyTestFilerServer{
|
||||
entries: make(map[string]*filer_pb.Entry),
|
||||
contentlessListEntry: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *policyTestFilerServer) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
s.mu.RLock()
|
||||
beforeLookup := s.beforeLookup
|
||||
s.mu.RUnlock()
|
||||
if beforeLookup != nil {
|
||||
if err := beforeLookup(ctx, req.Directory, req.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
entry, found := s.entries[filerEntryKey(req.Directory, req.Name)]
|
||||
if !found {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: cloneEntry(entry)}, nil
|
||||
}
|
||||
|
||||
func (s *policyTestFilerServer) ListEntries(req *filer_pb.ListEntriesRequest, stream grpc.ServerStreamingServer[filer_pb.ListEntriesResponse]) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0)
|
||||
for key := range s.entries {
|
||||
dir, name := splitFilerEntryKey(key)
|
||||
if dir != req.Directory {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
entry := cloneEntry(s.entries[filerEntryKey(req.Directory, name)])
|
||||
if _, found := s.contentlessListEntry[filerEntryKey(req.Directory, name)]; found {
|
||||
entry.Content = nil
|
||||
}
|
||||
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: entry}); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.afterListEntry != nil {
|
||||
s.afterListEntry(req.Directory, name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *policyTestFilerServer) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.entries[filerEntryKey(req.Directory, req.Entry.Name)] = cloneEntry(req.Entry)
|
||||
return &filer_pb.CreateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *policyTestFilerServer) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
|
||||
s.mu.RLock()
|
||||
beforeUpdate := s.beforeUpdate
|
||||
s.mu.RUnlock()
|
||||
if beforeUpdate != nil {
|
||||
if err := beforeUpdate(req.Directory, req.Entry.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.entries[filerEntryKey(req.Directory, req.Entry.Name)] = cloneEntry(req.Entry)
|
||||
return &filer_pb.UpdateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *policyTestFilerServer) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
|
||||
s.mu.RLock()
|
||||
beforeDelete := s.beforeDelete
|
||||
s.mu.RUnlock()
|
||||
if beforeDelete != nil {
|
||||
if err := beforeDelete(req.Directory, req.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := filerEntryKey(req.Directory, req.Name)
|
||||
if _, found := s.entries[key]; !found {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
|
||||
delete(s.entries, key)
|
||||
return &filer_pb.DeleteEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func newPolicyTestStore(t *testing.T) *FilerEtcStore {
|
||||
store, _ := newPolicyTestStoreWithServer(t)
|
||||
return store
|
||||
}
|
||||
|
||||
func newPolicyTestStoreWithServer(t *testing.T) (*FilerEtcStore, *policyTestFilerServer) {
|
||||
t.Helper()
|
||||
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
server := newPolicyTestFilerServer()
|
||||
grpcServer := pb.NewGrpcServer()
|
||||
filer_pb.RegisterSeaweedFilerServer(grpcServer, server)
|
||||
go func() {
|
||||
_ = grpcServer.Serve(lis)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
grpcServer.Stop()
|
||||
_ = lis.Close()
|
||||
})
|
||||
|
||||
store := &FilerEtcStore{}
|
||||
host, portString, err := net.SplitHostPort(lis.Addr().String())
|
||||
require.NoError(t, err)
|
||||
grpcPort, err := strconv.Atoi(portString)
|
||||
require.NoError(t, err)
|
||||
store.SetFilerAddressFunc(func() pb.ServerAddress {
|
||||
return pb.NewServerAddress(host, 1, grpcPort)
|
||||
}, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
|
||||
return store, server
|
||||
}
|
||||
|
||||
func TestFilerEtcStoreListPolicyNamesIncludesLegacyPolicies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newPolicyTestStore(t)
|
||||
|
||||
legacyPolicies := newPoliciesCollection()
|
||||
legacyPolicies.Policies["legacy-only"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::legacy-only/*")
|
||||
legacyPolicies.Policies["shared"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::shared/*")
|
||||
require.NoError(t, store.saveLegacyPoliciesCollection(ctx, legacyPolicies))
|
||||
|
||||
require.NoError(t, store.savePolicy(ctx, "multi-file-only", testPolicyDocument("s3:PutObject", "arn:aws:s3:::multi-file-only/*")))
|
||||
require.NoError(t, store.savePolicy(ctx, "shared", testPolicyDocument("s3:DeleteObject", "arn:aws:s3:::shared/*")))
|
||||
|
||||
names, err := store.ListPolicyNames(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.ElementsMatch(t, []string{"legacy-only", "multi-file-only", "shared"}, names)
|
||||
}
|
||||
|
||||
func TestFilerEtcStoreDeletePolicyRemovesLegacyManagedCopy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newPolicyTestStore(t)
|
||||
|
||||
inlinePolicy := testPolicyDocument("s3:PutObject", "arn:aws:s3:::inline-user/*")
|
||||
legacyPolicies := newPoliciesCollection()
|
||||
legacyPolicies.Policies["legacy-only"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::legacy-only/*")
|
||||
legacyPolicies.InlinePolicies["inline-user"] = map[string]policy_engine.PolicyDocument{
|
||||
"PutOnly": inlinePolicy,
|
||||
}
|
||||
require.NoError(t, store.saveLegacyPoliciesCollection(ctx, legacyPolicies))
|
||||
|
||||
managedPolicies, err := store.LoadManagedPolicies(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"legacy-only"}, managedPolicyNames(managedPolicies))
|
||||
|
||||
require.NoError(t, store.DeletePolicy(ctx, "legacy-only"))
|
||||
|
||||
managedPolicies, err = store.LoadManagedPolicies(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, managedPolicies)
|
||||
|
||||
inlinePolicies, err := store.LoadInlinePolicies(ctx)
|
||||
require.NoError(t, err)
|
||||
assertInlinePolicyPreserved(t, inlinePolicies, "inline-user", "PutOnly")
|
||||
|
||||
loadedLegacyPolicies, foundLegacy, err := store.loadLegacyPoliciesCollection(ctx)
|
||||
require.NoError(t, err)
|
||||
require.True(t, foundLegacy)
|
||||
assert.Empty(t, loadedLegacyPolicies.Policies)
|
||||
assertInlinePolicyPreserved(t, loadedLegacyPolicies.InlinePolicies, "inline-user", "PutOnly")
|
||||
}
|
||||
|
||||
func TestFilerEtcStoreDeletePolicySerializesLegacyUpdates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newPolicyTestStoreWithServer(t)
|
||||
|
||||
legacyPolicies := newPoliciesCollection()
|
||||
legacyPolicies.Policies["first"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::first/*")
|
||||
legacyPolicies.Policies["second"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::second/*")
|
||||
require.NoError(t, store.saveLegacyPoliciesCollection(ctx, legacyPolicies))
|
||||
require.NoError(t, store.savePolicy(ctx, "first", testPolicyDocument("s3:GetObject", "arn:aws:s3:::first/*")))
|
||||
require.NoError(t, store.savePolicy(ctx, "second", testPolicyDocument("s3:GetObject", "arn:aws:s3:::second/*")))
|
||||
|
||||
firstSaveStarted := make(chan struct{})
|
||||
releaseFirstSave := make(chan struct{})
|
||||
secondReachedDelete := make(chan struct{}, 1)
|
||||
var blockOnce sync.Once
|
||||
|
||||
server.mu.Lock()
|
||||
server.beforeUpdate = func(dir string, name string) error {
|
||||
if dir == filer.IamConfigDirectory && name == filer.IamPoliciesFile {
|
||||
blockOnce.Do(func() {
|
||||
close(firstSaveStarted)
|
||||
<-releaseFirstSave
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
server.beforeDelete = func(dir string, name string) error {
|
||||
if dir == filer.IamConfigDirectory+"/"+IamPoliciesDirectory && name == "second.json" {
|
||||
select {
|
||||
case secondReachedDelete <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
server.mu.Unlock()
|
||||
|
||||
firstDeleteErr := make(chan error, 1)
|
||||
go func() {
|
||||
firstDeleteErr <- store.DeletePolicy(ctx, "first")
|
||||
}()
|
||||
|
||||
<-firstSaveStarted
|
||||
|
||||
secondDeleteErr := make(chan error, 1)
|
||||
go func() {
|
||||
secondDeleteErr <- store.DeletePolicy(ctx, "second")
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-secondReachedDelete:
|
||||
t.Fatal("second delete reached filer mutation while first delete was still blocked")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(releaseFirstSave)
|
||||
|
||||
require.NoError(t, <-firstDeleteErr)
|
||||
require.NoError(t, <-secondDeleteErr)
|
||||
|
||||
loadedLegacyPolicies, foundLegacy, err := store.loadLegacyPoliciesCollection(ctx)
|
||||
require.NoError(t, err)
|
||||
require.True(t, foundLegacy)
|
||||
assert.Empty(t, loadedLegacyPolicies.Policies)
|
||||
}
|
||||
|
||||
func TestFilerEtcStoreLoadManagedPoliciesRespectsReadContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
store, server := newPolicyTestStoreWithServer(t)
|
||||
|
||||
require.NoError(t, store.savePolicy(context.Background(), "cancel-me", testPolicyDocument("s3:GetObject", "arn:aws:s3:::cancel-me/*")))
|
||||
|
||||
server.mu.Lock()
|
||||
server.contentlessListEntry[filerEntryKey(filer.IamConfigDirectory+"/"+IamPoliciesDirectory, "cancel-me.json")] = struct{}{}
|
||||
server.beforeLookup = func(ctx context.Context, dir string, name string) error {
|
||||
if dir == filer.IamConfigDirectory+"/"+IamPoliciesDirectory && name == "cancel-me.json" {
|
||||
cancel()
|
||||
return status.Error(codes.Canceled, context.Canceled.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
server.mu.Unlock()
|
||||
|
||||
managedPolicies, err := store.LoadManagedPolicies(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, managedPolicies)
|
||||
}
|
||||
|
||||
func testPolicyDocument(action string, resource string) policy_engine.PolicyDocument {
|
||||
return policy_engine.PolicyDocument{
|
||||
Version: policy_engine.PolicyVersion2012_10_17,
|
||||
Statement: []policy_engine.PolicyStatement{
|
||||
{
|
||||
Effect: policy_engine.PolicyEffectAllow,
|
||||
Action: policy_engine.NewStringOrStringSlice(action),
|
||||
Resource: policy_engine.NewStringOrStringSlice(resource),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func managedPolicyNames(policies []*iam_pb.Policy) []string {
|
||||
names := make([]string, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
names = append(names, policy.Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func assertInlinePolicyPreserved(t *testing.T, inlinePolicies map[string]map[string]policy_engine.PolicyDocument, userName string, policyName string) {
|
||||
t.Helper()
|
||||
|
||||
userPolicies, found := inlinePolicies[userName]
|
||||
require.True(t, found)
|
||||
|
||||
policy, found := userPolicies[policyName]
|
||||
require.True(t, found)
|
||||
assert.Equal(t, policy_engine.PolicyVersion2012_10_17, policy.Version)
|
||||
require.Len(t, policy.Statement, 1)
|
||||
assert.Equal(t, policy_engine.PolicyEffectAllow, policy.Statement[0].Effect)
|
||||
}
|
||||
|
||||
func cloneEntry(entry *filer_pb.Entry) *filer_pb.Entry {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
return proto.Clone(entry).(*filer_pb.Entry)
|
||||
}
|
||||
|
||||
func filerEntryKey(dir string, name string) string {
|
||||
return dir + "\x00" + name
|
||||
}
|
||||
|
||||
func splitFilerEntryKey(key string) (dir string, name string) {
|
||||
for idx := 0; idx < len(key); idx++ {
|
||||
if key[idx] == '\x00' {
|
||||
return key[:idx], key[idx+1:]
|
||||
}
|
||||
}
|
||||
return key, ""
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (store *FilerEtcStore) loadServiceAccountsFromMultiFile(ctx context.Context
|
||||
if len(entry.Content) > 0 {
|
||||
content = entry.Content
|
||||
} else {
|
||||
c, err := filer.ReadInsideFiler(client, dir, entry.Name)
|
||||
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to read service account file %s: %v", entry.Name, err)
|
||||
continue
|
||||
@@ -133,7 +133,7 @@ func (store *FilerEtcStore) GetServiceAccount(ctx context.Context, id string) (*
|
||||
}
|
||||
var sa *iam_pb.ServiceAccount
|
||||
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := filer.ReadInsideFiler(client, filer.IamConfigDirectory+"/"+IamServiceAccountsDirectory, id+".json")
|
||||
data, err := filer.ReadInsideFiler(ctx, client, filer.IamConfigDirectory+"/"+IamServiceAccountsDirectory, id+".json")
|
||||
if err != nil {
|
||||
if err == filer_pb.ErrNotFound {
|
||||
return credential.ErrServiceAccountNotFound
|
||||
@@ -170,7 +170,7 @@ func (store *FilerEtcStore) ListServiceAccounts(ctx context.Context) ([]*iam_pb.
|
||||
if len(entry.Content) > 0 {
|
||||
content = entry.Content
|
||||
} else {
|
||||
c, err := filer.ReadInsideFiler(client, dir, entry.Name)
|
||||
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
|
||||
if err != nil {
|
||||
glog.Warningf("Failed to read service account file %s: %v", entry.Name, err)
|
||||
continue
|
||||
|
||||
@@ -20,6 +20,7 @@ type FilerEtcStore struct {
|
||||
filerAddressFunc func() pb.ServerAddress // Function to get current active filer
|
||||
grpcDialOption grpc.DialOption
|
||||
mu sync.RWMutex // Protects filerAddressFunc and grpcDialOption
|
||||
policyMu sync.Mutex // Serializes legacy managed-policy mutations
|
||||
}
|
||||
|
||||
func (store *FilerEtcStore) GetName() credential.CredentialStoreTypeName {
|
||||
|
||||
@@ -20,6 +20,14 @@ import (
|
||||
var _ CredentialStore = &PropagatingCredentialStore{}
|
||||
var _ PolicyManager = &PropagatingCredentialStore{}
|
||||
|
||||
type propagatingManagedPolicyLoader interface {
|
||||
LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error)
|
||||
}
|
||||
|
||||
type propagatingInlinePolicyLoader interface {
|
||||
LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error)
|
||||
}
|
||||
|
||||
type PropagatingCredentialStore struct {
|
||||
CredentialStore
|
||||
masterClient *wdclient.MasterClient
|
||||
@@ -240,6 +248,38 @@ func (s *PropagatingCredentialStore) ListPolicyNames(ctx context.Context) ([]str
|
||||
return s.CredentialStore.ListPolicyNames(ctx)
|
||||
}
|
||||
|
||||
func (s *PropagatingCredentialStore) LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error) {
|
||||
if loader, ok := s.CredentialStore.(propagatingManagedPolicyLoader); ok {
|
||||
return loader.LoadManagedPolicies(ctx)
|
||||
}
|
||||
|
||||
policies, err := s.CredentialStore.GetPolicies(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
managedPolicies := make([]*iam_pb.Policy, 0, len(policies))
|
||||
for name, policyDocument := range policies {
|
||||
content, err := json.Marshal(policyDocument)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
managedPolicies = append(managedPolicies, &iam_pb.Policy{
|
||||
Name: name,
|
||||
Content: string(content),
|
||||
})
|
||||
}
|
||||
|
||||
return managedPolicies, nil
|
||||
}
|
||||
|
||||
func (s *PropagatingCredentialStore) LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
|
||||
if loader, ok := s.CredentialStore.(propagatingInlinePolicyLoader); ok {
|
||||
return loader.LoadInlinePolicies(ctx)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *PropagatingCredentialStore) CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
|
||||
if pm, ok := s.CredentialStore.(PolicyManager); ok {
|
||||
if err := pm.CreatePolicy(ctx, name, document); err != nil {
|
||||
|
||||
@@ -47,7 +47,7 @@ func ReadFilerConfFromFilers(filerGrpcAddresses []pb.ServerAddress, grpcDialOpti
|
||||
data = buf.Bytes()
|
||||
return nil
|
||||
}
|
||||
content, err := ReadInsideFiler(client, DirectoryEtcSeaweedFS, FilerConfName)
|
||||
content, err := ReadInsideFiler(context.Background(), client, DirectoryEtcSeaweedFS, FilerConfName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+45
-25
@@ -19,19 +19,23 @@ import (
|
||||
)
|
||||
|
||||
func (f *Filer) NotifyUpdateEvent(ctx context.Context, oldEntry, newEntry *Entry, deleteChunks, isFromOtherCluster bool, signatures []int32) {
|
||||
f.notifyUpdateEvent(ctx, oldEntry, newEntry, deleteChunks, isFromOtherCluster, signatures)
|
||||
}
|
||||
|
||||
func (f *Filer) notifyUpdateEvent(ctx context.Context, oldEntry, newEntry *Entry, deleteChunks, isFromOtherCluster bool, signatures []int32) *filer_pb.SubscribeMetadataResponse {
|
||||
var fullpath string
|
||||
if oldEntry != nil {
|
||||
fullpath = string(oldEntry.FullPath)
|
||||
} else if newEntry != nil {
|
||||
fullpath = string(newEntry.FullPath)
|
||||
} else {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// println("fullpath:", fullpath)
|
||||
|
||||
if strings.HasPrefix(fullpath, SystemLogDir) {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
foundSelf := false
|
||||
for _, sig := range signatures {
|
||||
@@ -43,18 +47,8 @@ func (f *Filer) NotifyUpdateEvent(ctx context.Context, oldEntry, newEntry *Entry
|
||||
signatures = append(signatures, f.Signature)
|
||||
}
|
||||
|
||||
newParentPath := ""
|
||||
if newEntry != nil {
|
||||
newParentPath, _ = newEntry.FullPath.DirAndName()
|
||||
}
|
||||
eventNotification := &filer_pb.EventNotification{
|
||||
OldEntry: oldEntry.ToProtoEntry(),
|
||||
NewEntry: newEntry.ToProtoEntry(),
|
||||
DeleteChunks: deleteChunks,
|
||||
NewParentPath: newParentPath,
|
||||
IsFromOtherCluster: isFromOtherCluster,
|
||||
Signatures: signatures,
|
||||
}
|
||||
event := f.newMetadataEvent(oldEntry, newEntry, deleteChunks, isFromOtherCluster, signatures)
|
||||
eventNotification := event.EventNotification
|
||||
|
||||
if notification.Queue != nil {
|
||||
glog.V(3).Infof("notifying entry update %v", fullpath)
|
||||
@@ -64,31 +58,57 @@ func (f *Filer) NotifyUpdateEvent(ctx context.Context, oldEntry, newEntry *Entry
|
||||
}
|
||||
}
|
||||
|
||||
f.logMetaEvent(ctx, fullpath, eventNotification)
|
||||
f.logMetaEvent(ctx, event)
|
||||
if sink := metadataEventSinkFromContext(ctx); sink != nil {
|
||||
sink.Record(event)
|
||||
}
|
||||
|
||||
// Trigger empty folder cleanup for local events
|
||||
// Remote events are handled via MetaAggregator.onMetadataChangeEvent
|
||||
f.triggerLocalEmptyFolderCleanup(oldEntry, newEntry)
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
func (f *Filer) logMetaEvent(ctx context.Context, fullpath string, eventNotification *filer_pb.EventNotification) {
|
||||
|
||||
dir, _ := util.FullPath(fullpath).DirAndName()
|
||||
|
||||
event := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: dir,
|
||||
EventNotification: eventNotification,
|
||||
TsNs: time.Now().UnixNano(),
|
||||
func (f *Filer) newMetadataEvent(oldEntry, newEntry *Entry, deleteChunks, isFromOtherCluster bool, signatures []int32) *filer_pb.SubscribeMetadataResponse {
|
||||
if oldEntry == nil && newEntry == nil {
|
||||
return nil
|
||||
}
|
||||
var fullpath util.FullPath
|
||||
if oldEntry != nil {
|
||||
fullpath = oldEntry.FullPath
|
||||
}
|
||||
if fullpath == "" && newEntry != nil {
|
||||
fullpath = newEntry.FullPath
|
||||
}
|
||||
dir, _ := fullpath.DirAndName()
|
||||
newParentPath := ""
|
||||
if newEntry != nil {
|
||||
newParentPath, _ = newEntry.FullPath.DirAndName()
|
||||
}
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: dir,
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: oldEntry.ToProtoEntry(),
|
||||
NewEntry: newEntry.ToProtoEntry(),
|
||||
DeleteChunks: deleteChunks,
|
||||
NewParentPath: newParentPath,
|
||||
IsFromOtherCluster: isFromOtherCluster,
|
||||
Signatures: signatures,
|
||||
},
|
||||
TsNs: time.Now().UnixNano(),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Filer) logMetaEvent(ctx context.Context, event *filer_pb.SubscribeMetadataResponse) {
|
||||
data, err := proto.Marshal(event)
|
||||
if err != nil {
|
||||
glog.Errorf("failed to marshal filer_pb.SubscribeMetadataResponse %+v: %v", event, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := f.LocalMetaLogBuffer.AddDataToBuffer([]byte(dir), data, event.TsNs); err != nil {
|
||||
glog.Errorf("failed to add data to log buffer for %s: %v", dir, err)
|
||||
if err := f.LocalMetaLogBuffer.AddDataToBuffer([]byte(event.Directory), data, event.TsNs); err != nil {
|
||||
glog.Errorf("failed to add data to log buffer for %s: %v", event.Directory, err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
|
||||
type metadataEventSinkKey struct{}
|
||||
|
||||
// MetadataEventSink captures the last metadata event emitted while serving a
|
||||
// request. It is request-scoped and accessed only by the goroutine handling
|
||||
// the gRPC call, so no mutex is needed.
|
||||
type MetadataEventSink struct {
|
||||
last *filer_pb.SubscribeMetadataResponse
|
||||
}
|
||||
|
||||
func WithMetadataEventSink(ctx context.Context) (context.Context, *MetadataEventSink) {
|
||||
sink := &MetadataEventSink{}
|
||||
return context.WithValue(ctx, metadataEventSinkKey{}, sink), sink
|
||||
}
|
||||
|
||||
func metadataEventSinkFromContext(ctx context.Context) *MetadataEventSink {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
sink, _ := ctx.Value(metadataEventSinkKey{}).(*MetadataEventSink)
|
||||
return sink
|
||||
}
|
||||
|
||||
// Record stores the event, replacing any previously recorded one.
|
||||
// Each filer RPC emits at most one NotifyUpdateEvent, so only the last
|
||||
// event is retained. If an RPC were to emit multiple events, only the
|
||||
// final one would be returned to the caller.
|
||||
func (s *MetadataEventSink) Record(event *filer_pb.SubscribeMetadataResponse) {
|
||||
if s == nil || event == nil {
|
||||
return
|
||||
}
|
||||
s.last = event
|
||||
}
|
||||
|
||||
func (s *MetadataEventSink) Last() *filer_pb.SubscribeMetadataResponse {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.last
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
|
||||
)
|
||||
|
||||
func TestNotifyUpdateEventRecordsRequestMetadataEvent(t *testing.T) {
|
||||
f := &Filer{
|
||||
Signature: 42,
|
||||
LocalMetaLogBuffer: log_buffer.NewLogBuffer(
|
||||
"test",
|
||||
time.Hour,
|
||||
func(*log_buffer.LogBuffer, time.Time, time.Time, []byte, int64, int64) {},
|
||||
nil,
|
||||
nil,
|
||||
),
|
||||
}
|
||||
|
||||
ctx, sink := WithMetadataEventSink(context.Background())
|
||||
f.NotifyUpdateEvent(ctx, &Entry{FullPath: util.FullPath("/dir/file.txt")}, nil, true, false, []int32{7})
|
||||
|
||||
event := sink.Last()
|
||||
if event == nil {
|
||||
t.Fatal("expected metadata event to be recorded")
|
||||
}
|
||||
if event.Directory != "/dir" {
|
||||
t.Fatalf("directory = %q, want /dir", event.Directory)
|
||||
}
|
||||
if event.EventNotification.OldEntry == nil || event.EventNotification.OldEntry.Name != "file.txt" {
|
||||
t.Fatalf("old entry = %+v, want file.txt", event.EventNotification.OldEntry)
|
||||
}
|
||||
if got := event.EventNotification.Signatures; len(got) != 2 || got[0] != 7 || got[1] != 42 {
|
||||
t.Fatalf("signatures = %v, want [7 42]", got)
|
||||
}
|
||||
if event.TsNs == 0 {
|
||||
t.Fatal("expected event timestamp to be set")
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,12 @@ func ReadEntry(masterClient *wdclient.MasterClient, filerClient filer_pb.Seaweed
|
||||
|
||||
}
|
||||
|
||||
func ReadInsideFiler(filerClient filer_pb.SeaweedFilerClient, dir, name string) (content []byte, err error) {
|
||||
func ReadInsideFiler(ctx context.Context, filerClient filer_pb.SeaweedFilerClient, dir, name string) (content []byte, err error) {
|
||||
request := &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir,
|
||||
Name: name,
|
||||
}
|
||||
respLookupEntry, err := filer_pb.LookupEntry(context.Background(), filerClient, request)
|
||||
respLookupEntry, err := filer_pb.LookupEntry(ctx, filerClient, request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package filer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
@@ -13,7 +14,7 @@ import (
|
||||
func ReadMountMappings(grpcDialOption grpc.DialOption, filerAddress pb.ServerAddress) (mappings *remote_pb.RemoteStorageMapping, readErr error) {
|
||||
var oldContent []byte
|
||||
if readErr = pb.WithFilerClient(false, 0, filerAddress, grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
oldContent, readErr = ReadInsideFiler(client, DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE)
|
||||
oldContent, readErr = ReadInsideFiler(context.Background(), client, DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE)
|
||||
return readErr
|
||||
}); readErr != nil {
|
||||
if readErr != filer_pb.ErrNotFound {
|
||||
@@ -34,7 +35,7 @@ func InsertMountMapping(filerClient filer_pb.FilerClient, dir string, remoteStor
|
||||
// read current mapping
|
||||
var oldContent, newContent []byte
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
oldContent, err = ReadInsideFiler(client, DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE)
|
||||
oldContent, err = ReadInsideFiler(context.Background(), client, DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -65,7 +66,7 @@ func DeleteMountMapping(filerClient filer_pb.FilerClient, dir string) (err error
|
||||
// read current mapping
|
||||
var oldContent, newContent []byte
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
oldContent, err = ReadInsideFiler(client, DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE)
|
||||
oldContent, err = ReadInsideFiler(context.Background(), client, DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -138,7 +138,7 @@ func UnmarshalRemoteStorageMappings(oldContent []byte) (mappings *remote_pb.Remo
|
||||
func ReadRemoteStorageConf(grpcDialOption grpc.DialOption, filerAddress pb.ServerAddress, storageName string) (conf *remote_pb.RemoteConf, readErr error) {
|
||||
var oldContent []byte
|
||||
if readErr = pb.WithFilerClient(false, 0, filerAddress, grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
oldContent, readErr = ReadInsideFiler(client, DirectoryEtcRemote, storageName+REMOTE_STORAGE_CONF_SUFFIX)
|
||||
oldContent, readErr = ReadInsideFiler(context.Background(), client, DirectoryEtcRemote, storageName+REMOTE_STORAGE_CONF_SUFFIX)
|
||||
return readErr
|
||||
}); readErr != nil {
|
||||
return nil, readErr
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
@@ -28,6 +29,8 @@ type IAMManager struct {
|
||||
userStore UserStore
|
||||
filerAddressProvider func() string // Function to get current filer address
|
||||
initialized bool
|
||||
runtimePolicyMu sync.Mutex
|
||||
runtimePolicyNames map[string]struct{}
|
||||
}
|
||||
|
||||
// IAMConfig holds configuration for all IAM components
|
||||
@@ -105,6 +108,57 @@ func (m *IAMManager) SetUserStore(store UserStore) {
|
||||
m.userStore = store
|
||||
}
|
||||
|
||||
// SyncRuntimePolicies keeps zero-config runtime policies available to the
|
||||
// in-memory policy engine used by the advanced IAM authorizer.
|
||||
func (m *IAMManager) SyncRuntimePolicies(ctx context.Context, policies []*iam_pb.Policy) error {
|
||||
if !m.initialized || m.policyEngine == nil {
|
||||
return nil
|
||||
}
|
||||
if m.policyEngine.StoreType() != sts.StoreTypeMemory {
|
||||
return nil
|
||||
}
|
||||
|
||||
desiredPolicies := make(map[string]*policy.PolicyDocument, len(policies))
|
||||
for _, runtimePolicy := range policies {
|
||||
if runtimePolicy == nil || runtimePolicy.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var document policy.PolicyDocument
|
||||
if err := json.Unmarshal([]byte(runtimePolicy.Content), &document); err != nil {
|
||||
return fmt.Errorf("failed to parse runtime policy %q: %w", runtimePolicy.Name, err)
|
||||
}
|
||||
|
||||
desiredPolicies[runtimePolicy.Name] = &document
|
||||
}
|
||||
|
||||
m.runtimePolicyMu.Lock()
|
||||
defer m.runtimePolicyMu.Unlock()
|
||||
|
||||
filerAddress := m.getFilerAddress()
|
||||
for policyName := range m.runtimePolicyNames {
|
||||
if _, keep := desiredPolicies[policyName]; keep {
|
||||
continue
|
||||
}
|
||||
if err := m.policyEngine.DeletePolicy(ctx, filerAddress, policyName); err != nil {
|
||||
return fmt.Errorf("failed to delete runtime policy %q: %w", policyName, err)
|
||||
}
|
||||
}
|
||||
|
||||
for policyName, document := range desiredPolicies {
|
||||
if err := m.policyEngine.AddPolicy(filerAddress, policyName, document); err != nil {
|
||||
return fmt.Errorf("failed to sync runtime policy %q: %w", policyName, err)
|
||||
}
|
||||
}
|
||||
|
||||
m.runtimePolicyNames = make(map[string]struct{}, len(desiredPolicies))
|
||||
for policyName := range desiredPolicies {
|
||||
m.runtimePolicyNames[policyName] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize initializes the IAM manager with all components
|
||||
func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() string) error {
|
||||
if config == nil {
|
||||
@@ -422,6 +476,7 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
|
||||
var baseResult *policy.EvaluationResult
|
||||
var err error
|
||||
subjectPolicyCount := 0
|
||||
|
||||
if isAdmin {
|
||||
// Admin always has base access allowed
|
||||
@@ -454,6 +509,7 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
policies = roleDef.AttachedPolicies
|
||||
}
|
||||
}
|
||||
subjectPolicyCount = len(policies)
|
||||
|
||||
if bucketPolicyName != "" {
|
||||
// Enforce an upper bound on the number of policies to avoid excessive allocations
|
||||
@@ -477,6 +533,14 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Zero-config IAM uses DefaultEffect=Allow to preserve open-by-default behavior
|
||||
// for requests without any subject policies. Once a user or role has attached
|
||||
// policies, "no matching statement" must fall back to deny so the attachment
|
||||
// actually scopes access.
|
||||
if subjectPolicyCount > 0 && len(baseResult.MatchingStatements) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// If there's a session policy, it must also allow the action
|
||||
if sessionInfo != nil && sessionInfo.SessionPolicy != "" {
|
||||
var sessionPolicy policy.PolicyDocument
|
||||
|
||||
@@ -353,6 +353,27 @@ func (e *PolicyEngine) AddPolicy(filerAddress string, name string, policy *Polic
|
||||
return e.store.StorePolicy(context.Background(), filerAddress, name, policy)
|
||||
}
|
||||
|
||||
// DeletePolicy removes a policy from the configured store.
|
||||
func (e *PolicyEngine) DeletePolicy(ctx context.Context, filerAddress string, name string) error {
|
||||
if !e.initialized {
|
||||
return fmt.Errorf("policy engine not initialized")
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return fmt.Errorf("policy name cannot be empty")
|
||||
}
|
||||
|
||||
return e.store.DeletePolicy(ctx, filerAddress, name)
|
||||
}
|
||||
|
||||
// StoreType returns the configured backend type for the policy store.
|
||||
func (e *PolicyEngine) StoreType() string {
|
||||
if e.config == nil {
|
||||
return ""
|
||||
}
|
||||
return e.config.StoreType
|
||||
}
|
||||
|
||||
// Evaluate evaluates policies against a request context (filerAddress ignored for memory stores)
|
||||
func (e *PolicyEngine) Evaluate(ctx context.Context, filerAddress string, evalCtx *EvaluationContext, policyNames []string) (*EvaluationResult, error) {
|
||||
if !e.initialized {
|
||||
|
||||
+201
-65
@@ -3,15 +3,19 @@ package policy
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/credential"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// MemoryPolicyStore implements PolicyStore using in-memory storage
|
||||
@@ -134,11 +138,15 @@ func copyPolicyDocument(original *PolicyDocument) *PolicyDocument {
|
||||
copy(copied.Statement[i].NotResource, stmt.NotResource)
|
||||
}
|
||||
|
||||
// Copy condition map (shallow copy for now)
|
||||
// Copy condition map
|
||||
if stmt.Condition != nil {
|
||||
copied.Statement[i].Condition = make(map[string]map[string]interface{})
|
||||
for k, v := range stmt.Condition {
|
||||
copied.Statement[i].Condition[k] = v
|
||||
for conditionType, conditionValues := range stmt.Condition {
|
||||
copiedConditionValues := make(map[string]interface{}, len(conditionValues))
|
||||
for conditionKey, conditionValue := range conditionValues {
|
||||
copiedConditionValues[conditionKey] = copyPolicyConditionValue(conditionValue)
|
||||
}
|
||||
copied.Statement[i].Condition[conditionType] = copiedConditionValues
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +154,29 @@ func copyPolicyDocument(original *PolicyDocument) *PolicyDocument {
|
||||
return copied
|
||||
}
|
||||
|
||||
func copyPolicyConditionValue(value interface{}) interface{} {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
copied := make([]string, len(v))
|
||||
copy(copied, v)
|
||||
return copied
|
||||
case []interface{}:
|
||||
copied := make([]interface{}, len(v))
|
||||
for i := range v {
|
||||
copied[i] = copyPolicyConditionValue(v[i])
|
||||
}
|
||||
return copied
|
||||
case map[string]interface{}:
|
||||
copied := make(map[string]interface{}, len(v))
|
||||
for key, nestedValue := range v {
|
||||
copied[key] = copyPolicyConditionValue(nestedValue)
|
||||
}
|
||||
return copied
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// FilerPolicyStore implements PolicyStore using SeaweedFS filer
|
||||
type FilerPolicyStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
@@ -198,27 +229,13 @@ func (s *FilerPolicyStore) StorePolicy(ctx context.Context, filerAddress string,
|
||||
|
||||
// Store in filer
|
||||
return s.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.CreateEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: s.getPolicyFileName(name),
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0600), // Read/write for owner only
|
||||
Uid: uint32(0),
|
||||
Gid: uint32(0),
|
||||
},
|
||||
Content: policyData,
|
||||
},
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Storing policy %s at %s", name, policyPath)
|
||||
_, err := client.CreateEntry(ctx, request)
|
||||
if err != nil {
|
||||
if err := s.savePolicyFile(ctx, client, s.getPolicyFileName(name), policyData); err != nil {
|
||||
return fmt.Errorf("failed to store policy %s: %v", name, err)
|
||||
}
|
||||
if err := s.deleteLegacyPolicyFileIfPresent(ctx, client, name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -239,23 +256,30 @@ func (s *FilerPolicyStore) GetPolicy(ctx context.Context, filerAddress string, n
|
||||
|
||||
var policyData []byte
|
||||
err := s.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Name: s.getPolicyFileName(name),
|
||||
for _, fileName := range s.getPolicyLookupFileNames(name) {
|
||||
request := &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Name: fileName,
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Looking up policy %s as %s", name, fileName)
|
||||
response, err := client.LookupDirectoryEntry(ctx, request)
|
||||
if err != nil {
|
||||
if isNotFoundPolicyStoreError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("policy lookup failed: %v", err)
|
||||
}
|
||||
|
||||
if response.Entry == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
policyData = response.Entry.Content
|
||||
return nil
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Looking up policy %s", name)
|
||||
response, err := client.LookupDirectoryEntry(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("policy not found: %v", err)
|
||||
}
|
||||
|
||||
if response.Entry == nil {
|
||||
return fmt.Errorf("policy not found")
|
||||
}
|
||||
|
||||
policyData = response.Entry.Content
|
||||
return nil
|
||||
return fmt.Errorf("policy not found")
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -285,31 +309,27 @@ func (s *FilerPolicyStore) DeletePolicy(ctx context.Context, filerAddress string
|
||||
}
|
||||
|
||||
return s.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.DeleteEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Name: s.getPolicyFileName(name),
|
||||
IsDeleteData: true,
|
||||
IsRecursive: false,
|
||||
IgnoreRecursiveError: false,
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Deleting policy %s", name)
|
||||
resp, err := client.DeleteEntry(ctx, request)
|
||||
if err != nil {
|
||||
// Ignore "not found" errors - policy may already be deleted
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return nil
|
||||
for _, fileName := range s.getPolicyLookupFileNames(name) {
|
||||
request := &filer_pb.DeleteEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Name: fileName,
|
||||
IsDeleteData: true,
|
||||
IsRecursive: false,
|
||||
IgnoreRecursiveError: false,
|
||||
}
|
||||
return fmt.Errorf("failed to delete policy %s: %v", name, err)
|
||||
}
|
||||
|
||||
// Check response error
|
||||
if resp.Error != "" {
|
||||
// Ignore "not found" errors - policy may already be deleted
|
||||
if strings.Contains(resp.Error, "not found") {
|
||||
return nil
|
||||
glog.V(3).Infof("Deleting policy %s as %s", name, fileName)
|
||||
resp, err := client.DeleteEntry(ctx, request)
|
||||
if err != nil {
|
||||
if isNotFoundPolicyStoreError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("failed to delete policy %s: %v", name, err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("failed to delete policy %s: %s", name, resp.Error)
|
||||
}
|
||||
return fmt.Errorf("failed to delete policy %s: %s", name, resp.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -332,7 +352,7 @@ func (s *FilerPolicyStore) ListPolicies(ctx context.Context, filerAddress string
|
||||
// List all entries in the policy directory
|
||||
request := &filer_pb.ListEntriesRequest{
|
||||
Directory: s.basePath,
|
||||
Prefix: "policy_",
|
||||
Prefix: "",
|
||||
StartFromFileName: "",
|
||||
InclusiveStartFrom: false,
|
||||
Limit: 1000, // Process in batches of 1000
|
||||
@@ -353,11 +373,7 @@ func (s *FilerPolicyStore) ListPolicies(ctx context.Context, filerAddress string
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract policy name from filename
|
||||
filename := resp.Entry.Name
|
||||
if strings.HasPrefix(filename, "policy_") && strings.HasSuffix(filename, ".json") {
|
||||
// Remove "policy_" prefix and ".json" suffix
|
||||
policyName := strings.TrimSuffix(strings.TrimPrefix(filename, "policy_"), ".json")
|
||||
if policyName, ok := s.policyNameFromFileName(resp.Entry.Name); ok {
|
||||
policyNames = append(policyNames, policyName)
|
||||
}
|
||||
}
|
||||
@@ -369,7 +385,17 @@ func (s *FilerPolicyStore) ListPolicies(ctx context.Context, filerAddress string
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return policyNames, nil
|
||||
uniquePolicyNames := make([]string, 0, len(policyNames))
|
||||
seen := make(map[string]struct{}, len(policyNames))
|
||||
for _, policyName := range policyNames {
|
||||
if _, found := seen[policyName]; found {
|
||||
continue
|
||||
}
|
||||
seen[policyName] = struct{}{}
|
||||
uniquePolicyNames = append(uniquePolicyNames, policyName)
|
||||
}
|
||||
|
||||
return uniquePolicyNames, nil
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
@@ -391,5 +417,115 @@ func (s *FilerPolicyStore) getPolicyPath(policyName string) string {
|
||||
|
||||
// getPolicyFileName returns the filename for a policy
|
||||
func (s *FilerPolicyStore) getPolicyFileName(policyName string) string {
|
||||
return s.getCanonicalPolicyFileName(policyName)
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) getLegacyPolicyFileName(policyName string) string {
|
||||
return "policy_" + policyName + ".json"
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) getCanonicalPolicyFileName(policyName string) string {
|
||||
return policyName + ".json"
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) getPolicyLookupFileNames(policyName string) []string {
|
||||
return []string{
|
||||
s.getCanonicalPolicyFileName(policyName),
|
||||
s.getLegacyPolicyFileName(policyName),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) policyNameFromFileName(fileName string) (string, bool) {
|
||||
if !strings.HasSuffix(fileName, ".json") {
|
||||
return "", false
|
||||
}
|
||||
policyName := strings.TrimSuffix(fileName, ".json")
|
||||
if strings.HasPrefix(fileName, "policy_") {
|
||||
policyName = strings.TrimPrefix(policyName, "policy_")
|
||||
}
|
||||
if s.isSupportedPolicyName(policyName) {
|
||||
return policyName, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) isSupportedPolicyName(policyName string) bool {
|
||||
if policyName == "" {
|
||||
return false
|
||||
}
|
||||
// Bucket policies are stored alongside IAM policies but use the internal
|
||||
// "bucket-policy:<bucket>" naming scheme, which is intentionally outside the
|
||||
// public IAM policy-name validator.
|
||||
if strings.HasPrefix(policyName, "bucket-policy:") {
|
||||
return len(policyName) > len("bucket-policy:")
|
||||
}
|
||||
return credential.ValidatePolicyName(policyName) == nil
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) deleteLegacyPolicyFileIfPresent(ctx context.Context, client filer_pb.SeaweedFilerClient, policyName string) error {
|
||||
legacyFileName := s.getLegacyPolicyFileName(policyName)
|
||||
response, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Name: legacyFileName,
|
||||
IsDeleteData: true,
|
||||
IsRecursive: false,
|
||||
IgnoreRecursiveError: false,
|
||||
})
|
||||
if err != nil {
|
||||
if isNotFoundPolicyStoreError(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to delete legacy policy %s: %v", policyName, err)
|
||||
}
|
||||
if response.Error != "" {
|
||||
return fmt.Errorf("failed to delete legacy policy %s: %s", policyName, response.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FilerPolicyStore) savePolicyFile(ctx context.Context, client filer_pb.SeaweedFilerClient, fileName string, content []byte) error {
|
||||
now := time.Now().Unix()
|
||||
entry := &filer_pb.Entry{
|
||||
Name: fileName,
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: now,
|
||||
Crtime: now,
|
||||
FileMode: uint32(0600),
|
||||
Uid: uint32(0),
|
||||
Gid: uint32(0),
|
||||
FileSize: uint64(len(content)),
|
||||
},
|
||||
Content: content,
|
||||
}
|
||||
|
||||
createRequest := &filer_pb.CreateEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Entry: entry,
|
||||
}
|
||||
|
||||
if err := filer_pb.CreateEntry(ctx, client, createRequest); err == nil {
|
||||
return nil
|
||||
} else if !isAlreadyExistsPolicyStoreError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return filer_pb.UpdateEntry(ctx, client, &filer_pb.UpdateEntryRequest{
|
||||
Directory: s.basePath,
|
||||
Entry: entry,
|
||||
})
|
||||
}
|
||||
|
||||
func isNotFoundPolicyStoreError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return errors.Is(err, filer_pb.ErrNotFound) || status.Code(err) == codes.NotFound
|
||||
}
|
||||
|
||||
func isAlreadyExistsPolicyStoreError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return status.Code(err) == codes.AlreadyExists
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type policyStoreTestFilerServer struct {
|
||||
filer_pb.UnimplementedSeaweedFilerServer
|
||||
mu sync.RWMutex
|
||||
entries map[string]*filer_pb.Entry
|
||||
}
|
||||
|
||||
func newPolicyStoreTestFilerServer() *policyStoreTestFilerServer {
|
||||
return &policyStoreTestFilerServer{
|
||||
entries: make(map[string]*filer_pb.Entry),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
entry, found := s.entries[policyStoreTestEntryKey(req.Directory, req.Name)]
|
||||
if !found {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: clonePolicyStoreEntry(entry)}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := policyStoreTestEntryKey(req.Directory, req.Entry.Name)
|
||||
if _, found := s.entries[key]; found {
|
||||
return nil, status.Error(codes.AlreadyExists, "entry already exists")
|
||||
}
|
||||
|
||||
s.entries[key] = clonePolicyStoreEntry(req.Entry)
|
||||
return &filer_pb.CreateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := policyStoreTestEntryKey(req.Directory, req.Entry.Name)
|
||||
if _, found := s.entries[key]; !found {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
|
||||
s.entries[key] = clonePolicyStoreEntry(req.Entry)
|
||||
return &filer_pb.UpdateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) ListEntries(req *filer_pb.ListEntriesRequest, stream grpc.ServerStreamingServer[filer_pb.ListEntriesResponse]) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0)
|
||||
for key := range s.entries {
|
||||
dir, name := splitPolicyStoreEntryKey(key)
|
||||
if dir != req.Directory {
|
||||
continue
|
||||
}
|
||||
if req.Prefix != "" && len(name) >= len(req.Prefix) && name[:len(req.Prefix)] != req.Prefix {
|
||||
continue
|
||||
}
|
||||
if req.Prefix != "" && len(name) < len(req.Prefix) {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
if err := stream.Send(&filer_pb.ListEntriesResponse{
|
||||
Entry: clonePolicyStoreEntry(s.entries[policyStoreTestEntryKey(req.Directory, name)]),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key := policyStoreTestEntryKey(req.Directory, req.Name)
|
||||
if _, found := s.entries[key]; !found {
|
||||
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
|
||||
}
|
||||
|
||||
delete(s.entries, key)
|
||||
return &filer_pb.DeleteEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) putPolicyFile(t *testing.T, dir string, name string, document *PolicyDocument) {
|
||||
t.Helper()
|
||||
|
||||
content, err := json.Marshal(document)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.entries[policyStoreTestEntryKey(dir, name)] = &filer_pb.Entry{
|
||||
Name: name,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *policyStoreTestFilerServer) hasEntry(dir string, name string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, found := s.entries[policyStoreTestEntryKey(dir, name)]
|
||||
return found
|
||||
}
|
||||
|
||||
func newTestFilerPolicyStore(t *testing.T) (*FilerPolicyStore, *policyStoreTestFilerServer) {
|
||||
t.Helper()
|
||||
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
server := newPolicyStoreTestFilerServer()
|
||||
grpcServer := pb.NewGrpcServer()
|
||||
filer_pb.RegisterSeaweedFilerServer(grpcServer, server)
|
||||
go func() {
|
||||
_ = grpcServer.Serve(lis)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
grpcServer.Stop()
|
||||
_ = lis.Close()
|
||||
})
|
||||
|
||||
host, portString, err := net.SplitHostPort(lis.Addr().String())
|
||||
require.NoError(t, err)
|
||||
grpcPort, err := strconv.Atoi(portString)
|
||||
require.NoError(t, err)
|
||||
|
||||
store, err := NewFilerPolicyStore(nil, func() string {
|
||||
return string(pb.NewServerAddress(host, 1, grpcPort))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
store.grpcDialOption = grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
|
||||
return store, server
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreGetPolicyPrefersCanonicalFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "cli-bucket-access-policy.json", testPolicyDocument("s3:ListBucket", "arn:aws:s3:::cli-allowed-bucket"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_cli-bucket-access-policy.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::cli-forbidden-bucket/*"))
|
||||
|
||||
document, err := store.GetPolicy(ctx, "", "cli-bucket-access-policy")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, document.Statement, 1)
|
||||
assert.Equal(t, "s3:ListBucket", document.Statement[0].Action[0])
|
||||
assert.Equal(t, "arn:aws:s3:::cli-allowed-bucket", document.Statement[0].Resource[0])
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreListPoliciesIncludesCanonicalAndLegacyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "canonical-only.json", testPolicyDocument("s3:GetObject", "arn:aws:s3:::canonical-only/*"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_legacy-only.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::legacy-only/*"))
|
||||
server.putPolicyFile(t, store.basePath, "shared.json", testPolicyDocument("s3:DeleteObject", "arn:aws:s3:::shared/*"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_shared.json", testPolicyDocument("s3:ListBucket", "arn:aws:s3:::shared"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_invalid:name.json", testPolicyDocument("s3:GetObject", "arn:aws:s3:::ignored/*"))
|
||||
server.putPolicyFile(t, store.basePath, "bucket-policy:bucket-a.json", testPolicyDocument("s3:ListBucket", "arn:aws:s3:::bucket-a"))
|
||||
|
||||
names, err := store.ListPolicies(ctx, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.ElementsMatch(t, []string{"canonical-only", "legacy-only", "shared", "bucket-policy:bucket-a"}, names)
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreDeletePolicyRemovesCanonicalAndLegacyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "dual-format.json", testPolicyDocument("s3:GetObject", "arn:aws:s3:::dual-format/*"))
|
||||
server.putPolicyFile(t, store.basePath, "policy_dual-format.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::dual-format/*"))
|
||||
|
||||
require.NoError(t, store.DeletePolicy(ctx, "", "dual-format"))
|
||||
assert.False(t, server.hasEntry(store.basePath, "dual-format.json"))
|
||||
assert.False(t, server.hasEntry(store.basePath, "policy_dual-format.json"))
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreStorePolicyWritesCanonicalFileAndRemovesLegacyTwin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "policy_dual-format.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::dual-format/*"))
|
||||
|
||||
require.NoError(t, store.StorePolicy(ctx, "", "dual-format", testPolicyDocument("s3:GetObject", "arn:aws:s3:::dual-format/*")))
|
||||
|
||||
assert.True(t, server.hasEntry(store.basePath, "dual-format.json"))
|
||||
assert.False(t, server.hasEntry(store.basePath, "policy_dual-format.json"))
|
||||
|
||||
document, err := store.GetPolicy(ctx, "", "dual-format")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, document.Statement, 1)
|
||||
assert.Equal(t, "s3:GetObject", document.Statement[0].Action[0])
|
||||
}
|
||||
|
||||
func TestFilerPolicyStoreStorePolicyUpdatesExistingCanonicalFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server := newTestFilerPolicyStore(t)
|
||||
|
||||
server.putPolicyFile(t, store.basePath, "existing.json", testPolicyDocument("s3:PutObject", "arn:aws:s3:::existing/*"))
|
||||
|
||||
require.NoError(t, store.StorePolicy(ctx, "", "existing", testPolicyDocument("s3:GetObject", "arn:aws:s3:::existing/*")))
|
||||
|
||||
document, err := store.GetPolicy(ctx, "", "existing")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, document.Statement, 1)
|
||||
assert.Equal(t, "s3:GetObject", document.Statement[0].Action[0])
|
||||
assert.Equal(t, "arn:aws:s3:::existing/*", document.Statement[0].Resource[0])
|
||||
}
|
||||
|
||||
func TestCopyPolicyDocumentClonesConditionState(t *testing.T) {
|
||||
original := &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{
|
||||
"arn:aws:s3:::test-bucket/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringEquals": {
|
||||
"s3:prefix": []string{"public/", "private/"},
|
||||
},
|
||||
"Null": {
|
||||
"aws:PrincipalArn": "false",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
copied := copyPolicyDocument(original)
|
||||
require.NotNil(t, copied)
|
||||
|
||||
original.Statement[0].Condition["StringEquals"]["s3:prefix"] = []string{"mutated/"}
|
||||
original.Statement[0].Condition["Null"]["aws:PrincipalArn"] = "true"
|
||||
|
||||
assert.Equal(t, []string{"public/", "private/"}, copied.Statement[0].Condition["StringEquals"]["s3:prefix"])
|
||||
assert.Equal(t, "false", copied.Statement[0].Condition["Null"]["aws:PrincipalArn"])
|
||||
}
|
||||
|
||||
func TestIsAlreadyExistsPolicyStoreErrorUsesStatusCode(t *testing.T) {
|
||||
assert.True(t, isAlreadyExistsPolicyStoreError(status.Error(codes.AlreadyExists, "entry already exists")))
|
||||
assert.False(t, isAlreadyExistsPolicyStoreError(fmt.Errorf("entry already exists")))
|
||||
}
|
||||
|
||||
func testPolicyDocument(action string, resource string) *PolicyDocument {
|
||||
return &PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{action},
|
||||
Resource: []string{resource},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func clonePolicyStoreEntry(entry *filer_pb.Entry) *filer_pb.Entry {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
return proto.Clone(entry).(*filer_pb.Entry)
|
||||
}
|
||||
|
||||
func policyStoreTestEntryKey(dir string, name string) string {
|
||||
return dir + "\x00" + name
|
||||
}
|
||||
|
||||
func splitPolicyStoreEntryKey(key string) (string, string) {
|
||||
for i := 0; i < len(key); i++ {
|
||||
if key[i] == '\x00' {
|
||||
return key[:i], key[i+1:]
|
||||
}
|
||||
}
|
||||
return key, ""
|
||||
}
|
||||
+43
-34
@@ -2,198 +2,207 @@ package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
)
|
||||
|
||||
// CommonResponse is embedded in all IAM response types to provide RequestId.
|
||||
// CommonResponse is embedded in IAM success response types to provide RequestId.
|
||||
type CommonResponse struct {
|
||||
ResponseMetadata struct {
|
||||
RequestId string `xml:"RequestId"`
|
||||
} `xml:"ResponseMetadata"`
|
||||
}
|
||||
|
||||
// SetRequestId sets a unique request ID based on current timestamp.
|
||||
func (r *CommonResponse) SetRequestId() {
|
||||
r.ResponseMetadata.RequestId = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
// SetRequestId stores the request ID generated for the current HTTP request.
|
||||
func (r *CommonResponse) SetRequestId(requestID string) {
|
||||
r.ResponseMetadata.RequestId = requestID
|
||||
}
|
||||
|
||||
// RequestIDSetter is implemented by IAM responses that can carry a RequestId.
|
||||
type RequestIDSetter interface {
|
||||
SetRequestId(string)
|
||||
}
|
||||
|
||||
// ListUsersResponse is the response for ListUsers action.
|
||||
type ListUsersResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUsersResponse"`
|
||||
ListUsersResult struct {
|
||||
Users []*iam.User `xml:"Users>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
} `xml:"ListUsersResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListAccessKeysResponse is the response for ListAccessKeys action.
|
||||
type ListAccessKeysResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAccessKeysResponse"`
|
||||
ListAccessKeysResult struct {
|
||||
AccessKeyMetadata []*iam.AccessKeyMetadata `xml:"AccessKeyMetadata>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
} `xml:"ListAccessKeysResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteAccessKeyResponse is the response for DeleteAccessKey action.
|
||||
type DeleteAccessKeyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteAccessKeyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// CreatePolicyResponse is the response for CreatePolicy action.
|
||||
type CreatePolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreatePolicyResponse"`
|
||||
CreatePolicyResult struct {
|
||||
Policy iam.Policy `xml:"Policy"`
|
||||
} `xml:"CreatePolicyResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeletePolicyResponse is the response for DeletePolicy action.
|
||||
type DeletePolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeletePolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListPoliciesResponse is the response for ListPolicies action.
|
||||
type ListPoliciesResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListPoliciesResponse"`
|
||||
ListPoliciesResult struct {
|
||||
Policies []*iam.Policy `xml:"Policies>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListPoliciesResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetPolicyResponse is the response for GetPolicy action.
|
||||
type GetPolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetPolicyResponse"`
|
||||
GetPolicyResult struct {
|
||||
Policy iam.Policy `xml:"Policy"`
|
||||
} `xml:"GetPolicyResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListPolicyVersionsResponse is the response for ListPolicyVersions action.
|
||||
type ListPolicyVersionsResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListPolicyVersionsResponse"`
|
||||
ListPolicyVersionsResult struct {
|
||||
Versions []*iam.PolicyVersion `xml:"Versions>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListPolicyVersionsResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetPolicyVersionResponse is the response for GetPolicyVersion action.
|
||||
type GetPolicyVersionResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetPolicyVersionResponse"`
|
||||
GetPolicyVersionResult struct {
|
||||
PolicyVersion iam.PolicyVersion `xml:"PolicyVersion"`
|
||||
} `xml:"GetPolicyVersionResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// CreateUserResponse is the response for CreateUser action.
|
||||
type CreateUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateUserResponse"`
|
||||
CreateUserResult struct {
|
||||
User iam.User `xml:"User"`
|
||||
} `xml:"CreateUserResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteUserResponse is the response for DeleteUser action.
|
||||
type DeleteUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetUserResponse is the response for GetUser action.
|
||||
type GetUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserResponse"`
|
||||
GetUserResult struct {
|
||||
User iam.User `xml:"User"`
|
||||
} `xml:"GetUserResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UpdateUserResponse is the response for UpdateUser action.
|
||||
type UpdateUserResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateUserResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// CreateAccessKeyResponse is the response for CreateAccessKey action.
|
||||
type CreateAccessKeyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateAccessKeyResponse"`
|
||||
CreateAccessKeyResult struct {
|
||||
AccessKey iam.AccessKey `xml:"AccessKey"`
|
||||
} `xml:"CreateAccessKeyResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// PutUserPolicyResponse is the response for PutUserPolicy action.
|
||||
type PutUserPolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutUserPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteUserPolicyResponse is the response for DeleteUserPolicy action.
|
||||
type DeleteUserPolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// AttachUserPolicyResponse is the response for AttachUserPolicy action.
|
||||
type AttachUserPolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AttachUserPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DetachUserPolicyResponse is the response for DetachUserPolicy action.
|
||||
type DetachUserPolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DetachUserPolicyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListAttachedUserPoliciesResponse is the response for ListAttachedUserPolicies action.
|
||||
type ListAttachedUserPoliciesResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAttachedUserPoliciesResponse"`
|
||||
ListAttachedUserPoliciesResult struct {
|
||||
AttachedPolicies []*iam.AttachedPolicy `xml:"AttachedPolicies>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
Marker string `xml:"Marker,omitempty"`
|
||||
} `xml:"ListAttachedUserPoliciesResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetUserPolicyResponse is the response for GetUserPolicy action.
|
||||
type GetUserPolicyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserPolicyResponse"`
|
||||
GetUserPolicyResult struct {
|
||||
UserName string `xml:"UserName"`
|
||||
PolicyName string `xml:"PolicyName"`
|
||||
PolicyDocument string `xml:"PolicyDocument"`
|
||||
} `xml:"GetUserPolicyResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ErrorResponse is the IAM error response format.
|
||||
// AWS IAM uses a bare <RequestId> at root level for errors, not <ResponseMetadata>.
|
||||
type ErrorResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ErrorResponse"`
|
||||
Error struct {
|
||||
iam.ErrorDetails
|
||||
Type string `xml:"Type"`
|
||||
} `xml:"Error"`
|
||||
RequestId string `xml:"RequestId"`
|
||||
}
|
||||
|
||||
// SetRequestId stores the request ID generated for the current HTTP request.
|
||||
func (r *ErrorResponse) SetRequestId(requestID string) {
|
||||
r.RequestId = requestID
|
||||
}
|
||||
|
||||
// Error represents an IAM API error with code and underlying error.
|
||||
@@ -210,14 +219,14 @@ type Policies struct {
|
||||
// SetUserStatusResponse is the response for SetUserStatus action.
|
||||
// This is a SeaweedFS extension to enable/disable users without deleting them.
|
||||
type SetUserStatusResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ SetUserStatusResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UpdateAccessKeyResponse is the response for UpdateAccessKey action.
|
||||
type UpdateAccessKeyResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateAccessKeyResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ServiceAccountInfo contains service account details for API responses.
|
||||
@@ -234,40 +243,40 @@ type ServiceAccountInfo struct {
|
||||
|
||||
// CreateServiceAccountResponse is the response for CreateServiceAccount action.
|
||||
type CreateServiceAccountResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateServiceAccountResponse"`
|
||||
CreateServiceAccountResult struct {
|
||||
ServiceAccount ServiceAccountInfo `xml:"ServiceAccount"`
|
||||
} `xml:"CreateServiceAccountResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteServiceAccountResponse is the response for DeleteServiceAccount action.
|
||||
type DeleteServiceAccountResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteServiceAccountResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// ListServiceAccountsResponse is the response for ListServiceAccounts action.
|
||||
type ListServiceAccountsResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListServiceAccountsResponse"`
|
||||
ListServiceAccountsResult struct {
|
||||
ServiceAccounts []*ServiceAccountInfo `xml:"ServiceAccounts>member"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
} `xml:"ListServiceAccountsResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetServiceAccountResponse is the response for GetServiceAccount action.
|
||||
type GetServiceAccountResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetServiceAccountResponse"`
|
||||
GetServiceAccountResult struct {
|
||||
ServiceAccount ServiceAccountInfo `xml:"ServiceAccount"`
|
||||
} `xml:"GetServiceAccountResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UpdateServiceAccountResponse is the response for UpdateServiceAccount action.
|
||||
type UpdateServiceAccountResponse struct {
|
||||
CommonResponse
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateServiceAccountResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListUsersResponseXMLOrdering(t *testing.T) {
|
||||
resp := ListUsersResponse{}
|
||||
resp.SetRequestId("test-req-id")
|
||||
|
||||
output, err := xml.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
xmlString := string(output)
|
||||
listUsersResultIndex := strings.Index(xmlString, "<ListUsersResult>")
|
||||
responseMetadataIndex := strings.Index(xmlString, "<ResponseMetadata>")
|
||||
|
||||
assert.NotEqual(t, -1, listUsersResultIndex, "ListUsersResult should be present")
|
||||
assert.NotEqual(t, -1, responseMetadataIndex, "ResponseMetadata should be present")
|
||||
assert.Less(t, listUsersResultIndex, responseMetadataIndex,
|
||||
"ListUsersResult should appear before ResponseMetadata")
|
||||
}
|
||||
|
||||
func TestErrorResponseXMLUsesTopLevelRequestId(t *testing.T) {
|
||||
errCode := "NoSuchEntity"
|
||||
errMsg := "the requested IAM entity does not exist"
|
||||
|
||||
resp := ErrorResponse{}
|
||||
resp.Error.Type = "Sender"
|
||||
resp.Error.Code = &errCode
|
||||
resp.Error.Message = &errMsg
|
||||
resp.SetRequestId("request-123")
|
||||
|
||||
output, err := xml.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
xmlString := string(output)
|
||||
errorIndex := strings.Index(xmlString, "<Error>")
|
||||
requestIDIndex := strings.Index(xmlString, "<RequestId>request-123</RequestId>")
|
||||
|
||||
assert.NotEqual(t, -1, errorIndex, "Error should be present")
|
||||
assert.NotEqual(t, -1, requestIDIndex, "RequestId should be present")
|
||||
assert.NotContains(t, xmlString, "<ResponseMetadata>",
|
||||
"ErrorResponse should use bare RequestId, not ResponseMetadata wrapper")
|
||||
assert.Less(t, errorIndex, requestIDIndex,
|
||||
"RequestId should appear after Error at the root level")
|
||||
}
|
||||
@@ -8,19 +8,20 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
func newErrorResponse(errCode string, errMsg string) ErrorResponse {
|
||||
func newErrorResponse(errCode string, errMsg string, requestID string) ErrorResponse {
|
||||
errorResp := ErrorResponse{}
|
||||
errorResp.Error.Type = "Sender"
|
||||
errorResp.Error.Code = &errCode
|
||||
errorResp.Error.Message = &errMsg
|
||||
errorResp.SetRequestId(requestID)
|
||||
return errorResp
|
||||
}
|
||||
|
||||
func writeIamErrorResponse(w http.ResponseWriter, r *http.Request, iamError *IamError) {
|
||||
|
||||
func writeIamErrorResponse(w http.ResponseWriter, r *http.Request, reqID string, iamError *IamError) {
|
||||
if iamError == nil {
|
||||
// Do nothing if there is no error
|
||||
glog.Errorf("No error found")
|
||||
glog.Errorf("writeIamErrorResponse called with nil error")
|
||||
internalResp := newErrorResponse(iam.ErrCodeServiceFailureException, "Internal server error", reqID)
|
||||
s3err.WriteXMLResponse(w, r, http.StatusInternalServerError, internalResp)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,8 +29,8 @@ func writeIamErrorResponse(w http.ResponseWriter, r *http.Request, iamError *Iam
|
||||
errMsg := iamError.Error.Error()
|
||||
glog.Errorf("Response %+v", errMsg)
|
||||
|
||||
errorResp := newErrorResponse(errCode, errMsg)
|
||||
internalErrorResponse := newErrorResponse(iam.ErrCodeServiceFailureException, "Internal server error")
|
||||
errorResp := newErrorResponse(errCode, errMsg, reqID)
|
||||
internalErrorResponse := newErrorResponse(iam.ErrCodeServiceFailureException, "Internal server error", reqID)
|
||||
|
||||
switch errCode {
|
||||
case iam.ErrCodeNoSuchEntityException:
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
|
||||
)
|
||||
|
||||
// Constants from shared package
|
||||
@@ -162,14 +163,16 @@ func validateAccessKeyStatus(status string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) ListUsers(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp ListUsersResponse) {
|
||||
func (iama *IamApiServer) ListUsers(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *ListUsersResponse) {
|
||||
resp = &ListUsersResponse{}
|
||||
for _, ident := range s3cfg.Identities {
|
||||
resp.ListUsersResult.Users = append(resp.ListUsersResult.Users, &iam.User{UserName: &ident.Name})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) ListAccessKeys(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp ListAccessKeysResponse) {
|
||||
func (iama *IamApiServer) ListAccessKeys(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *ListAccessKeysResponse) {
|
||||
resp = &ListAccessKeysResponse{}
|
||||
userName := values.Get("UserName")
|
||||
for _, ident := range s3cfg.Identities {
|
||||
if userName != "" && userName != ident.Name {
|
||||
@@ -190,16 +193,31 @@ func (iama *IamApiServer) ListAccessKeys(s3cfg *iam_pb.S3ApiConfiguration, value
|
||||
return resp
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) CreateUser(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp CreateUserResponse) {
|
||||
func (iama *IamApiServer) CreateUser(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *CreateUserResponse) {
|
||||
resp = &CreateUserResponse{}
|
||||
userName := values.Get("UserName")
|
||||
resp.CreateUserResult.User.UserName = &userName
|
||||
s3cfg.Identities = append(s3cfg.Identities, &iam_pb.Identity{Name: userName})
|
||||
return resp
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName string) (resp DeleteUserResponse, err *IamError) {
|
||||
func (iama *IamApiServer) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName string) (resp *DeleteUserResponse, err *IamError) {
|
||||
resp = &DeleteUserResponse{}
|
||||
for i, ident := range s3cfg.Identities {
|
||||
if userName == ident.Name {
|
||||
// Clean up any inline policies stored for this user
|
||||
policies := Policies{}
|
||||
if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) {
|
||||
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr}
|
||||
}
|
||||
if policies.InlinePolicies != nil {
|
||||
if _, exists := policies.InlinePolicies[userName]; exists {
|
||||
delete(policies.InlinePolicies, userName)
|
||||
if pErr := iama.s3ApiConfig.PutPolicies(&policies); pErr != nil {
|
||||
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr}
|
||||
}
|
||||
}
|
||||
}
|
||||
s3cfg.Identities = append(s3cfg.Identities[:i], s3cfg.Identities[i+1:]...)
|
||||
return resp, nil
|
||||
}
|
||||
@@ -207,7 +225,8 @@ func (iama *IamApiServer) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName
|
||||
return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)}
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) GetUser(s3cfg *iam_pb.S3ApiConfiguration, userName string) (resp GetUserResponse, err *IamError) {
|
||||
func (iama *IamApiServer) GetUser(s3cfg *iam_pb.S3ApiConfiguration, userName string) (resp *GetUserResponse, err *IamError) {
|
||||
resp = &GetUserResponse{}
|
||||
for _, ident := range s3cfg.Identities {
|
||||
if userName == ident.Name {
|
||||
resp.GetUserResult.User = iam.User{UserName: &ident.Name}
|
||||
@@ -217,13 +236,28 @@ func (iama *IamApiServer) GetUser(s3cfg *iam_pb.S3ApiConfiguration, userName str
|
||||
return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)}
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp UpdateUserResponse, err *IamError) {
|
||||
func (iama *IamApiServer) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *UpdateUserResponse, err *IamError) {
|
||||
resp = &UpdateUserResponse{}
|
||||
userName := values.Get("UserName")
|
||||
newUserName := values.Get("NewUserName")
|
||||
if newUserName != "" {
|
||||
for _, ident := range s3cfg.Identities {
|
||||
if userName == ident.Name {
|
||||
ident.Name = newUserName
|
||||
// Move any inline policies from old username to new username
|
||||
policies := Policies{}
|
||||
if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) {
|
||||
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr}
|
||||
}
|
||||
if policies.InlinePolicies != nil {
|
||||
if userPolicies, exists := policies.InlinePolicies[userName]; exists {
|
||||
delete(policies.InlinePolicies, userName)
|
||||
policies.InlinePolicies[newUserName] = userPolicies
|
||||
if pErr := iama.s3ApiConfig.PutPolicies(&policies); pErr != nil {
|
||||
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr}
|
||||
}
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
@@ -241,12 +275,13 @@ func GetPolicyDocument(policy *string) (policy_engine.PolicyDocument, error) {
|
||||
return policyDocument, nil
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) CreatePolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp CreatePolicyResponse, iamError *IamError) {
|
||||
func (iama *IamApiServer) CreatePolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *CreatePolicyResponse, iamError *IamError) {
|
||||
resp = &CreatePolicyResponse{}
|
||||
policyName := values.Get("PolicyName")
|
||||
policyDocumentString := values.Get("PolicyDocument")
|
||||
policyDocument, err := GetPolicyDocument(&policyDocumentString)
|
||||
if err != nil {
|
||||
return CreatePolicyResponse{}, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err}
|
||||
return resp, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err}
|
||||
}
|
||||
policyId := Hash(&policyName)
|
||||
arn := fmt.Sprintf("arn:aws:iam:::policy/%s", policyName)
|
||||
@@ -274,16 +309,17 @@ type IamError struct {
|
||||
}
|
||||
|
||||
// https://docs.aws.amazon.com/IAM/latest/APIReference/API_PutUserPolicy.html
|
||||
func (iama *IamApiServer) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp PutUserPolicyResponse, iamError *IamError) {
|
||||
func (iama *IamApiServer) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *PutUserPolicyResponse, iamError *IamError) {
|
||||
resp = &PutUserPolicyResponse{}
|
||||
userName := values.Get("UserName")
|
||||
policyName := values.Get("PolicyName")
|
||||
policyDocumentString := values.Get("PolicyDocument")
|
||||
policyDocument, err := GetPolicyDocument(&policyDocumentString)
|
||||
if err != nil {
|
||||
return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err}
|
||||
return resp, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err}
|
||||
}
|
||||
if _, err := GetActions(&policyDocument); err != nil {
|
||||
return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err}
|
||||
return resp, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err}
|
||||
}
|
||||
|
||||
// Verify the user exists before persisting the policy
|
||||
@@ -295,20 +331,20 @@ func (iama *IamApiServer) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
|
||||
}
|
||||
}
|
||||
if targetIdent == nil {
|
||||
return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("the user with name %s cannot be found", userName)}
|
||||
return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("the user with name %s cannot be found", userName)}
|
||||
}
|
||||
|
||||
// Persist inline policy to storage using per-user indexed structure
|
||||
policies := Policies{}
|
||||
if err = iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
}
|
||||
|
||||
userPolicies := policies.getOrCreateUserPolicies(userName)
|
||||
userPolicies[policyName] = policyDocument
|
||||
|
||||
if err = iama.s3ApiConfig.PutPolicies(&policies); err != nil {
|
||||
return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
}
|
||||
|
||||
// Recompute aggregated actions (inline + managed)
|
||||
@@ -321,7 +357,8 @@ func (iama *IamApiServer) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp GetUserPolicyResponse, err *IamError) {
|
||||
func (iama *IamApiServer) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *GetUserPolicyResponse, err *IamError) {
|
||||
resp = &GetUserPolicyResponse{}
|
||||
userName := values.Get("UserName")
|
||||
policyName := values.Get("PolicyName")
|
||||
for _, ident := range s3cfg.Identities {
|
||||
@@ -404,7 +441,8 @@ func (iama *IamApiServer) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
|
||||
}
|
||||
|
||||
// DeleteUserPolicy removes the inline policy from a user (clears their actions).
|
||||
func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp DeleteUserPolicyResponse, err *IamError) {
|
||||
func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *DeleteUserPolicyResponse, err *IamError) {
|
||||
resp = &DeleteUserPolicyResponse{}
|
||||
userName := values.Get("UserName")
|
||||
policyName := values.Get("PolicyName")
|
||||
|
||||
@@ -447,7 +485,8 @@ func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, val
|
||||
}
|
||||
|
||||
// GetPolicy retrieves a managed policy by ARN.
|
||||
func (iama *IamApiServer) GetPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp GetPolicyResponse, iamError *IamError) {
|
||||
func (iama *IamApiServer) GetPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *GetPolicyResponse, iamError *IamError) {
|
||||
resp = &GetPolicyResponse{}
|
||||
policyArn := values.Get("PolicyArn")
|
||||
policyName, iamError := parsePolicyArn(policyArn)
|
||||
if iamError != nil {
|
||||
@@ -472,7 +511,8 @@ func (iama *IamApiServer) GetPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url
|
||||
|
||||
// DeletePolicy removes a managed policy. Rejects deletion if the policy is still attached to any user
|
||||
// (matching AWS IAM behavior: must detach before deleting).
|
||||
func (iama *IamApiServer) DeletePolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp DeletePolicyResponse, iamError *IamError) {
|
||||
func (iama *IamApiServer) DeletePolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *DeletePolicyResponse, iamError *IamError) {
|
||||
resp = &DeletePolicyResponse{}
|
||||
policyArn := values.Get("PolicyArn")
|
||||
policyName, iamError := parsePolicyArn(policyArn)
|
||||
if iamError != nil {
|
||||
@@ -509,7 +549,8 @@ func (iama *IamApiServer) DeletePolicy(s3cfg *iam_pb.S3ApiConfiguration, values
|
||||
}
|
||||
|
||||
// ListPolicies lists all managed policies.
|
||||
func (iama *IamApiServer) ListPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp ListPoliciesResponse, iamError *IamError) {
|
||||
func (iama *IamApiServer) ListPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *ListPoliciesResponse, iamError *IamError) {
|
||||
resp = &ListPoliciesResponse{}
|
||||
policies := Policies{}
|
||||
if err := iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
@@ -529,7 +570,8 @@ func (iama *IamApiServer) ListPolicies(s3cfg *iam_pb.S3ApiConfiguration, values
|
||||
}
|
||||
|
||||
// AttachUserPolicy attaches a managed policy to a user.
|
||||
func (iama *IamApiServer) AttachUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp AttachUserPolicyResponse, iamError *IamError) {
|
||||
func (iama *IamApiServer) AttachUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *AttachUserPolicyResponse, iamError *IamError) {
|
||||
resp = &AttachUserPolicyResponse{}
|
||||
userName := values.Get("UserName")
|
||||
policyArn := values.Get("PolicyArn")
|
||||
policyName, iamError := parsePolicyArn(policyArn)
|
||||
@@ -574,7 +616,8 @@ func (iama *IamApiServer) AttachUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, val
|
||||
}
|
||||
|
||||
// DetachUserPolicy detaches a managed policy from a user.
|
||||
func (iama *IamApiServer) DetachUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp DetachUserPolicyResponse, iamError *IamError) {
|
||||
func (iama *IamApiServer) DetachUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *DetachUserPolicyResponse, iamError *IamError) {
|
||||
resp = &DetachUserPolicyResponse{}
|
||||
userName := values.Get("UserName")
|
||||
policyArn := values.Get("PolicyArn")
|
||||
policyName, iamError := parsePolicyArn(policyArn)
|
||||
@@ -622,7 +665,8 @@ func (iama *IamApiServer) DetachUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, val
|
||||
}
|
||||
|
||||
// ListAttachedUserPolicies lists the managed policies attached to a user.
|
||||
func (iama *IamApiServer) ListAttachedUserPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp ListAttachedUserPoliciesResponse, iamError *IamError) {
|
||||
func (iama *IamApiServer) ListAttachedUserPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *ListAttachedUserPoliciesResponse, iamError *IamError) {
|
||||
resp = &ListAttachedUserPoliciesResponse{}
|
||||
userName := values.Get("UserName")
|
||||
for _, ident := range s3cfg.Identities {
|
||||
if ident.Name != userName {
|
||||
@@ -714,7 +758,8 @@ func GetActions(policy *policy_engine.PolicyDocument) ([]string, error) {
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp CreateAccessKeyResponse, iamErr *IamError) {
|
||||
func (iama *IamApiServer) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *CreateAccessKeyResponse, iamErr *IamError) {
|
||||
resp = &CreateAccessKeyResponse{}
|
||||
userName := values.Get("UserName")
|
||||
status := iam.StatusTypeActive
|
||||
|
||||
@@ -758,7 +803,8 @@ func (iama *IamApiServer) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, valu
|
||||
}
|
||||
|
||||
// UpdateAccessKey updates the status of an access key (Active or Inactive).
|
||||
func (iama *IamApiServer) UpdateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp UpdateAccessKeyResponse, err *IamError) {
|
||||
func (iama *IamApiServer) UpdateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *UpdateAccessKeyResponse, err *IamError) {
|
||||
resp = &UpdateAccessKeyResponse{}
|
||||
userName := values.Get("UserName")
|
||||
accessKeyId := values.Get("AccessKeyId")
|
||||
status := values.Get("Status")
|
||||
@@ -788,7 +834,8 @@ func (iama *IamApiServer) UpdateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, valu
|
||||
return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)}
|
||||
}
|
||||
|
||||
func (iama *IamApiServer) DeleteAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp DeleteAccessKeyResponse) {
|
||||
func (iama *IamApiServer) DeleteAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *DeleteAccessKeyResponse) {
|
||||
resp = &DeleteAccessKeyResponse{}
|
||||
userName := values.Get("UserName")
|
||||
accessKeyId := values.Get("AccessKeyId")
|
||||
for _, ident := range s3cfg.Identities {
|
||||
@@ -859,6 +906,8 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
policyLock.Lock()
|
||||
defer policyLock.Unlock()
|
||||
|
||||
r, reqID := request_id.Ensure(r)
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
|
||||
return
|
||||
@@ -871,8 +920,7 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
glog.V(4).Infof("DoActions: %+v", values)
|
||||
var response interface{}
|
||||
var iamError *IamError
|
||||
var response iamlib.RequestIDSetter
|
||||
changed := true
|
||||
switch r.Form.Get("Action") {
|
||||
case "ListUsers":
|
||||
@@ -886,32 +934,35 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
response = iama.CreateUser(s3cfg, values)
|
||||
case "GetUser":
|
||||
userName := values.Get("UserName")
|
||||
response, iamError = iama.GetUser(s3cfg, userName)
|
||||
if iamError != nil {
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.GetUser(s3cfg, userName)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
changed = false
|
||||
case "UpdateUser":
|
||||
response, iamError = iama.UpdateUser(s3cfg, values)
|
||||
if iamError != nil {
|
||||
glog.Errorf("UpdateUser: %+v", iamError.Error)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
|
||||
var err *IamError
|
||||
response, err = iama.UpdateUser(s3cfg, values)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
case "DeleteUser":
|
||||
userName := values.Get("UserName")
|
||||
response, iamError = iama.DeleteUser(s3cfg, userName)
|
||||
if iamError != nil {
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.DeleteUser(s3cfg, userName)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
case "CreateAccessKey":
|
||||
iama.handleImplicitUsername(r, values)
|
||||
response, iamError = iama.CreateAccessKey(s3cfg, values)
|
||||
if iamError != nil {
|
||||
glog.Errorf("CreateAccessKey: %+v", iamError.Error)
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.CreateAccessKey(s3cfg, values)
|
||||
if err != nil {
|
||||
glog.Errorf("CreateAccessKey: %+v", err.Error)
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
case "DeleteAccessKey":
|
||||
@@ -919,87 +970,94 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
response = iama.DeleteAccessKey(s3cfg, values)
|
||||
case "UpdateAccessKey":
|
||||
iama.handleImplicitUsername(r, values)
|
||||
response, iamError = iama.UpdateAccessKey(s3cfg, values)
|
||||
if iamError != nil {
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.UpdateAccessKey(s3cfg, values)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
case "CreatePolicy":
|
||||
response, iamError = iama.CreatePolicy(s3cfg, values)
|
||||
if iamError != nil {
|
||||
glog.Errorf("CreatePolicy: %+v", iamError.Error)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
|
||||
var err *IamError
|
||||
response, err = iama.CreatePolicy(s3cfg, values)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
// CreatePolicy persists the policy document via iama.s3ApiConfig.PutPolicies().
|
||||
// The `changed` flag is false because this does not modify the main s3cfg.Identities configuration.
|
||||
changed = false
|
||||
case "PutUserPolicy":
|
||||
var iamError *IamError
|
||||
response, iamError = iama.PutUserPolicy(s3cfg, values)
|
||||
if iamError != nil {
|
||||
glog.Errorf("PutUserPolicy: %+v", iamError.Error)
|
||||
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.PutUserPolicy(s3cfg, values)
|
||||
if err != nil {
|
||||
glog.Errorf("PutUserPolicy: %+v", err.Error)
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
case "GetUserPolicy":
|
||||
response, iamError = iama.GetUserPolicy(s3cfg, values)
|
||||
if iamError != nil {
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.GetUserPolicy(s3cfg, values)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
changed = false
|
||||
case "DeleteUserPolicy":
|
||||
if response, iamError = iama.DeleteUserPolicy(s3cfg, values); iamError != nil {
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.DeleteUserPolicy(s3cfg, values)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
case "GetPolicy":
|
||||
response, iamError = iama.GetPolicy(s3cfg, values)
|
||||
if iamError != nil {
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.GetPolicy(s3cfg, values)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
changed = false
|
||||
case "DeletePolicy":
|
||||
response, iamError = iama.DeletePolicy(s3cfg, values)
|
||||
if iamError != nil {
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.DeletePolicy(s3cfg, values)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
changed = false
|
||||
case "ListPolicies":
|
||||
response, iamError = iama.ListPolicies(s3cfg, values)
|
||||
if iamError != nil {
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.ListPolicies(s3cfg, values)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
changed = false
|
||||
case "AttachUserPolicy":
|
||||
response, iamError = iama.AttachUserPolicy(s3cfg, values)
|
||||
if iamError != nil {
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.AttachUserPolicy(s3cfg, values)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
case "DetachUserPolicy":
|
||||
response, iamError = iama.DetachUserPolicy(s3cfg, values)
|
||||
if iamError != nil {
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.DetachUserPolicy(s3cfg, values)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
case "ListAttachedUserPolicies":
|
||||
response, iamError = iama.ListAttachedUserPolicies(s3cfg, values)
|
||||
if iamError != nil {
|
||||
writeIamErrorResponse(w, r, iamError)
|
||||
var err *IamError
|
||||
response, err = iama.ListAttachedUserPolicies(s3cfg, values)
|
||||
if err != nil {
|
||||
writeIamErrorResponse(w, r, reqID, err)
|
||||
return
|
||||
}
|
||||
changed = false
|
||||
default:
|
||||
errNotImplemented := s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
errorResponse := ErrorResponse{}
|
||||
errorResponse.Error.Code = &errNotImplemented.Code
|
||||
errorResponse.Error.Message = &errNotImplemented.Description
|
||||
errorResponse := newErrorResponse(errNotImplemented.Code, errNotImplemented.Description, reqID)
|
||||
s3err.WriteXMLResponse(w, r, errNotImplemented.HTTPStatusCode, errorResponse)
|
||||
return
|
||||
}
|
||||
@@ -1007,7 +1065,7 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
err := iama.s3ApiConfig.PutS3ApiConfiguration(s3cfg)
|
||||
if err != nil {
|
||||
var iamError = IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
|
||||
writeIamErrorResponse(w, r, &iamError)
|
||||
writeIamErrorResponse(w, r, reqID, &iamError)
|
||||
return
|
||||
}
|
||||
// Reload in-memory identity maps so subsequent LookupByAccessKey calls
|
||||
@@ -1019,5 +1077,6 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
response.SetRequestId(reqID)
|
||||
s3err.WriteXMLResponse(w, r, http.StatusOK, response)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
. "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
|
||||
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
@@ -117,6 +118,7 @@ func NewIamApiServerWithStore(router *mux.Router, option *IamServerOption, expli
|
||||
func (iama *IamApiServer) registerRouter(router *mux.Router) {
|
||||
// API Router
|
||||
apiRouter := router.PathPrefix("/").Subrouter()
|
||||
apiRouter.Use(request_id.Middleware)
|
||||
// ListBuckets
|
||||
|
||||
// apiRouter.Methods("GET").Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.ListBucketsHandler, ACTION_ADMIN), "LIST"))
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -73,6 +74,21 @@ func TestListUsers(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, response.Code)
|
||||
}
|
||||
|
||||
func TestListUsersRequestIdMatchesResponseHeader(t *testing.T) {
|
||||
params := &iam.ListUsersInput{}
|
||||
req, _ := iam.New(session.New()).ListUsersRequest(params)
|
||||
_ = req.Build()
|
||||
|
||||
out := ListUsersResponse{}
|
||||
response, err := executeRequest(req.HTTPRequest, out)
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, http.StatusOK, response.Code)
|
||||
|
||||
headerRequestID := response.Header().Get(request_id.AmzRequestIDHeader)
|
||||
assert.NotEmpty(t, headerRequestID)
|
||||
assert.Equal(t, headerRequestID, extractRequestID(response))
|
||||
}
|
||||
|
||||
func TestListAccessKeys(t *testing.T) {
|
||||
svc := iam.New(session.New())
|
||||
params := &iam.ListAccessKeysInput{}
|
||||
@@ -244,6 +260,9 @@ func TestPutUserPolicyError(t *testing.T) {
|
||||
|
||||
assert.Equal(t, expectedMessage, message)
|
||||
assert.Equal(t, expectedCode, code)
|
||||
assert.Contains(t, response.Body.String(), "<RequestId>")
|
||||
assert.NotContains(t, response.Body.String(), "<ResponseMetadata>")
|
||||
assert.Equal(t, response.Header().Get(request_id.AmzRequestIDHeader), extractRequestID(response))
|
||||
}
|
||||
|
||||
func extractErrorCodeAndMessage(response *httptest.ResponseRecorder) (string, string) {
|
||||
@@ -255,6 +274,15 @@ func extractErrorCodeAndMessage(response *httptest.ResponseRecorder) (string, st
|
||||
return code, message
|
||||
}
|
||||
|
||||
func extractRequestID(response *httptest.ResponseRecorder) string {
|
||||
re := regexp.MustCompile(`<RequestId>([^<]+)</RequestId>`)
|
||||
matches := re.FindStringSubmatch(response.Body.String())
|
||||
if len(matches) < 2 {
|
||||
return ""
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
func TestGetUserPolicy(t *testing.T) {
|
||||
userName := aws.String("Test")
|
||||
params := &iam.GetUserPolicyInput{UserName: userName, PolicyName: aws.String("S3-read-only-example-bucket")}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
func (fh *FileHandle) lockForRead(startOffset int64, size int) {
|
||||
@@ -163,11 +162,12 @@ func (fh *FileHandle) downloadRemoteEntry(entry *LockedEntry) error {
|
||||
|
||||
fh.SetEntry(resp.Entry)
|
||||
|
||||
// Only update cache if the parent directory is cached
|
||||
if fh.wfs.metaCache.IsDirectoryCached(util.FullPath(dir)) {
|
||||
if err := fh.wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry(request.Directory, resp.Entry)); err != nil {
|
||||
return fmt.Errorf("update meta cache for %s: %w", fileFullPath, err)
|
||||
}
|
||||
event := resp.GetMetadataEvent()
|
||||
if event == nil {
|
||||
event = metadataUpdateEvent(request.Directory, resp.Entry)
|
||||
}
|
||||
if applyErr := fh.wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
|
||||
glog.Warningf("CacheRemoteObject %s: best-effort metadata apply failed: %v", fileFullPath, applyErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
@@ -20,7 +21,7 @@ func (wfs *WFS) subscribeFilerConfEvents() (*meta_cache.MetadataFollower, error)
|
||||
|
||||
// read current conf
|
||||
err := wfs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
content, err := filer.ReadInsideFiler(client, confDir, confName)
|
||||
content, err := filer.ReadInsideFiler(context.Background(), client, confDir, confName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -21,18 +21,18 @@ type InodeEntry struct {
|
||||
nlookup uint64
|
||||
isDirectory bool
|
||||
isChildrenCached bool
|
||||
readDirDirect bool
|
||||
cachedExpiresTime time.Time
|
||||
lastAccess time.Time
|
||||
lastRefresh time.Time
|
||||
updateWindowStart time.Time
|
||||
updateCount int
|
||||
needsRefresh bool
|
||||
}
|
||||
|
||||
func (ie *InodeEntry) resetCacheState() {
|
||||
ie.isChildrenCached = false
|
||||
ie.readDirDirect = false
|
||||
ie.cachedExpiresTime = time.Time{}
|
||||
ie.needsRefresh = false
|
||||
ie.updateCount = 0
|
||||
ie.updateWindowStart = time.Time{}
|
||||
}
|
||||
@@ -188,11 +188,11 @@ func (i *InodeToPath) MarkChildrenCached(fullpath util.FullPath) {
|
||||
return
|
||||
}
|
||||
path.isChildrenCached = true
|
||||
path.readDirDirect = false
|
||||
now := time.Now()
|
||||
path.lastAccess = now
|
||||
path.lastRefresh = now
|
||||
path.updateCount = 0
|
||||
path.needsRefresh = false
|
||||
path.updateWindowStart = time.Time{}
|
||||
if i.cacheMetaTtlSec > 0 {
|
||||
path.cachedExpiresTime = now.Add(i.cacheMetaTtlSec)
|
||||
@@ -264,6 +264,27 @@ func (i *InodeToPath) TouchDirectory(fullpath util.FullPath) {
|
||||
entry.lastAccess = time.Now()
|
||||
}
|
||||
|
||||
func (i *InodeToPath) MarkDirectoryReadThrough(fullpath util.FullPath, now time.Time) bool {
|
||||
i.Lock()
|
||||
defer i.Unlock()
|
||||
inode, found := i.path2inode[fullpath]
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
entry, found := i.inode2path[inode]
|
||||
if !found || !entry.isDirectory {
|
||||
return false
|
||||
}
|
||||
entry.isChildrenCached = false
|
||||
entry.readDirDirect = true
|
||||
entry.cachedExpiresTime = time.Time{}
|
||||
entry.lastAccess = now
|
||||
entry.lastRefresh = time.Time{}
|
||||
entry.updateCount = 0
|
||||
entry.updateWindowStart = time.Time{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (i *InodeToPath) RecordDirectoryUpdate(fullpath util.FullPath, now time.Time, window time.Duration, threshold int) bool {
|
||||
if threshold <= 0 || window <= 0 {
|
||||
return false
|
||||
@@ -284,13 +305,19 @@ func (i *InodeToPath) RecordDirectoryUpdate(fullpath util.FullPath, now time.Tim
|
||||
}
|
||||
entry.updateCount++
|
||||
if entry.updateCount >= threshold {
|
||||
entry.needsRefresh = true
|
||||
entry.isChildrenCached = false
|
||||
entry.readDirDirect = true
|
||||
entry.cachedExpiresTime = time.Time{}
|
||||
entry.lastAccess = now
|
||||
entry.lastRefresh = time.Time{}
|
||||
entry.updateCount = 0
|
||||
entry.updateWindowStart = time.Time{}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (i *InodeToPath) NeedsRefresh(fullpath util.FullPath) bool {
|
||||
func (i *InodeToPath) ShouldReadDirectoryDirect(fullpath util.FullPath) bool {
|
||||
i.RLock()
|
||||
defer i.RUnlock()
|
||||
inode, found := i.path2inode[fullpath]
|
||||
@@ -301,7 +328,7 @@ func (i *InodeToPath) NeedsRefresh(fullpath util.FullPath) bool {
|
||||
if !found || !entry.isDirectory {
|
||||
return false
|
||||
}
|
||||
return entry.isChildrenCached && entry.needsRefresh
|
||||
return entry.readDirDirect
|
||||
}
|
||||
|
||||
func (i *InodeToPath) MarkDirectoryRefreshed(fullpath util.FullPath, now time.Time) {
|
||||
@@ -317,8 +344,8 @@ func (i *InodeToPath) MarkDirectoryRefreshed(fullpath util.FullPath, now time.Ti
|
||||
}
|
||||
entry.lastRefresh = now
|
||||
entry.lastAccess = now
|
||||
entry.readDirDirect = false
|
||||
entry.updateCount = 0
|
||||
entry.needsRefresh = false
|
||||
entry.updateWindowStart = time.Time{}
|
||||
if i.cacheMetaTtlSec > 0 {
|
||||
entry.cachedExpiresTime = now.Add(i.cacheMetaTtlSec)
|
||||
|
||||
@@ -2,6 +2,7 @@ package mount
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
@@ -92,3 +93,43 @@ func TestInodeEntry_removeOnePath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDirectoryUpdateSwitchesDirectoryToReadThrough(t *testing.T) {
|
||||
root := util.FullPath("/")
|
||||
dir := util.FullPath("/data")
|
||||
|
||||
inodeToPath := NewInodeToPath(root, 60)
|
||||
inodeToPath.Lookup(dir, time.Now().Unix(), true, false, 0, true)
|
||||
inodeToPath.MarkChildrenCached(dir)
|
||||
|
||||
now := time.Now()
|
||||
if !inodeToPath.RecordDirectoryUpdate(dir, now, time.Second, 1) {
|
||||
t.Fatal("expected directory to switch to read-through mode")
|
||||
}
|
||||
if inodeToPath.IsChildrenCached(dir) {
|
||||
t.Fatal("directory should no longer be marked cached")
|
||||
}
|
||||
if !inodeToPath.ShouldReadDirectoryDirect(dir) {
|
||||
t.Fatal("directory should be served via direct reads after hot invalidation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkChildrenCachedClearsReadThroughMode(t *testing.T) {
|
||||
root := util.FullPath("/")
|
||||
dir := util.FullPath("/data")
|
||||
|
||||
inodeToPath := NewInodeToPath(root, 60)
|
||||
inodeToPath.Lookup(dir, time.Now().Unix(), true, false, 0, true)
|
||||
|
||||
if !inodeToPath.MarkDirectoryReadThrough(dir, time.Now()) {
|
||||
t.Fatal("expected read-through flag to be set")
|
||||
}
|
||||
inodeToPath.MarkChildrenCached(dir)
|
||||
|
||||
if !inodeToPath.IsChildrenCached(dir) {
|
||||
t.Fatal("directory should be cached after MarkChildrenCached")
|
||||
}
|
||||
if inodeToPath.ShouldReadDirectoryDirect(dir) {
|
||||
t.Fatal("directory should leave read-through mode after caching")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,21 @@ package meta_cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer/leveldb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// need to have logic similar to FilerStoreWrapper
|
||||
@@ -29,12 +33,61 @@ type MetaCache struct {
|
||||
invalidateFunc func(fullpath util.FullPath, entry *filer_pb.Entry)
|
||||
onDirectoryUpdate func(dir util.FullPath)
|
||||
visitGroup singleflight.Group // deduplicates concurrent EnsureVisited calls for the same path
|
||||
applyCh chan metadataApplyRequest
|
||||
applyDone chan struct{}
|
||||
applyStateMu sync.Mutex
|
||||
applyClosed bool
|
||||
buildingDirs map[util.FullPath]*directoryBuildState
|
||||
dedupRing dedupRingBuffer
|
||||
}
|
||||
|
||||
var errMetaCacheClosed = errors.New("metadata cache is shut down")
|
||||
|
||||
type MetadataResponseApplyOptions struct {
|
||||
NotifyDirectories bool
|
||||
InvalidateEntries bool
|
||||
}
|
||||
|
||||
var (
|
||||
LocalMetadataResponseApplyOptions = MetadataResponseApplyOptions{
|
||||
NotifyDirectories: true,
|
||||
}
|
||||
SubscriberMetadataResponseApplyOptions = MetadataResponseApplyOptions{
|
||||
NotifyDirectories: true,
|
||||
InvalidateEntries: true,
|
||||
}
|
||||
)
|
||||
|
||||
type directoryBuildState struct {
|
||||
bufferedEvents []*filer_pb.SubscribeMetadataResponse
|
||||
}
|
||||
|
||||
const recentEventDedupWindow = 4096
|
||||
|
||||
type metadataApplyRequestKind int
|
||||
|
||||
const (
|
||||
metadataApplyEvent metadataApplyRequestKind = iota
|
||||
metadataBeginBuild
|
||||
metadataCompleteBuild
|
||||
metadataAbortBuild
|
||||
metadataShutdown
|
||||
)
|
||||
|
||||
type metadataApplyRequest struct {
|
||||
ctx context.Context
|
||||
kind metadataApplyRequestKind
|
||||
resp *filer_pb.SubscribeMetadataResponse
|
||||
options MetadataResponseApplyOptions
|
||||
buildPath util.FullPath
|
||||
snapshotTsNs int64
|
||||
done chan error
|
||||
}
|
||||
|
||||
func NewMetaCache(dbFolder string, uidGidMapper *UidGidMapper, root util.FullPath,
|
||||
markCachedFn func(path util.FullPath), isCachedFn func(path util.FullPath) bool, invalidateFunc func(util.FullPath, *filer_pb.Entry), onDirectoryUpdate func(dir util.FullPath)) *MetaCache {
|
||||
leveldbStore, virtualStore := openMetaStore(dbFolder)
|
||||
return &MetaCache{
|
||||
mc := &MetaCache{
|
||||
root: root,
|
||||
localStore: virtualStore,
|
||||
leveldbStore: leveldbStore,
|
||||
@@ -45,7 +98,13 @@ func NewMetaCache(dbFolder string, uidGidMapper *UidGidMapper, root util.FullPat
|
||||
invalidateFunc: func(fullpath util.FullPath, entry *filer_pb.Entry) {
|
||||
invalidateFunc(fullpath, entry)
|
||||
},
|
||||
applyCh: make(chan metadataApplyRequest, 128),
|
||||
applyDone: make(chan struct{}),
|
||||
buildingDirs: make(map[util.FullPath]*directoryBuildState),
|
||||
dedupRing: newDedupRingBuffer(),
|
||||
}
|
||||
go mc.runApplyLoop()
|
||||
return mc
|
||||
}
|
||||
|
||||
func openMetaStore(dbFolder string) (*leveldb.LevelDBStore, filer.VirtualFilerStore) {
|
||||
@@ -85,7 +144,10 @@ func (mc *MetaCache) doBatchInsertEntries(ctx context.Context, entries []*filer.
|
||||
func (mc *MetaCache) AtomicUpdateEntryFromFiler(ctx context.Context, oldPath util.FullPath, newEntry *filer.Entry) error {
|
||||
mc.Lock()
|
||||
defer mc.Unlock()
|
||||
return mc.atomicUpdateEntryFromFilerLocked(ctx, oldPath, newEntry, false)
|
||||
}
|
||||
|
||||
func (mc *MetaCache) atomicUpdateEntryFromFilerLocked(ctx context.Context, oldPath util.FullPath, newEntry *filer.Entry, allowUncachedInsert bool) error {
|
||||
entry, err := mc.localStore.FindEntry(ctx, oldPath)
|
||||
if err != nil && err != filer_pb.ErrNotFound {
|
||||
glog.Errorf("Metacache: find entry error: %v", err)
|
||||
@@ -110,7 +172,7 @@ func (mc *MetaCache) AtomicUpdateEntryFromFiler(ctx context.Context, oldPath uti
|
||||
|
||||
if newEntry != nil {
|
||||
newDir, _ := newEntry.DirAndName()
|
||||
if mc.isCachedFn(util.FullPath(newDir)) {
|
||||
if allowUncachedInsert || mc.isCachedFn(util.FullPath(newDir)) {
|
||||
glog.V(3).Infof("InsertEntry %s/%s", newDir, newEntry.Name())
|
||||
if err := mc.localStore.InsertEntry(ctx, newEntry); err != nil {
|
||||
return err
|
||||
@@ -120,6 +182,71 @@ func (mc *MetaCache) AtomicUpdateEntryFromFiler(ctx context.Context, oldPath uti
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mc *MetaCache) ApplyMetadataResponse(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) error {
|
||||
if resp == nil || resp.EventNotification == nil {
|
||||
return nil
|
||||
}
|
||||
clonedResp := proto.Clone(resp).(*filer_pb.SubscribeMetadataResponse)
|
||||
return mc.applyMetadataResponseEnqueue(ctx, clonedResp, options)
|
||||
}
|
||||
|
||||
// ApplyMetadataResponseOwned is like ApplyMetadataResponse but takes ownership
|
||||
// of resp without cloning. The caller must not use resp after this call.
|
||||
func (mc *MetaCache) ApplyMetadataResponseOwned(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) error {
|
||||
if resp == nil || resp.EventNotification == nil {
|
||||
return nil
|
||||
}
|
||||
return mc.applyMetadataResponseEnqueue(ctx, resp, options)
|
||||
}
|
||||
|
||||
func (mc *MetaCache) applyMetadataResponseEnqueue(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
req := metadataApplyRequest{
|
||||
// Use a non-cancellable context for the queued mutation so a
|
||||
// cancelled caller doesn't abort the apply loop mid-write.
|
||||
ctx: context.Background(),
|
||||
kind: metadataApplyEvent,
|
||||
resp: resp,
|
||||
options: options,
|
||||
done: make(chan error, 1),
|
||||
}
|
||||
|
||||
if err := mc.enqueueApplyRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-req.done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetaCache) BeginDirectoryBuild(ctx context.Context, dirPath util.FullPath) error {
|
||||
return mc.enqueueAndWait(ctx, metadataApplyRequest{
|
||||
kind: metadataBeginBuild,
|
||||
buildPath: dirPath,
|
||||
})
|
||||
}
|
||||
|
||||
func (mc *MetaCache) CompleteDirectoryBuild(ctx context.Context, dirPath util.FullPath, snapshotTsNs int64) error {
|
||||
return mc.enqueueAndWait(ctx, metadataApplyRequest{
|
||||
kind: metadataCompleteBuild,
|
||||
buildPath: dirPath,
|
||||
snapshotTsNs: snapshotTsNs,
|
||||
})
|
||||
}
|
||||
|
||||
func (mc *MetaCache) AbortDirectoryBuild(ctx context.Context, dirPath util.FullPath) error {
|
||||
return mc.enqueueAndWait(ctx, metadataApplyRequest{
|
||||
kind: metadataAbortBuild,
|
||||
buildPath: dirPath,
|
||||
})
|
||||
}
|
||||
|
||||
func (mc *MetaCache) UpdateEntry(ctx context.Context, entry *filer.Entry) error {
|
||||
mc.Lock()
|
||||
defer mc.Unlock()
|
||||
@@ -174,6 +301,25 @@ func (mc *MetaCache) ListDirectoryEntries(ctx context.Context, dirPath util.Full
|
||||
}
|
||||
|
||||
func (mc *MetaCache) Shutdown() {
|
||||
done := make(chan error, 1)
|
||||
|
||||
mc.applyStateMu.Lock()
|
||||
if !mc.applyClosed {
|
||||
mc.applyClosed = true
|
||||
mc.applyCh <- metadataApplyRequest{
|
||||
kind: metadataShutdown,
|
||||
done: done,
|
||||
}
|
||||
}
|
||||
mc.applyStateMu.Unlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-mc.applyDone:
|
||||
}
|
||||
|
||||
<-mc.applyDone
|
||||
|
||||
mc.Lock()
|
||||
defer mc.Unlock()
|
||||
mc.localStore.Shutdown()
|
||||
@@ -201,3 +347,494 @@ func (mc *MetaCache) noteDirectoryUpdate(dirPath util.FullPath) {
|
||||
mc.onDirectoryUpdate(dirPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetaCache) enqueueAndWait(ctx context.Context, req metadataApplyRequest) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
// Use a non-cancellable context for the queued operation so a
|
||||
// cancelled caller doesn't abort a build/complete mid-way.
|
||||
req.ctx = context.Background()
|
||||
req.done = make(chan error, 1)
|
||||
if err := mc.enqueueApplyRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case err := <-req.done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetaCache) enqueueApplyRequest(req metadataApplyRequest) error {
|
||||
mc.applyStateMu.Lock()
|
||||
if mc.applyClosed {
|
||||
mc.applyStateMu.Unlock()
|
||||
return errMetaCacheClosed
|
||||
}
|
||||
// Release the mutex before the potentially-blocking channel send so that
|
||||
// Shutdown can still acquire it to set applyClosed when the channel is full.
|
||||
mc.applyStateMu.Unlock()
|
||||
select {
|
||||
case mc.applyCh <- req:
|
||||
return nil
|
||||
case <-mc.applyDone:
|
||||
return errMetaCacheClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetaCache) runApplyLoop() {
|
||||
defer close(mc.applyDone)
|
||||
|
||||
for req := range mc.applyCh {
|
||||
req.done <- mc.handleApplyRequest(req)
|
||||
close(req.done)
|
||||
if req.kind == metadataShutdown {
|
||||
mc.drainApplyCh()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainApplyCh non-blockingly drains any remaining requests from applyCh
|
||||
// after a shutdown sentinel, signalling each caller so they don't block.
|
||||
func (mc *MetaCache) drainApplyCh() {
|
||||
for {
|
||||
select {
|
||||
case req := <-mc.applyCh:
|
||||
req.done <- errMetaCacheClosed
|
||||
close(req.done)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetaCache) handleApplyRequest(req metadataApplyRequest) error {
|
||||
switch req.kind {
|
||||
case metadataApplyEvent:
|
||||
return mc.applyMetadataResponseNow(req.ctx, req.resp, req.options)
|
||||
case metadataBeginBuild:
|
||||
return mc.beginDirectoryBuildNow(req.buildPath)
|
||||
case metadataCompleteBuild:
|
||||
return mc.completeDirectoryBuildNow(req.ctx, req.buildPath, req.snapshotTsNs)
|
||||
case metadataAbortBuild:
|
||||
return mc.abortDirectoryBuildNow(req.buildPath)
|
||||
case metadataShutdown:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type metadataInvalidation struct {
|
||||
path util.FullPath
|
||||
entry *filer_pb.Entry
|
||||
}
|
||||
|
||||
type metadataResponseSideEffects struct {
|
||||
dirsToNotify []util.FullPath
|
||||
invalidations []metadataInvalidation
|
||||
}
|
||||
|
||||
func (mc *MetaCache) applyMetadataResponseNow(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) error {
|
||||
if mc.shouldSkipDuplicateEvent(resp) {
|
||||
return nil
|
||||
}
|
||||
|
||||
immediateEvents, bufferedEvents := mc.routeMetadataResponse(resp)
|
||||
if len(bufferedEvents) == 0 {
|
||||
return mc.applyMetadataResponseDirect(ctx, resp, options, false)
|
||||
}
|
||||
|
||||
// Apply side effects but skip directory notifications for dirs that are
|
||||
// currently being built. Notifying a building dir can trigger
|
||||
// markDirectoryReadThrough → DeleteFolderChildren, wiping entries that
|
||||
// EnsureVisited already inserted, leaving an incomplete cache.
|
||||
mc.applyMetadataSideEffectsSkippingBuildingDirs(resp, options)
|
||||
for buildDir, events := range bufferedEvents {
|
||||
state := mc.buildingDirs[buildDir]
|
||||
if state == nil {
|
||||
continue
|
||||
}
|
||||
state.bufferedEvents = append(state.bufferedEvents, events...)
|
||||
}
|
||||
for _, immediateEvent := range immediateEvents {
|
||||
if err := mc.applyMetadataResponseDirect(ctx, immediateEvent, MetadataResponseApplyOptions{}, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mc *MetaCache) applyMetadataResponseDirect(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions, allowUncachedInsert bool) error {
|
||||
if _, err := mc.applyMetadataResponseLocked(ctx, resp, options, allowUncachedInsert); err != nil {
|
||||
return err
|
||||
}
|
||||
mc.applyMetadataSideEffects(resp, options)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mc *MetaCache) applyMetadataSideEffects(resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) {
|
||||
sideEffects := metadataResponseSideEffects{}
|
||||
if options.NotifyDirectories {
|
||||
sideEffects.dirsToNotify = collectDirectoryNotifications(resp)
|
||||
}
|
||||
if options.InvalidateEntries {
|
||||
sideEffects.invalidations = collectEntryInvalidations(resp)
|
||||
}
|
||||
for _, dirPath := range sideEffects.dirsToNotify {
|
||||
mc.noteDirectoryUpdate(dirPath)
|
||||
}
|
||||
for _, invalidation := range sideEffects.invalidations {
|
||||
mc.invalidateFunc(invalidation.path, invalidation.entry)
|
||||
}
|
||||
}
|
||||
|
||||
// applyMetadataSideEffectsSkippingBuildingDirs is like applyMetadataSideEffects
|
||||
// but suppresses directory notifications for dirs currently in buildingDirs.
|
||||
// This prevents markDirectoryReadThrough from wiping entries mid-build.
|
||||
func (mc *MetaCache) applyMetadataSideEffectsSkippingBuildingDirs(resp *filer_pb.SubscribeMetadataResponse, options MetadataResponseApplyOptions) {
|
||||
sideEffects := metadataResponseSideEffects{}
|
||||
if options.NotifyDirectories {
|
||||
sideEffects.dirsToNotify = collectDirectoryNotifications(resp)
|
||||
}
|
||||
if options.InvalidateEntries {
|
||||
sideEffects.invalidations = collectEntryInvalidations(resp)
|
||||
}
|
||||
for _, dirPath := range sideEffects.dirsToNotify {
|
||||
if _, building := mc.buildingDirs[dirPath]; !building {
|
||||
mc.noteDirectoryUpdate(dirPath)
|
||||
}
|
||||
}
|
||||
for _, invalidation := range sideEffects.invalidations {
|
||||
mc.invalidateFunc(invalidation.path, invalidation.entry)
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetaCache) applyMetadataResponseLocked(ctx context.Context, resp *filer_pb.SubscribeMetadataResponse, _ MetadataResponseApplyOptions, allowUncachedInsert bool) (metadataResponseSideEffects, error) {
|
||||
message := resp.GetEventNotification()
|
||||
if message == nil {
|
||||
return metadataResponseSideEffects{}, nil
|
||||
}
|
||||
|
||||
var oldPath util.FullPath
|
||||
var newEntry *filer.Entry
|
||||
if message.OldEntry != nil {
|
||||
oldPath = util.NewFullPath(resp.Directory, message.OldEntry.Name)
|
||||
}
|
||||
|
||||
if message.NewEntry != nil {
|
||||
dir := resp.Directory
|
||||
if message.NewParentPath != "" {
|
||||
dir = message.NewParentPath
|
||||
}
|
||||
newEntry = filer.FromPbEntry(dir, message.NewEntry)
|
||||
}
|
||||
|
||||
mc.Lock()
|
||||
err := mc.atomicUpdateEntryFromFilerLocked(ctx, oldPath, newEntry, allowUncachedInsert)
|
||||
// When a directory is deleted or moved, remove its cached descendants
|
||||
// so stale children cannot be served from the local cache.
|
||||
if err == nil && oldPath != "" && message.OldEntry != nil && message.OldEntry.IsDirectory {
|
||||
isDelete := message.NewEntry == nil
|
||||
isMove := message.NewEntry != nil && (message.NewParentPath != resp.Directory || message.NewEntry.Name != message.OldEntry.Name)
|
||||
if isDelete || isMove {
|
||||
if deleteErr := mc.localStore.DeleteFolderChildren(ctx, oldPath); deleteErr != nil {
|
||||
glog.V(2).Infof("delete descendants of %s: %v", oldPath, deleteErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
mc.Unlock()
|
||||
if err != nil {
|
||||
return metadataResponseSideEffects{}, err
|
||||
}
|
||||
return metadataResponseSideEffects{}, nil
|
||||
}
|
||||
|
||||
func (mc *MetaCache) beginDirectoryBuildNow(dirPath util.FullPath) error {
|
||||
if _, found := mc.buildingDirs[dirPath]; found {
|
||||
return nil
|
||||
}
|
||||
mc.buildingDirs[dirPath] = &directoryBuildState{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mc *MetaCache) abortDirectoryBuildNow(dirPath util.FullPath) error {
|
||||
delete(mc.buildingDirs, dirPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mc *MetaCache) completeDirectoryBuildNow(ctx context.Context, dirPath util.FullPath, snapshotTsNs int64) error {
|
||||
state := mc.buildingDirs[dirPath]
|
||||
delete(mc.buildingDirs, dirPath)
|
||||
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, event := range state.bufferedEvents {
|
||||
// When the server provided a snapshot timestamp, skip events that
|
||||
// the listing already included. When snapshotTsNs == 0 (empty
|
||||
// directory — server returned no entries and no snapshot), replay
|
||||
// ALL buffered events to avoid dropping mutations due to
|
||||
// client/server clock skew.
|
||||
if snapshotTsNs != 0 && event.TsNs != 0 && event.TsNs <= snapshotTsNs {
|
||||
continue
|
||||
}
|
||||
if err := mc.applyMetadataResponseDirect(ctx, event, MetadataResponseApplyOptions{}, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
mc.markCachedFn(dirPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mc *MetaCache) routeMetadataResponse(resp *filer_pb.SubscribeMetadataResponse) ([]*filer_pb.SubscribeMetadataResponse, map[util.FullPath][]*filer_pb.SubscribeMetadataResponse) {
|
||||
message := resp.GetEventNotification()
|
||||
if message == nil {
|
||||
return []*filer_pb.SubscribeMetadataResponse{resp}, nil
|
||||
}
|
||||
|
||||
oldDir, hasOld := metadataOldParentDir(resp)
|
||||
newDir, hasNew := metadataNewParentDir(resp)
|
||||
oldBuilding := hasOld && mc.isBuildingDir(oldDir)
|
||||
newBuilding := hasNew && mc.isBuildingDir(newDir)
|
||||
if !oldBuilding && !newBuilding {
|
||||
return []*filer_pb.SubscribeMetadataResponse{resp}, nil
|
||||
}
|
||||
|
||||
bufferedEvents := make(map[util.FullPath][]*filer_pb.SubscribeMetadataResponse)
|
||||
var immediateEvents []*filer_pb.SubscribeMetadataResponse
|
||||
|
||||
if hasOld && hasNew && oldDir != newDir {
|
||||
deleteEvent := metadataDeleteFragment(resp)
|
||||
createEvent := metadataCreateFragment(resp)
|
||||
if oldBuilding {
|
||||
bufferedEvents[oldDir] = append(bufferedEvents[oldDir], deleteEvent)
|
||||
} else {
|
||||
immediateEvents = append(immediateEvents, deleteEvent)
|
||||
}
|
||||
if newBuilding {
|
||||
bufferedEvents[newDir] = append(bufferedEvents[newDir], createEvent)
|
||||
} else {
|
||||
immediateEvents = append(immediateEvents, createEvent)
|
||||
}
|
||||
return immediateEvents, bufferedEvents
|
||||
}
|
||||
|
||||
targetDir := newDir
|
||||
if hasOld {
|
||||
targetDir = oldDir
|
||||
}
|
||||
if mc.isBuildingDir(targetDir) {
|
||||
bufferedEvents[targetDir] = append(bufferedEvents[targetDir], resp)
|
||||
return nil, bufferedEvents
|
||||
}
|
||||
return []*filer_pb.SubscribeMetadataResponse{resp}, nil
|
||||
}
|
||||
|
||||
func (mc *MetaCache) isBuildingDir(dirPath util.FullPath) bool {
|
||||
_, found := mc.buildingDirs[dirPath]
|
||||
return found
|
||||
}
|
||||
|
||||
func metadataOldParentDir(resp *filer_pb.SubscribeMetadataResponse) (util.FullPath, bool) {
|
||||
if resp.GetEventNotification() == nil || resp.EventNotification.OldEntry == nil {
|
||||
return "", false
|
||||
}
|
||||
return util.FullPath(resp.Directory), true
|
||||
}
|
||||
|
||||
func metadataNewParentDir(resp *filer_pb.SubscribeMetadataResponse) (util.FullPath, bool) {
|
||||
if resp.GetEventNotification() == nil || resp.EventNotification.NewEntry == nil {
|
||||
return "", false
|
||||
}
|
||||
newDir := resp.Directory
|
||||
if resp.EventNotification.NewParentPath != "" {
|
||||
newDir = resp.EventNotification.NewParentPath
|
||||
}
|
||||
return util.FullPath(newDir), true
|
||||
}
|
||||
|
||||
func metadataDeleteFragment(resp *filer_pb.SubscribeMetadataResponse) *filer_pb.SubscribeMetadataResponse {
|
||||
if resp.GetEventNotification() == nil || resp.EventNotification.OldEntry == nil {
|
||||
return nil
|
||||
}
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: resp.Directory,
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: proto.Clone(resp.EventNotification.OldEntry).(*filer_pb.Entry),
|
||||
},
|
||||
TsNs: resp.TsNs,
|
||||
}
|
||||
}
|
||||
|
||||
func metadataCreateFragment(resp *filer_pb.SubscribeMetadataResponse) *filer_pb.SubscribeMetadataResponse {
|
||||
if resp.GetEventNotification() == nil || resp.EventNotification.NewEntry == nil {
|
||||
return nil
|
||||
}
|
||||
newDir := resp.Directory
|
||||
if resp.EventNotification.NewParentPath != "" {
|
||||
newDir = resp.EventNotification.NewParentPath
|
||||
}
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: newDir,
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: proto.Clone(resp.EventNotification.NewEntry).(*filer_pb.Entry),
|
||||
NewParentPath: newDir,
|
||||
},
|
||||
TsNs: resp.TsNs,
|
||||
}
|
||||
}
|
||||
|
||||
func metadataEventDedupKey(resp *filer_pb.SubscribeMetadataResponse) string {
|
||||
var oldName, newName, newParent string
|
||||
hasOld, hasNew := false, false
|
||||
if msg := resp.GetEventNotification(); msg != nil {
|
||||
if msg.OldEntry != nil {
|
||||
oldName = msg.OldEntry.Name
|
||||
hasOld = true
|
||||
}
|
||||
if msg.NewEntry != nil {
|
||||
newName = msg.NewEntry.Name
|
||||
hasNew = true
|
||||
newParent = msg.NewParentPath
|
||||
}
|
||||
}
|
||||
// Encode event shape (create/delete/update/rename) so structurally
|
||||
// different events with the same names are not collapsed.
|
||||
var shape byte
|
||||
switch {
|
||||
case hasOld && hasNew:
|
||||
if resp.Directory != newParent && newParent != "" {
|
||||
shape = 'R' // rename across directories
|
||||
} else {
|
||||
shape = 'U' // update in place
|
||||
}
|
||||
case hasOld:
|
||||
shape = 'D' // delete
|
||||
case hasNew:
|
||||
shape = 'C' // create
|
||||
}
|
||||
return fmt.Sprintf("%d|%c|%s|%s|%s|%s", resp.TsNs, shape, resp.Directory, oldName, newParent, newName)
|
||||
}
|
||||
|
||||
func (mc *MetaCache) shouldSkipDuplicateEvent(resp *filer_pb.SubscribeMetadataResponse) bool {
|
||||
if resp == nil || resp.TsNs == 0 {
|
||||
return false
|
||||
}
|
||||
key := metadataEventDedupKey(resp)
|
||||
return !mc.dedupRing.Add(key)
|
||||
}
|
||||
|
||||
type dedupRingBuffer struct {
|
||||
keys [recentEventDedupWindow]string
|
||||
head int
|
||||
size int
|
||||
set map[string]struct{}
|
||||
}
|
||||
|
||||
func newDedupRingBuffer() dedupRingBuffer {
|
||||
return dedupRingBuffer{
|
||||
set: make(map[string]struct{}, recentEventDedupWindow),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *dedupRingBuffer) Add(key string) bool {
|
||||
if _, found := r.set[key]; found {
|
||||
return false // duplicate
|
||||
}
|
||||
if r.size == recentEventDedupWindow {
|
||||
evicted := r.keys[r.head]
|
||||
delete(r.set, evicted)
|
||||
} else {
|
||||
r.size++
|
||||
}
|
||||
r.keys[r.head] = key
|
||||
r.set[key] = struct{}{}
|
||||
r.head = (r.head + 1) % recentEventDedupWindow
|
||||
return true // new entry
|
||||
}
|
||||
|
||||
func collectDirectoryNotifications(resp *filer_pb.SubscribeMetadataResponse) []util.FullPath {
|
||||
message := resp.GetEventNotification()
|
||||
if message == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// At most 3 dirs: old parent, new parent, new child (if directory).
|
||||
// Use a fixed slice with linear dedup to avoid map allocation.
|
||||
var dirs [3]util.FullPath
|
||||
n := 0
|
||||
addUnique := func(p util.FullPath) {
|
||||
for i := 0; i < n; i++ {
|
||||
if dirs[i] == p {
|
||||
return
|
||||
}
|
||||
}
|
||||
dirs[n] = p
|
||||
n++
|
||||
}
|
||||
|
||||
if message.OldEntry != nil {
|
||||
oldPath := util.NewFullPath(resp.Directory, message.OldEntry.Name)
|
||||
parent, _ := oldPath.DirAndName()
|
||||
addUnique(util.FullPath(parent))
|
||||
}
|
||||
if message.NewEntry != nil {
|
||||
newDir := resp.Directory
|
||||
if message.NewParentPath != "" {
|
||||
newDir = message.NewParentPath
|
||||
}
|
||||
newPath := util.NewFullPath(newDir, message.NewEntry.Name)
|
||||
parent, _ := newPath.DirAndName()
|
||||
addUnique(util.FullPath(parent))
|
||||
if message.NewEntry.IsDirectory {
|
||||
addUnique(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
return dirs[:n]
|
||||
}
|
||||
|
||||
func collectEntryInvalidations(resp *filer_pb.SubscribeMetadataResponse) []metadataInvalidation {
|
||||
message := resp.GetEventNotification()
|
||||
if message == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var invalidations []metadataInvalidation
|
||||
if message.OldEntry != nil && message.NewEntry != nil {
|
||||
oldKey := util.NewFullPath(resp.Directory, message.OldEntry.Name)
|
||||
invalidations = append(invalidations, metadataInvalidation{path: oldKey, entry: message.OldEntry})
|
||||
// Normalize NewParentPath: empty means same directory as resp.Directory
|
||||
newDir := resp.Directory
|
||||
if message.NewParentPath != "" {
|
||||
newDir = message.NewParentPath
|
||||
}
|
||||
if message.OldEntry.Name != message.NewEntry.Name || resp.Directory != newDir {
|
||||
newKey := util.NewFullPath(newDir, message.NewEntry.Name)
|
||||
invalidations = append(invalidations, metadataInvalidation{path: newKey, entry: message.NewEntry})
|
||||
}
|
||||
return invalidations
|
||||
}
|
||||
|
||||
if filer_pb.IsCreate(resp) && message.NewEntry != nil {
|
||||
newDir := resp.Directory
|
||||
if message.NewParentPath != "" {
|
||||
newDir = message.NewParentPath
|
||||
}
|
||||
newKey := util.NewFullPath(newDir, message.NewEntry.Name)
|
||||
invalidations = append(invalidations, metadataInvalidation{path: newKey, entry: message.NewEntry})
|
||||
}
|
||||
|
||||
if filer_pb.IsDelete(resp) && message.OldEntry != nil {
|
||||
oldKey := util.NewFullPath(resp.Directory, message.OldEntry.Name)
|
||||
invalidations = append(invalidations, metadataInvalidation{path: oldKey, entry: message.OldEntry})
|
||||
}
|
||||
|
||||
return invalidations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package meta_cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
func TestApplyMetadataResponseAppliesEventsInOrder(t *testing.T) {
|
||||
mc, _, notifications, invalidations := newTestMetaCache(t, map[util.FullPath]bool{
|
||||
"/": true,
|
||||
"/dir": true,
|
||||
})
|
||||
defer mc.Shutdown()
|
||||
|
||||
createResp := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{
|
||||
Name: "file.txt",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: 1,
|
||||
Mtime: 1,
|
||||
FileMode: 0100644,
|
||||
FileSize: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
updateResp := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{
|
||||
Name: "file.txt",
|
||||
},
|
||||
NewEntry: &filer_pb.Entry{
|
||||
Name: "file.txt",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: 1,
|
||||
Mtime: 2,
|
||||
FileMode: 0100644,
|
||||
FileSize: 29,
|
||||
},
|
||||
},
|
||||
NewParentPath: "/dir",
|
||||
},
|
||||
}
|
||||
deleteResp := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{
|
||||
Name: "file.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := mc.ApplyMetadataResponse(context.Background(), createResp, SubscriberMetadataResponseApplyOptions); err != nil {
|
||||
t.Fatalf("apply create: %v", err)
|
||||
}
|
||||
|
||||
entry, err := mc.FindEntry(context.Background(), util.FullPath("/dir/file.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("find created entry: %v", err)
|
||||
}
|
||||
if entry.FileSize != 11 {
|
||||
t.Fatalf("created file size = %d, want 11", entry.FileSize)
|
||||
}
|
||||
|
||||
if err := mc.ApplyMetadataResponse(context.Background(), updateResp, SubscriberMetadataResponseApplyOptions); err != nil {
|
||||
t.Fatalf("apply update: %v", err)
|
||||
}
|
||||
|
||||
entry, err = mc.FindEntry(context.Background(), util.FullPath("/dir/file.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("find updated entry: %v", err)
|
||||
}
|
||||
if entry.FileSize != 29 {
|
||||
t.Fatalf("updated file size = %d, want 29", entry.FileSize)
|
||||
}
|
||||
|
||||
if err := mc.ApplyMetadataResponse(context.Background(), deleteResp, SubscriberMetadataResponseApplyOptions); err != nil {
|
||||
t.Fatalf("apply delete: %v", err)
|
||||
}
|
||||
|
||||
entry, err = mc.FindEntry(context.Background(), util.FullPath("/dir/file.txt"))
|
||||
if err != filer_pb.ErrNotFound {
|
||||
t.Fatalf("find deleted entry error = %v, want %v", err, filer_pb.ErrNotFound)
|
||||
}
|
||||
if entry != nil {
|
||||
t.Fatalf("deleted entry still cached: %+v", entry)
|
||||
}
|
||||
|
||||
if got := countPath(notifications.paths(), util.FullPath("/dir")); got != 3 {
|
||||
t.Fatalf("directory notifications for /dir = %d, want 3", got)
|
||||
}
|
||||
if got := countPath(invalidations.paths(), util.FullPath("/dir/file.txt")); got != 3 {
|
||||
t.Fatalf("invalidations for /dir/file.txt = %d, want 3 (create + update + delete)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMetadataResponseRenamesAcrossCachedDirectories(t *testing.T) {
|
||||
mc, _, notifications, invalidations := newTestMetaCache(t, map[util.FullPath]bool{
|
||||
"/": true,
|
||||
"/src": true,
|
||||
"/dst": true,
|
||||
})
|
||||
defer mc.Shutdown()
|
||||
|
||||
if err := mc.InsertEntry(context.Background(), &filer.Entry{
|
||||
FullPath: "/src/file.tmp",
|
||||
Attr: filer.Attr{
|
||||
Crtime: time.Unix(1, 0),
|
||||
Mtime: time.Unix(1, 0),
|
||||
Mode: 0100644,
|
||||
FileSize: 7,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("insert source entry: %v", err)
|
||||
}
|
||||
|
||||
renameResp := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/src",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{
|
||||
Name: "file.tmp",
|
||||
},
|
||||
NewEntry: &filer_pb.Entry{
|
||||
Name: "file.txt",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: 1,
|
||||
Mtime: 2,
|
||||
FileMode: 0100644,
|
||||
FileSize: 41,
|
||||
},
|
||||
},
|
||||
NewParentPath: "/dst",
|
||||
},
|
||||
}
|
||||
|
||||
if err := mc.ApplyMetadataResponse(context.Background(), renameResp, SubscriberMetadataResponseApplyOptions); err != nil {
|
||||
t.Fatalf("apply rename: %v", err)
|
||||
}
|
||||
|
||||
oldEntry, err := mc.FindEntry(context.Background(), util.FullPath("/src/file.tmp"))
|
||||
if err != filer_pb.ErrNotFound {
|
||||
t.Fatalf("find old path error = %v, want %v", err, filer_pb.ErrNotFound)
|
||||
}
|
||||
if oldEntry != nil {
|
||||
t.Fatalf("old path still cached: %+v", oldEntry)
|
||||
}
|
||||
|
||||
newEntry, err := mc.FindEntry(context.Background(), util.FullPath("/dst/file.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("find new path: %v", err)
|
||||
}
|
||||
if newEntry.FileSize != 41 {
|
||||
t.Fatalf("renamed file size = %d, want 41", newEntry.FileSize)
|
||||
}
|
||||
|
||||
if got := countPath(notifications.paths(), util.FullPath("/src")); got != 1 {
|
||||
t.Fatalf("directory notifications for /src = %d, want 1", got)
|
||||
}
|
||||
if got := countPath(notifications.paths(), util.FullPath("/dst")); got != 1 {
|
||||
t.Fatalf("directory notifications for /dst = %d, want 1", got)
|
||||
}
|
||||
if got := countPath(invalidations.paths(), util.FullPath("/src/file.tmp")); got != 1 {
|
||||
t.Fatalf("invalidations for /src/file.tmp = %d, want 1", got)
|
||||
}
|
||||
if got := countPath(invalidations.paths(), util.FullPath("/dst/file.txt")); got != 1 {
|
||||
t.Fatalf("invalidations for /dst/file.txt = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMetadataResponseLocalOptionsSkipInvalidations(t *testing.T) {
|
||||
mc, _, notifications, invalidations := newTestMetaCache(t, map[util.FullPath]bool{
|
||||
"/": true,
|
||||
"/dir": true,
|
||||
})
|
||||
defer mc.Shutdown()
|
||||
|
||||
if err := mc.InsertEntry(context.Background(), &filer.Entry{
|
||||
FullPath: "/dir/file.txt",
|
||||
Attr: filer.Attr{
|
||||
Crtime: time.Unix(1, 0),
|
||||
Mtime: time.Unix(1, 0),
|
||||
Mode: 0100644,
|
||||
FileSize: 7,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("insert source entry: %v", err)
|
||||
}
|
||||
|
||||
updateResp := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{
|
||||
Name: "file.txt",
|
||||
},
|
||||
NewEntry: &filer_pb.Entry{
|
||||
Name: "file.txt",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: 1,
|
||||
Mtime: 2,
|
||||
FileMode: 0100644,
|
||||
FileSize: 17,
|
||||
},
|
||||
},
|
||||
NewParentPath: "/dir",
|
||||
},
|
||||
}
|
||||
|
||||
if err := mc.ApplyMetadataResponse(context.Background(), updateResp, LocalMetadataResponseApplyOptions); err != nil {
|
||||
t.Fatalf("apply local update: %v", err)
|
||||
}
|
||||
|
||||
entry, err := mc.FindEntry(context.Background(), util.FullPath("/dir/file.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("find updated entry: %v", err)
|
||||
}
|
||||
if entry.FileSize != 17 {
|
||||
t.Fatalf("updated file size = %d, want 17", entry.FileSize)
|
||||
}
|
||||
if got := countPath(notifications.paths(), util.FullPath("/dir")); got != 1 {
|
||||
t.Fatalf("directory notifications for /dir = %d, want 1", got)
|
||||
}
|
||||
if got := len(invalidations.paths()); got != 0 {
|
||||
t.Fatalf("invalidations = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMetadataResponseDeduplicatesRepeatedFilerEvent(t *testing.T) {
|
||||
mc, _, notifications, invalidations := newTestMetaCache(t, map[util.FullPath]bool{
|
||||
"/": true,
|
||||
"/dir": true,
|
||||
})
|
||||
defer mc.Shutdown()
|
||||
|
||||
if err := mc.InsertEntry(context.Background(), &filer.Entry{
|
||||
FullPath: "/dir/file.txt",
|
||||
Attr: filer.Attr{
|
||||
Crtime: time.Unix(1, 0),
|
||||
Mtime: time.Unix(1, 0),
|
||||
Mode: 0100644,
|
||||
FileSize: 5,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("insert source entry: %v", err)
|
||||
}
|
||||
|
||||
updateResp := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{
|
||||
Name: "file.txt",
|
||||
},
|
||||
NewEntry: &filer_pb.Entry{
|
||||
Name: "file.txt",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: 1,
|
||||
Mtime: 2,
|
||||
FileMode: 0100644,
|
||||
FileSize: 15,
|
||||
},
|
||||
},
|
||||
NewParentPath: "/dir",
|
||||
Signatures: []int32{7},
|
||||
},
|
||||
TsNs: 99,
|
||||
}
|
||||
|
||||
if err := mc.ApplyMetadataResponse(context.Background(), updateResp, SubscriberMetadataResponseApplyOptions); err != nil {
|
||||
t.Fatalf("first apply: %v", err)
|
||||
}
|
||||
if err := mc.ApplyMetadataResponse(context.Background(), updateResp, SubscriberMetadataResponseApplyOptions); err != nil {
|
||||
t.Fatalf("second apply: %v", err)
|
||||
}
|
||||
|
||||
entry, err := mc.FindEntry(context.Background(), util.FullPath("/dir/file.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("find updated entry: %v", err)
|
||||
}
|
||||
if entry.FileSize != 15 {
|
||||
t.Fatalf("updated file size = %d, want 15", entry.FileSize)
|
||||
}
|
||||
if got := countPath(notifications.paths(), util.FullPath("/dir")); got != 1 {
|
||||
t.Fatalf("directory notifications for /dir = %d, want 1", got)
|
||||
}
|
||||
if got := countPath(invalidations.paths(), util.FullPath("/dir/file.txt")); got != 1 {
|
||||
t.Fatalf("invalidations for /dir/file.txt = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestMetaCache(t *testing.T, cached map[util.FullPath]bool) (*MetaCache, map[util.FullPath]bool, *recordedPaths, *recordedPaths) {
|
||||
t.Helper()
|
||||
|
||||
mapper, err := NewUidGidMapper("", "")
|
||||
if err != nil {
|
||||
t.Fatalf("uid/gid mapper: %v", err)
|
||||
}
|
||||
|
||||
var cachedMu sync.Mutex
|
||||
notifications := &recordedPaths{}
|
||||
invalidations := &recordedPaths{}
|
||||
|
||||
mc := NewMetaCache(
|
||||
filepath.Join(t.TempDir(), "meta"),
|
||||
mapper,
|
||||
util.FullPath("/"),
|
||||
func(path util.FullPath) {
|
||||
cachedMu.Lock()
|
||||
defer cachedMu.Unlock()
|
||||
cached[path] = true
|
||||
},
|
||||
func(path util.FullPath) bool {
|
||||
cachedMu.Lock()
|
||||
defer cachedMu.Unlock()
|
||||
return cached[path]
|
||||
},
|
||||
func(path util.FullPath, entry *filer_pb.Entry) {
|
||||
invalidations.record(path)
|
||||
},
|
||||
func(dir util.FullPath) {
|
||||
notifications.record(dir)
|
||||
},
|
||||
)
|
||||
|
||||
return mc, cached, notifications, invalidations
|
||||
}
|
||||
|
||||
type recordedPaths struct {
|
||||
mu sync.Mutex
|
||||
items []util.FullPath
|
||||
}
|
||||
|
||||
func (r *recordedPaths) record(path util.FullPath) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.items = append(r.items, path)
|
||||
}
|
||||
|
||||
func (r *recordedPaths) paths() []util.FullPath {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]util.FullPath(nil), r.items...)
|
||||
}
|
||||
|
||||
func countPath(paths []util.FullPath, target util.FullPath) int {
|
||||
count := 0
|
||||
for _, path := range paths {
|
||||
if path == target {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package meta_cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type buildListStream struct {
|
||||
responses []*filer_pb.ListEntriesResponse
|
||||
onFirstRecv func()
|
||||
once sync.Once
|
||||
index int
|
||||
}
|
||||
|
||||
func (s *buildListStream) Recv() (*filer_pb.ListEntriesResponse, error) {
|
||||
s.once.Do(func() {
|
||||
if s.onFirstRecv != nil {
|
||||
s.onFirstRecv()
|
||||
}
|
||||
})
|
||||
if s.index >= len(s.responses) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
resp := s.responses[s.index]
|
||||
s.index++
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *buildListStream) Header() (metadata.MD, error) { return metadata.MD{}, nil }
|
||||
func (s *buildListStream) Trailer() metadata.MD { return metadata.MD{} }
|
||||
func (s *buildListStream) CloseSend() error { return nil }
|
||||
func (s *buildListStream) Context() context.Context { return context.Background() }
|
||||
func (s *buildListStream) SendMsg(any) error { return nil }
|
||||
func (s *buildListStream) RecvMsg(any) error { return nil }
|
||||
|
||||
type buildListClient struct {
|
||||
filer_pb.SeaweedFilerClient
|
||||
responses []*filer_pb.ListEntriesResponse
|
||||
onFirstRecv func()
|
||||
}
|
||||
|
||||
func (c *buildListClient) ListEntries(ctx context.Context, in *filer_pb.ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.ListEntriesResponse], error) {
|
||||
return &buildListStream{
|
||||
responses: c.responses,
|
||||
onFirstRecv: c.onFirstRecv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type buildFilerAccessor struct {
|
||||
client filer_pb.SeaweedFilerClient
|
||||
}
|
||||
|
||||
func (a *buildFilerAccessor) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(a.client)
|
||||
}
|
||||
|
||||
func (a *buildFilerAccessor) AdjustedUrl(*filer_pb.Location) string { return "" }
|
||||
func (a *buildFilerAccessor) GetDataCenter() string { return "" }
|
||||
|
||||
func TestEnsureVisitedReplaysBufferedEventsAfterSnapshot(t *testing.T) {
|
||||
mc, _, _, _ := newTestMetaCache(t, map[util.FullPath]bool{
|
||||
"/": true,
|
||||
})
|
||||
defer mc.Shutdown()
|
||||
|
||||
var applyErr error
|
||||
accessor := &buildFilerAccessor{
|
||||
client: &buildListClient{
|
||||
responses: []*filer_pb.ListEntriesResponse{
|
||||
{
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: "base.txt",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: 1,
|
||||
Mtime: 1,
|
||||
FileMode: 0100644,
|
||||
FileSize: 3,
|
||||
},
|
||||
},
|
||||
SnapshotTsNs: 100,
|
||||
},
|
||||
},
|
||||
onFirstRecv: func() {
|
||||
applyErr = mc.ApplyMetadataResponse(context.Background(), &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{
|
||||
Name: "after.txt",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: 2,
|
||||
Mtime: 2,
|
||||
FileMode: 0100644,
|
||||
FileSize: 9,
|
||||
},
|
||||
},
|
||||
},
|
||||
TsNs: 101,
|
||||
}, SubscriberMetadataResponseApplyOptions)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := EnsureVisited(mc, accessor, util.FullPath("/dir")); err != nil {
|
||||
t.Fatalf("ensure visited: %v", err)
|
||||
}
|
||||
if applyErr != nil {
|
||||
t.Fatalf("apply buffered event: %v", applyErr)
|
||||
}
|
||||
if !mc.IsDirectoryCached(util.FullPath("/dir")) {
|
||||
t.Fatal("directory /dir should be cached after build completes")
|
||||
}
|
||||
|
||||
baseEntry, err := mc.FindEntry(context.Background(), util.FullPath("/dir/base.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("find base entry: %v", err)
|
||||
}
|
||||
if baseEntry.FileSize != 3 {
|
||||
t.Fatalf("base entry size = %d, want 3", baseEntry.FileSize)
|
||||
}
|
||||
|
||||
afterEntry, err := mc.FindEntry(context.Background(), util.FullPath("/dir/after.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("find replayed entry: %v", err)
|
||||
}
|
||||
if afterEntry.FileSize != 9 {
|
||||
t.Fatalf("replayed entry size = %d, want 9", afterEntry.FileSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectoryNotificationsSuppressedDuringBuild verifies that metadata events
|
||||
// targeting a directory under active build do NOT fire onDirectoryUpdate for
|
||||
// that directory. In production, onDirectoryUpdate can trigger
|
||||
// markDirectoryReadThrough → DeleteFolderChildren, which would wipe entries
|
||||
// that EnsureVisited already inserted mid-build.
|
||||
func TestDirectoryNotificationsSuppressedDuringBuild(t *testing.T) {
|
||||
mc, _, notifications, _ := newTestMetaCache(t, map[util.FullPath]bool{
|
||||
"/": true,
|
||||
})
|
||||
defer mc.Shutdown()
|
||||
|
||||
// Start building /dir (simulates the beginning of EnsureVisited)
|
||||
if err := mc.BeginDirectoryBuild(context.Background(), util.FullPath("/dir")); err != nil {
|
||||
t.Fatalf("begin build: %v", err)
|
||||
}
|
||||
|
||||
// Insert an entry as EnsureVisited would during the filer listing
|
||||
if err := mc.InsertEntry(context.Background(), &filer.Entry{
|
||||
FullPath: "/dir/existing.txt",
|
||||
Attr: filer.Attr{
|
||||
Crtime: time.Unix(1, 0),
|
||||
Mtime: time.Unix(1, 0),
|
||||
Mode: 0100644,
|
||||
FileSize: 100,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("insert entry during build: %v", err)
|
||||
}
|
||||
|
||||
// Simulate multiple metadata events arriving for /dir while the build
|
||||
// is in progress. Each event would normally call noteDirectoryUpdate,
|
||||
// which in production can trigger markDirectoryReadThrough and wipe entries.
|
||||
for i := 0; i < 5; i++ {
|
||||
resp := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{
|
||||
Name: fmt.Sprintf("new-%d.txt", i),
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: int64(10 + i),
|
||||
Mtime: int64(10 + i),
|
||||
FileMode: 0100644,
|
||||
FileSize: uint64(i + 1),
|
||||
},
|
||||
},
|
||||
},
|
||||
TsNs: int64(200 + i),
|
||||
}
|
||||
if err := mc.ApplyMetadataResponse(context.Background(), resp, SubscriberMetadataResponseApplyOptions); err != nil {
|
||||
t.Fatalf("apply event %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The building directory /dir must NOT have received any notifications.
|
||||
// If it did, markDirectoryReadThrough would wipe the cache mid-build.
|
||||
for _, p := range notifications.paths() {
|
||||
if p == util.FullPath("/dir") {
|
||||
t.Fatal("onDirectoryUpdate was called for /dir during build; this would cause markDirectoryReadThrough to wipe entries mid-build")
|
||||
}
|
||||
}
|
||||
|
||||
// The entry inserted during the build must still be present
|
||||
entry, err := mc.FindEntry(context.Background(), util.FullPath("/dir/existing.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("entry wiped during build: %v", err)
|
||||
}
|
||||
if entry.FileSize != 100 {
|
||||
t.Fatalf("entry size = %d, want 100", entry.FileSize)
|
||||
}
|
||||
|
||||
// Complete the build — buffered events should be replayed
|
||||
if err := mc.CompleteDirectoryBuild(context.Background(), util.FullPath("/dir"), 150); err != nil {
|
||||
t.Fatalf("complete build: %v", err)
|
||||
}
|
||||
|
||||
// After build completes, the entry from the listing should still exist
|
||||
entry, err = mc.FindEntry(context.Background(), util.FullPath("/dir/existing.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("entry lost after build completion: %v", err)
|
||||
}
|
||||
if entry.FileSize != 100 {
|
||||
t.Fatalf("entry size after build = %d, want 100", entry.FileSize)
|
||||
}
|
||||
|
||||
// Buffered events with TsNs > snapshotTsNs (150) should have been replayed
|
||||
for i := 0; i < 5; i++ {
|
||||
name := fmt.Sprintf("new-%d.txt", i)
|
||||
e, err := mc.FindEntry(context.Background(), util.FullPath("/dir/"+name))
|
||||
if err != nil {
|
||||
t.Fatalf("replayed entry %s not found: %v", name, err)
|
||||
}
|
||||
if e.FileSize != uint64(i+1) {
|
||||
t.Fatalf("replayed entry %s size = %d, want %d", name, e.FileSize, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyDirectoryBuildReplaysAllBufferedEvents verifies that when a
|
||||
// directory build completes with snapshotTsNs=0 (empty directory — server
|
||||
// returned no entries and no snapshot), ALL buffered events are replayed
|
||||
// without any TsNs filtering. This prevents clock-skew between client and
|
||||
// filer from dropping legitimate mutations.
|
||||
func TestEmptyDirectoryBuildReplaysAllBufferedEvents(t *testing.T) {
|
||||
mc, _, _, _ := newTestMetaCache(t, map[util.FullPath]bool{
|
||||
"/": true,
|
||||
})
|
||||
defer mc.Shutdown()
|
||||
|
||||
if err := mc.BeginDirectoryBuild(context.Background(), util.FullPath("/empty")); err != nil {
|
||||
t.Fatalf("begin build: %v", err)
|
||||
}
|
||||
|
||||
// Buffer events with a range of TsNs values — some very old, some recent.
|
||||
// With a client-synthesized snapshot, old events could be incorrectly filtered.
|
||||
tsValues := []int64{1, 50, 500, 5000, 50000}
|
||||
for i, ts := range tsValues {
|
||||
resp := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/empty",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{
|
||||
Name: fmt.Sprintf("file-%d.txt", i),
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: ts,
|
||||
Mtime: ts,
|
||||
FileMode: 0100644,
|
||||
FileSize: uint64(i + 10),
|
||||
},
|
||||
},
|
||||
},
|
||||
TsNs: ts,
|
||||
}
|
||||
if err := mc.ApplyMetadataResponse(context.Background(), resp, SubscriberMetadataResponseApplyOptions); err != nil {
|
||||
t.Fatalf("apply event %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Complete with snapshotTsNs=0 — simulates empty directory listing
|
||||
if err := mc.CompleteDirectoryBuild(context.Background(), util.FullPath("/empty"), 0); err != nil {
|
||||
t.Fatalf("complete build: %v", err)
|
||||
}
|
||||
|
||||
// Every buffered event must have been replayed, regardless of TsNs
|
||||
for i := range tsValues {
|
||||
name := fmt.Sprintf("file-%d.txt", i)
|
||||
e, err := mc.FindEntry(context.Background(), util.FullPath("/empty/"+name))
|
||||
if err != nil {
|
||||
t.Fatalf("replayed entry %s not found: %v", name, err)
|
||||
}
|
||||
if e.FileSize != uint64(i+10) {
|
||||
t.Fatalf("replayed entry %s size = %d, want %d", name, e.FileSize, i+10)
|
||||
}
|
||||
}
|
||||
|
||||
if !mc.IsDirectoryCached(util.FullPath("/empty")) {
|
||||
t.Fatal("/empty should be marked cached after build completes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCompletionSurvivesCallerCancellation verifies that once
|
||||
// CompleteDirectoryBuild is enqueued, a cancelled caller context does not
|
||||
// prevent the build from completing. The apply loop uses context.Background()
|
||||
// internally, so the operation finishes even if the caller gives up waiting.
|
||||
func TestBuildCompletionSurvivesCallerCancellation(t *testing.T) {
|
||||
mc, _, _, _ := newTestMetaCache(t, map[util.FullPath]bool{
|
||||
"/": true,
|
||||
})
|
||||
defer mc.Shutdown()
|
||||
|
||||
if err := mc.BeginDirectoryBuild(context.Background(), util.FullPath("/dir")); err != nil {
|
||||
t.Fatalf("begin build: %v", err)
|
||||
}
|
||||
|
||||
// Insert an entry during the build (as EnsureVisited would)
|
||||
if err := mc.InsertEntry(context.Background(), &filer.Entry{
|
||||
FullPath: "/dir/kept.txt",
|
||||
Attr: filer.Attr{
|
||||
Crtime: time.Unix(1, 0),
|
||||
Mtime: time.Unix(1, 0),
|
||||
Mode: 0100644,
|
||||
FileSize: 42,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("insert entry: %v", err)
|
||||
}
|
||||
|
||||
// Buffer an event that should be replayed
|
||||
if err := mc.ApplyMetadataResponse(context.Background(), &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/dir",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: &filer_pb.Entry{
|
||||
Name: "buffered.txt",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: 5,
|
||||
Mtime: 5,
|
||||
FileMode: 0100644,
|
||||
FileSize: 77,
|
||||
},
|
||||
},
|
||||
},
|
||||
TsNs: 200,
|
||||
}, SubscriberMetadataResponseApplyOptions); err != nil {
|
||||
t.Fatalf("apply event: %v", err)
|
||||
}
|
||||
|
||||
// Complete with an already-cancelled context. The operation should still
|
||||
// succeed because enqueueAndWait sets req.ctx = context.Background().
|
||||
cancelledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
// CompleteDirectoryBuild may return ctx.Err() if the select picks
|
||||
// ctx.Done() first, but the operation itself still completes in the
|
||||
// apply loop. Poll for the observable side effect instead of using
|
||||
// a fixed sleep.
|
||||
_ = mc.CompleteDirectoryBuild(cancelledCtx, util.FullPath("/dir"), 100)
|
||||
|
||||
// Poll until the build completes or a deadline elapses.
|
||||
deadline := time.After(2 * time.Second)
|
||||
for !mc.IsDirectoryCached(util.FullPath("/dir")) {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("/dir should be cached — CompleteDirectoryBuild must have executed despite cancelled context")
|
||||
default:
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// The pre-existing entry must survive
|
||||
entry, findErr := mc.FindEntry(context.Background(), util.FullPath("/dir/kept.txt"))
|
||||
if findErr != nil {
|
||||
t.Fatalf("find kept entry: %v", findErr)
|
||||
}
|
||||
if entry.FileSize != 42 {
|
||||
t.Fatalf("kept entry size = %d, want 42", entry.FileSize)
|
||||
}
|
||||
|
||||
// The buffered event (TsNs 200 > snapshot 100) must have been replayed
|
||||
buffered, findErr := mc.FindEntry(context.Background(), util.FullPath("/dir/buffered.txt"))
|
||||
if findErr != nil {
|
||||
t.Fatalf("find buffered entry: %v", findErr)
|
||||
}
|
||||
if buffered.FileSize != 77 {
|
||||
t.Fatalf("buffered entry size = %d, want 77", buffered.FileSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferedRenameUpdatesOtherDirectoryBeforeBuildCompletes(t *testing.T) {
|
||||
mc, _, _, _ := newTestMetaCache(t, map[util.FullPath]bool{
|
||||
"/": true,
|
||||
"/src": true,
|
||||
})
|
||||
defer mc.Shutdown()
|
||||
|
||||
if err := mc.InsertEntry(context.Background(), &filer.Entry{
|
||||
FullPath: "/src/from.txt",
|
||||
Attr: filer.Attr{
|
||||
Crtime: time.Unix(1, 0),
|
||||
Mtime: time.Unix(1, 0),
|
||||
Mode: 0100644,
|
||||
FileSize: 7,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("insert source entry: %v", err)
|
||||
}
|
||||
|
||||
if err := mc.BeginDirectoryBuild(context.Background(), util.FullPath("/dst")); err != nil {
|
||||
t.Fatalf("begin build: %v", err)
|
||||
}
|
||||
|
||||
renameResp := &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: "/src",
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{
|
||||
Name: "from.txt",
|
||||
},
|
||||
NewEntry: &filer_pb.Entry{
|
||||
Name: "to.txt",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Crtime: 2,
|
||||
Mtime: 2,
|
||||
FileMode: 0100644,
|
||||
FileSize: 12,
|
||||
},
|
||||
},
|
||||
NewParentPath: "/dst",
|
||||
},
|
||||
TsNs: 101,
|
||||
}
|
||||
|
||||
if err := mc.ApplyMetadataResponse(context.Background(), renameResp, SubscriberMetadataResponseApplyOptions); err != nil {
|
||||
t.Fatalf("apply rename: %v", err)
|
||||
}
|
||||
|
||||
oldEntry, err := mc.FindEntry(context.Background(), util.FullPath("/src/from.txt"))
|
||||
if err != filer_pb.ErrNotFound {
|
||||
t.Fatalf("find old path error = %v, want %v", err, filer_pb.ErrNotFound)
|
||||
}
|
||||
if oldEntry != nil {
|
||||
t.Fatalf("old path should be removed before build completes: %+v", oldEntry)
|
||||
}
|
||||
|
||||
newEntry, err := mc.FindEntry(context.Background(), util.FullPath("/dst/to.txt"))
|
||||
if err != filer_pb.ErrNotFound {
|
||||
t.Fatalf("find buffered new path error = %v, want %v", err, filer_pb.ErrNotFound)
|
||||
}
|
||||
if newEntry != nil {
|
||||
t.Fatalf("new path should stay hidden until build completes: %+v", newEntry)
|
||||
}
|
||||
|
||||
if err := mc.CompleteDirectoryBuild(context.Background(), util.FullPath("/dst"), 100); err != nil {
|
||||
t.Fatalf("complete build: %v", err)
|
||||
}
|
||||
|
||||
newEntry, err = mc.FindEntry(context.Background(), util.FullPath("/dst/to.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("find replayed new path: %v", err)
|
||||
}
|
||||
if newEntry.FileSize != 12 {
|
||||
t.Fatalf("replayed new path size = %d, want 12", newEntry.FileSize)
|
||||
}
|
||||
}
|
||||
@@ -69,12 +69,43 @@ func doEnsureVisited(ctx context.Context, mc *MetaCache, client filer_pb.FilerCl
|
||||
|
||||
glog.V(4).Infof("ReadDirAllEntries %s ...", path)
|
||||
|
||||
// Use context.Background() for build lifecycle calls so that
|
||||
// errgroup cancellation of ctx doesn't cause enqueueAndWait to
|
||||
// return early, which would trigger cleanupBuild while the
|
||||
// operation is still queued.
|
||||
if err := mc.BeginDirectoryBuild(context.Background(), path); err != nil {
|
||||
return nil, fmt.Errorf("begin build %s: %w", path, err)
|
||||
}
|
||||
cleanupDone := false
|
||||
cleanupBuild := func(reason string) {
|
||||
if cleanupDone {
|
||||
return
|
||||
}
|
||||
cleanupDone = true
|
||||
if deleteErr := mc.DeleteFolderChildren(context.Background(), path); deleteErr != nil {
|
||||
glog.V(2).Infof("clear %s build %s: %v", reason, path, deleteErr)
|
||||
}
|
||||
if abortErr := mc.AbortDirectoryBuild(context.Background(), path); abortErr != nil {
|
||||
glog.V(2).Infof("abort %s build %s: %v", reason, path, abortErr)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if !cleanupDone && ctx.Err() != nil {
|
||||
cleanupBuild("canceled")
|
||||
}
|
||||
}()
|
||||
|
||||
// Collect entries in batches for efficient LevelDB writes
|
||||
var batch []*filer.Entry
|
||||
var snapshotTsNs int64
|
||||
|
||||
fetchErr := util.Retry("ReadDirAllEntries", func() error {
|
||||
batch = nil // Reset batch on retry, allow GC of previous entries
|
||||
return filer_pb.ReadDirAllEntries(ctx, client, path, "", func(pbEntry *filer_pb.Entry, isLast bool) error {
|
||||
if err := mc.DeleteFolderChildren(ctx, path); err != nil {
|
||||
return fmt.Errorf("clear existing entries for %s: %w", path, err)
|
||||
}
|
||||
var err error
|
||||
snapshotTsNs, err = filer_pb.ReadDirAllEntriesWithSnapshot(ctx, client, path, "", func(pbEntry *filer_pb.Entry, isLast bool) error {
|
||||
entry := filer.FromPbEntry(string(path), pbEntry)
|
||||
if IsHiddenSystemEntry(string(path), entry.Name()) {
|
||||
return nil
|
||||
@@ -94,19 +125,26 @@ func doEnsureVisited(ctx context.Context, mc *MetaCache, client filer_pb.FilerCl
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
})
|
||||
|
||||
if fetchErr != nil {
|
||||
cleanupBuild("failed")
|
||||
return nil, fmt.Errorf("list %s: %w", path, fetchErr)
|
||||
}
|
||||
|
||||
// Flush any remaining entries in the batch
|
||||
if len(batch) > 0 {
|
||||
if err := mc.doBatchInsertEntries(ctx, batch); err != nil {
|
||||
cleanupBuild("incomplete")
|
||||
return nil, fmt.Errorf("batch insert remaining for %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
mc.markCachedFn(path)
|
||||
if err := mc.CompleteDirectoryBuild(context.Background(), path, snapshotTsNs); err != nil {
|
||||
cleanupBuild("unreplayed")
|
||||
return nil, fmt.Errorf("complete build for %s: %w", path, err)
|
||||
}
|
||||
cleanupDone = true // Prevent deferred cleanup after successful publish
|
||||
return nil, nil
|
||||
})
|
||||
return err
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
@@ -51,67 +50,12 @@ func SubscribeMetaEvents(mc *MetaCache, selfSignature int32, client filer_pb.Fil
|
||||
}
|
||||
|
||||
processEventFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
|
||||
message := resp.EventNotification
|
||||
|
||||
for _, sig := range message.Signatures {
|
||||
if sig == selfSignature && selfSignature != 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
dir := resp.Directory
|
||||
var oldPath util.FullPath
|
||||
var newEntry *filer.Entry
|
||||
if message.OldEntry != nil {
|
||||
oldPath = util.NewFullPath(dir, message.OldEntry.Name)
|
||||
glog.V(4).Infof("deleting %v", oldPath)
|
||||
}
|
||||
|
||||
if message.NewEntry != nil {
|
||||
if message.NewParentPath != "" {
|
||||
dir = message.NewParentPath
|
||||
}
|
||||
key := util.NewFullPath(dir, message.NewEntry.Name)
|
||||
glog.V(4).Infof("creating %v", key)
|
||||
newEntry = filer.FromPbEntry(dir, message.NewEntry)
|
||||
}
|
||||
err := mc.AtomicUpdateEntryFromFiler(context.Background(), oldPath, newEntry)
|
||||
if err == nil {
|
||||
if message.NewEntry != nil || message.OldEntry != nil {
|
||||
dirsToNotify := make(map[util.FullPath]struct{})
|
||||
if oldPath != "" {
|
||||
parent, _ := oldPath.DirAndName()
|
||||
dirsToNotify[util.FullPath(parent)] = struct{}{}
|
||||
}
|
||||
if newEntry != nil {
|
||||
newParent, _ := newEntry.DirAndName()
|
||||
dirsToNotify[util.FullPath(newParent)] = struct{}{}
|
||||
}
|
||||
if message.NewEntry != nil && message.NewEntry.IsDirectory {
|
||||
childPath := util.NewFullPath(dir, message.NewEntry.Name)
|
||||
dirsToNotify[childPath] = struct{}{}
|
||||
}
|
||||
for dirPath := range dirsToNotify {
|
||||
mc.noteDirectoryUpdate(dirPath)
|
||||
}
|
||||
}
|
||||
if message.OldEntry != nil && message.NewEntry != nil {
|
||||
oldKey := util.NewFullPath(resp.Directory, message.OldEntry.Name)
|
||||
mc.invalidateFunc(oldKey, message.OldEntry)
|
||||
if message.OldEntry.Name != message.NewEntry.Name {
|
||||
newKey := util.NewFullPath(dir, message.NewEntry.Name)
|
||||
mc.invalidateFunc(newKey, message.NewEntry)
|
||||
}
|
||||
} else if filer_pb.IsCreate(resp) {
|
||||
// no need to invalidate
|
||||
} else if filer_pb.IsDelete(resp) {
|
||||
oldKey := util.NewFullPath(resp.Directory, message.OldEntry.Name)
|
||||
mc.invalidateFunc(oldKey, message.OldEntry)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
|
||||
// Let all events (including self-originated ones) flow through the
|
||||
// applier so that the directory-build buffering and dedup logic
|
||||
// can handle them consistently. The dedupRing in
|
||||
// applyMetadataResponseNow catches duplicates that were already
|
||||
// applied locally via applyLocalMetadataEvent.
|
||||
return mc.ApplyMetadataResponse(context.Background(), resp, SubscriberMetadataResponseApplyOptions)
|
||||
}
|
||||
|
||||
prefix := dir
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package mount
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/mount/meta_cache"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func (wfs *WFS) applyLocalMetadataEvent(ctx context.Context, event *filer_pb.SubscribeMetadataResponse) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return wfs.metaCache.ApplyMetadataResponseOwned(ctx, event, meta_cache.LocalMetadataResponseApplyOptions)
|
||||
}
|
||||
|
||||
func metadataDeleteEvent(directory, name string, isDirectory bool) *filer_pb.SubscribeMetadataResponse {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: directory,
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: name, IsDirectory: isDirectory},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func metadataCreateEvent(directory string, entry *filer_pb.Entry) *filer_pb.SubscribeMetadataResponse {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: directory,
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
NewEntry: proto.Clone(entry).(*filer_pb.Entry),
|
||||
NewParentPath: directory,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func metadataUpdateEvent(directory string, entry *filer_pb.Entry) *filer_pb.SubscribeMetadataResponse {
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: directory,
|
||||
EventNotification: &filer_pb.EventNotification{
|
||||
OldEntry: &filer_pb.Entry{Name: entry.Name},
|
||||
NewEntry: proto.Clone(entry).(*filer_pb.Entry),
|
||||
NewParentPath: directory,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func metadataEventFromRenameResponse(resp *filer_pb.StreamRenameEntryResponse) *filer_pb.SubscribeMetadataResponse {
|
||||
if resp == nil || resp.EventNotification == nil {
|
||||
return nil
|
||||
}
|
||||
return &filer_pb.SubscribeMetadataResponse{
|
||||
Directory: resp.Directory,
|
||||
EventNotification: proto.Clone(resp.EventNotification).(*filer_pb.EventNotification),
|
||||
TsNs: resp.TsNs,
|
||||
}
|
||||
}
|
||||
+32
-44
@@ -200,7 +200,7 @@ func NewSeaweedFileSystem(option *Option) *WFS {
|
||||
}
|
||||
}, func(dirPath util.FullPath) {
|
||||
if wfs.inodeToPath.RecordDirectoryUpdate(dirPath, time.Now(), wfs.dirHotWindow, wfs.dirHotThreshold) {
|
||||
wfs.maybeRefreshDirectory(dirPath)
|
||||
wfs.markDirectoryReadThrough(dirPath)
|
||||
}
|
||||
})
|
||||
grace.OnInterrupt(func() {
|
||||
@@ -313,36 +313,42 @@ func (wfs *WFS) maybeLoadEntry(fullpath util.FullPath) (*filer_pb.Entry, fuse.St
|
||||
}
|
||||
|
||||
// lookupEntry looks up an entry by path, checking the local cache first.
|
||||
// If the directory is cached, it trusts the cache. Otherwise, it fetches
|
||||
// directly from the filer without caching the entire directory.
|
||||
// This avoids the performance issue of listing millions of files just to open one.
|
||||
// Cached metadata is only authoritative when the parent directory itself is cached.
|
||||
// For uncached/read-through directories, always consult the filer directly so stale
|
||||
// local entries do not leak back into lookup results.
|
||||
func (wfs *WFS) lookupEntry(fullpath util.FullPath) (*filer.Entry, fuse.Status) {
|
||||
dir, _ := fullpath.DirAndName()
|
||||
dirPath := util.FullPath(dir)
|
||||
|
||||
// Try to find the entry in the local cache first.
|
||||
cachedEntry, cacheErr := wfs.metaCache.FindEntry(context.Background(), fullpath)
|
||||
if cacheErr != nil && cacheErr != filer_pb.ErrNotFound {
|
||||
glog.Errorf("lookupEntry: cache lookup for %s failed: %v", fullpath, cacheErr)
|
||||
return nil, fuse.EIO
|
||||
}
|
||||
if cachedEntry != nil {
|
||||
glog.V(4).Infof("lookupEntry cache hit %s", fullpath)
|
||||
return cachedEntry, fuse.OK
|
||||
}
|
||||
|
||||
// If the directory is cached but entry not found, file doesn't exist.
|
||||
// No need to query the filer again.
|
||||
if wfs.metaCache.IsDirectoryCached(util.FullPath(dir)) {
|
||||
glog.V(4).Infof("lookupEntry cache miss (dir cached) %s", fullpath)
|
||||
return nil, fuse.ENOENT
|
||||
if wfs.metaCache.IsDirectoryCached(dirPath) {
|
||||
cachedEntry, cacheErr := wfs.metaCache.FindEntry(context.Background(), fullpath)
|
||||
if cacheErr != nil && cacheErr != filer_pb.ErrNotFound {
|
||||
glog.Errorf("lookupEntry: cache lookup for %s failed: %v", fullpath, cacheErr)
|
||||
return nil, fuse.EIO
|
||||
}
|
||||
if cachedEntry != nil {
|
||||
glog.V(4).Infof("lookupEntry cache hit %s", fullpath)
|
||||
return cachedEntry, fuse.OK
|
||||
}
|
||||
// Re-check: the directory may have been evicted from cache between
|
||||
// our IsDirectoryCached check and FindEntry (e.g. markDirectoryReadThrough).
|
||||
// If it's no longer cached, fall through to the filer lookup below.
|
||||
if wfs.metaCache.IsDirectoryCached(dirPath) {
|
||||
glog.V(4).Infof("lookupEntry cache miss (dir cached) %s", fullpath)
|
||||
return nil, fuse.ENOENT
|
||||
}
|
||||
}
|
||||
|
||||
// Directory not cached - fetch directly from filer without caching the entire directory.
|
||||
glog.V(4).Infof("lookupEntry fetching from filer %s", fullpath)
|
||||
entry, err := filer_pb.GetEntry(context.Background(), wfs, fullpath)
|
||||
if err != nil {
|
||||
glog.V(1).Infof("lookupEntry GetEntry %s: %v", fullpath, err)
|
||||
return nil, fuse.ENOENT
|
||||
if err == filer_pb.ErrNotFound {
|
||||
glog.V(4).Infof("lookupEntry not found %s", fullpath)
|
||||
return nil, fuse.ENOENT
|
||||
}
|
||||
glog.Warningf("lookupEntry GetEntry %s: %v", fullpath, err)
|
||||
return nil, fuse.EIO
|
||||
}
|
||||
if entry != nil && entry.Attributes != nil && wfs.option.UidGidMapper != nil {
|
||||
entry.Attributes.Uid, entry.Attributes.Gid = wfs.option.UidGidMapper.FilerToLocal(entry.Attributes.Uid, entry.Attributes.Gid)
|
||||
@@ -371,31 +377,13 @@ func (wfs *WFS) ClearCacheDir() {
|
||||
os.RemoveAll(wfs.option.getUniqueCacheDirForRead())
|
||||
}
|
||||
|
||||
func (wfs *WFS) maybeRefreshDirectory(dirPath util.FullPath) {
|
||||
if !wfs.inodeToPath.NeedsRefresh(dirPath) {
|
||||
func (wfs *WFS) markDirectoryReadThrough(dirPath util.FullPath) {
|
||||
if !wfs.inodeToPath.MarkDirectoryReadThrough(dirPath, time.Now()) {
|
||||
return
|
||||
}
|
||||
wfs.refreshMu.Lock()
|
||||
if _, exists := wfs.refreshingDirs[dirPath]; exists {
|
||||
wfs.refreshMu.Unlock()
|
||||
return
|
||||
if err := wfs.metaCache.DeleteFolderChildren(context.Background(), dirPath); err != nil {
|
||||
glog.V(2).Infof("clear dir cache %s: %v", dirPath, err)
|
||||
}
|
||||
wfs.refreshingDirs[dirPath] = struct{}{}
|
||||
wfs.refreshMu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
wfs.refreshMu.Lock()
|
||||
delete(wfs.refreshingDirs, dirPath)
|
||||
wfs.refreshMu.Unlock()
|
||||
}()
|
||||
wfs.inodeToPath.InvalidateChildrenCache(dirPath)
|
||||
if err := meta_cache.EnsureVisited(wfs.metaCache, wfs, dirPath); err != nil {
|
||||
glog.Warningf("refresh dir cache %s: %v", dirPath, err)
|
||||
return
|
||||
}
|
||||
wfs.inodeToPath.MarkDirectoryRefreshed(dirPath, time.Now())
|
||||
}()
|
||||
}
|
||||
|
||||
func (wfs *WFS) loopEvictIdleDirCache() {
|
||||
|
||||
@@ -2,7 +2,6 @@ package mount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -63,19 +62,21 @@ func (wfs *WFS) Mkdir(cancel <-chan struct{}, in *fuse.MkdirIn, name string, out
|
||||
}
|
||||
|
||||
glog.V(1).Infof("mkdir: %v", request)
|
||||
if err := filer_pb.CreateEntry(context.Background(), client, request); err != nil {
|
||||
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
|
||||
if err != nil {
|
||||
glog.V(0).Infof("mkdir %s: %v", entryFullPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Only cache the entry if the parent directory is already cached.
|
||||
// This avoids polluting the cache with partial directory data.
|
||||
if wfs.metaCache.IsDirectoryCached(dirFullPath) {
|
||||
wfs.inodeToPath.TouchDirectory(dirFullPath)
|
||||
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry(request.Directory, request.Entry)); err != nil {
|
||||
return fmt.Errorf("local mkdir dir %s: %w", entryFullPath, err)
|
||||
}
|
||||
event := resp.GetMetadataEvent()
|
||||
if event == nil {
|
||||
event = metadataCreateEvent(string(dirFullPath), newEntry)
|
||||
}
|
||||
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
|
||||
glog.Warningf("mkdir %s: best-effort metadata apply failed: %v", entryFullPath, applyErr)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(dirFullPath)
|
||||
}
|
||||
wfs.inodeToPath.TouchDirectory(dirFullPath)
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -112,7 +113,7 @@ func (wfs *WFS) Rmdir(cancel <-chan struct{}, header *fuse.InHeader, name string
|
||||
|
||||
glog.V(3).Infof("remove directory: %v", entryFullPath)
|
||||
ignoreRecursiveErr := true // ignore recursion error since the OS should manage it
|
||||
err := filer_pb.Remove(context.Background(), wfs, string(dirFullPath), name, true, false, ignoreRecursiveErr, false, []int32{wfs.signature})
|
||||
resp, err := filer_pb.RemoveWithResponse(context.Background(), wfs, string(dirFullPath), name, true, false, ignoreRecursiveErr, false, []int32{wfs.signature})
|
||||
if err != nil {
|
||||
glog.V(0).Infof("remove %s: %v", entryFullPath, err)
|
||||
if strings.Contains(err.Error(), filer.MsgFailDelNonEmptyFolder) {
|
||||
@@ -121,7 +122,14 @@ func (wfs *WFS) Rmdir(cancel <-chan struct{}, header *fuse.InHeader, name string
|
||||
return fuse.ENOENT
|
||||
}
|
||||
|
||||
wfs.metaCache.DeleteEntry(context.Background(), entryFullPath)
|
||||
event := metadataDeleteEvent(string(dirFullPath), name, true)
|
||||
if resp != nil && resp.MetadataEvent != nil {
|
||||
event = resp.MetadataEvent
|
||||
}
|
||||
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
|
||||
glog.Warningf("rmdir %s: best-effort metadata apply failed: %v", entryFullPath, applyErr)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(dirFullPath)
|
||||
}
|
||||
wfs.inodeToPath.RemovePath(entryFullPath)
|
||||
wfs.inodeToPath.TouchDirectory(dirFullPath)
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ package mount
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/go-fuse/v2/fuse"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/mount/meta_cache"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
@@ -26,10 +28,12 @@ type DirectoryHandle struct {
|
||||
isFinished bool
|
||||
entryStream []*filer.Entry
|
||||
entryStreamOffset uint64
|
||||
snapshotTsNs int64 // snapshot timestamp for consistent readdir in direct mode
|
||||
}
|
||||
|
||||
func (dh *DirectoryHandle) reset() {
|
||||
dh.isFinished = false
|
||||
dh.snapshotTsNs = 0
|
||||
// Nil out pointers to allow garbage collection of old entries,
|
||||
// then reuse the slice's capacity to avoid re-allocations.
|
||||
for i := range dh.entryStream {
|
||||
@@ -164,7 +168,6 @@ func (wfs *WFS) doReadDirectory(input *fuse.ReadIn, out *fuse.DirEntryList, isPl
|
||||
return code
|
||||
}
|
||||
wfs.inodeToPath.TouchDirectory(dirPath)
|
||||
wfs.maybeRefreshDirectory(dirPath)
|
||||
|
||||
var dirEntry fuse.DirEntry
|
||||
|
||||
@@ -214,6 +217,10 @@ func (wfs *WFS) doReadDirectory(input *fuse.ReadIn, out *fuse.DirEntryList, isPl
|
||||
|
||||
var lastEntryName string
|
||||
|
||||
if wfs.inodeToPath.ShouldReadDirectoryDirect(dirPath) {
|
||||
return wfs.readDirectoryDirect(input, out, dh, dirPath, processEachEntryFn)
|
||||
}
|
||||
|
||||
// Read from cache first, then load next batch if needed
|
||||
if input.Offset >= dh.entryStreamOffset {
|
||||
// Handle case: new handle with non-zero offset but empty cache
|
||||
@@ -288,3 +295,90 @@ func (wfs *WFS) doReadDirectory(input *fuse.ReadIn, out *fuse.DirEntryList, isPl
|
||||
|
||||
return fuse.OK
|
||||
}
|
||||
|
||||
func (wfs *WFS) readDirectoryDirect(input *fuse.ReadIn, out *fuse.DirEntryList, dh *DirectoryHandle, dirPath util.FullPath, processEachEntryFn func(entry *filer.Entry, index int64) bool) fuse.Status {
|
||||
var lastEntryName string
|
||||
|
||||
if input.Offset >= dh.entryStreamOffset {
|
||||
if len(dh.entryStream) == 0 && input.Offset > dh.entryStreamOffset {
|
||||
skipCount := uint32(input.Offset-dh.entryStreamOffset) + batchSize
|
||||
entries, snapshotTs, err := loadDirectoryEntriesDirect(context.Background(), wfs, wfs.option.UidGidMapper, dirPath, "", false, skipCount, dh.snapshotTsNs)
|
||||
if err != nil {
|
||||
glog.Errorf("list filer directory: %v", err)
|
||||
return fuse.EIO
|
||||
}
|
||||
dh.entryStream = append(dh.entryStream, entries...)
|
||||
if dh.snapshotTsNs == 0 {
|
||||
dh.snapshotTsNs = snapshotTs
|
||||
}
|
||||
}
|
||||
|
||||
if input.Offset > dh.entryStreamOffset {
|
||||
entryPreviousIndex := (input.Offset - dh.entryStreamOffset) - 1
|
||||
if uint64(len(dh.entryStream)) > entryPreviousIndex {
|
||||
lastEntryName = dh.entryStream[entryPreviousIndex].Name()
|
||||
}
|
||||
}
|
||||
|
||||
entryCurrentIndex := int64(input.Offset - dh.entryStreamOffset)
|
||||
for int64(len(dh.entryStream)) > entryCurrentIndex {
|
||||
entry := dh.entryStream[entryCurrentIndex]
|
||||
if processEachEntryFn(entry, entryCurrentIndex) {
|
||||
lastEntryName = entry.Name()
|
||||
entryCurrentIndex++
|
||||
} else {
|
||||
return fuse.OK
|
||||
}
|
||||
}
|
||||
|
||||
entries, snapshotTs, err := loadDirectoryEntriesDirect(context.Background(), wfs, wfs.option.UidGidMapper, dirPath, lastEntryName, false, batchSize, dh.snapshotTsNs)
|
||||
if err != nil {
|
||||
glog.Errorf("list filer directory: %v", err)
|
||||
return fuse.EIO
|
||||
}
|
||||
if dh.snapshotTsNs == 0 {
|
||||
dh.snapshotTsNs = snapshotTs
|
||||
}
|
||||
|
||||
bufferFull := false
|
||||
for _, entry := range entries {
|
||||
currentIndex := int64(len(dh.entryStream))
|
||||
dh.entryStream = append(dh.entryStream, entry)
|
||||
if !processEachEntryFn(entry, currentIndex) {
|
||||
bufferFull = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !bufferFull && len(entries) < int(batchSize) {
|
||||
dh.isFinished = true
|
||||
// After a full successful read-through listing, exit direct mode
|
||||
// so subsequent reads can use the cache instead of hitting the filer.
|
||||
wfs.inodeToPath.MarkDirectoryRefreshed(dirPath, time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
return fuse.OK
|
||||
}
|
||||
|
||||
func loadDirectoryEntriesDirect(ctx context.Context, client filer_pb.FilerClient, uidGidMapper *meta_cache.UidGidMapper, dirPath util.FullPath, startFileName string, includeStart bool, limit uint32, snapshotTsNs int64) ([]*filer.Entry, int64, error) {
|
||||
entries := make([]*filer.Entry, 0, limit)
|
||||
var actualSnapshotTsNs int64
|
||||
err := client.WithFilerClient(false, func(sc filer_pb.SeaweedFilerClient) error {
|
||||
var innerErr error
|
||||
actualSnapshotTsNs, innerErr = filer_pb.DoSeaweedListWithSnapshot(ctx, sc, dirPath, "", func(entry *filer_pb.Entry, isLast bool) error {
|
||||
if meta_cache.IsHiddenSystemEntry(string(dirPath), entry.Name) {
|
||||
return nil
|
||||
}
|
||||
if uidGidMapper != nil && entry.Attributes != nil {
|
||||
entry.Attributes.Uid, entry.Attributes.Gid = uidGidMapper.FilerToLocal(entry.Attributes.Uid, entry.Attributes.Gid)
|
||||
}
|
||||
entries = append(entries, filer.FromPbEntry(string(dirPath), entry))
|
||||
return nil
|
||||
}, startFileName, includeStart, limit, snapshotTsNs)
|
||||
return innerErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, actualSnapshotTsNs, err
|
||||
}
|
||||
return entries, actualSnapshotTsNs, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package mount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/mount/meta_cache"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type directoryListStream struct {
|
||||
responses []*filer_pb.ListEntriesResponse
|
||||
index int
|
||||
}
|
||||
|
||||
func (s *directoryListStream) Recv() (*filer_pb.ListEntriesResponse, error) {
|
||||
if s.index >= len(s.responses) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
resp := s.responses[s.index]
|
||||
s.index++
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *directoryListStream) Header() (metadata.MD, error) { return metadata.MD{}, nil }
|
||||
func (s *directoryListStream) Trailer() metadata.MD { return metadata.MD{} }
|
||||
func (s *directoryListStream) CloseSend() error { return nil }
|
||||
func (s *directoryListStream) Context() context.Context { return context.Background() }
|
||||
func (s *directoryListStream) SendMsg(any) error { return nil }
|
||||
func (s *directoryListStream) RecvMsg(any) error { return nil }
|
||||
|
||||
type directoryListClient struct {
|
||||
filer_pb.SeaweedFilerClient
|
||||
responses []*filer_pb.ListEntriesResponse
|
||||
}
|
||||
|
||||
func (c *directoryListClient) ListEntries(ctx context.Context, in *filer_pb.ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.ListEntriesResponse], error) {
|
||||
return &directoryListStream{responses: c.responses}, nil
|
||||
}
|
||||
|
||||
type directoryFilerAccessor struct {
|
||||
client filer_pb.SeaweedFilerClient
|
||||
}
|
||||
|
||||
func (a *directoryFilerAccessor) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(a.client)
|
||||
}
|
||||
|
||||
func (a *directoryFilerAccessor) AdjustedUrl(*filer_pb.Location) string { return "" }
|
||||
func (a *directoryFilerAccessor) GetDataCenter() string { return "" }
|
||||
|
||||
func TestLoadDirectoryEntriesDirectFiltersHiddenEntriesAndMapsIds(t *testing.T) {
|
||||
mapper, err := meta_cache.NewUidGidMapper("10:1000", "20:2000")
|
||||
if err != nil {
|
||||
t.Fatalf("uid/gid mapper: %v", err)
|
||||
}
|
||||
|
||||
client := &directoryFilerAccessor{
|
||||
client: &directoryListClient{
|
||||
responses: []*filer_pb.ListEntriesResponse{
|
||||
{
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: "topics",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Uid: 1000,
|
||||
Gid: 2000,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: "visible",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Uid: 1000,
|
||||
Gid: 2000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
entries, _, err := loadDirectoryEntriesDirect(context.Background(), client, mapper, util.FullPath("/"), "", false, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("loadDirectoryEntriesDirect: %v", err)
|
||||
}
|
||||
if got := len(entries); got != 1 {
|
||||
t.Fatalf("entry count = %d, want 1", got)
|
||||
}
|
||||
if entries[0].Name() != "visible" {
|
||||
t.Fatalf("entry name = %q, want visible", entries[0].Name())
|
||||
}
|
||||
if entries[0].Attr.Uid != 10 || entries[0].Attr.Gid != 20 {
|
||||
t.Fatalf("mapped uid/gid = %d/%d, want 10/20", entries[0].Attr.Uid, entries[0].Attr.Gid)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,10 @@ package mount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/go-fuse/v2/fuse"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
@@ -83,19 +81,21 @@ func (wfs *WFS) Mknod(cancel <-chan struct{}, in *fuse.MknodIn, name string, out
|
||||
}
|
||||
|
||||
glog.V(1).Infof("mknod: %v", request)
|
||||
if err := filer_pb.CreateEntry(context.Background(), client, request); err != nil {
|
||||
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
|
||||
if err != nil {
|
||||
glog.V(0).Infof("mknod %s: %v", entryFullPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Only cache the entry if the parent directory is already cached.
|
||||
// This avoids polluting the cache with partial directory data.
|
||||
if wfs.metaCache.IsDirectoryCached(dirFullPath) {
|
||||
wfs.inodeToPath.TouchDirectory(dirFullPath)
|
||||
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry(request.Directory, request.Entry)); err != nil {
|
||||
return fmt.Errorf("local mknod %s: %w", entryFullPath, err)
|
||||
}
|
||||
event := resp.GetMetadataEvent()
|
||||
if event == nil {
|
||||
event = metadataCreateEvent(string(dirFullPath), newEntry)
|
||||
}
|
||||
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
|
||||
glog.Warningf("mknod %s: best-effort metadata apply failed: %v", entryFullPath, applyErr)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(dirFullPath)
|
||||
}
|
||||
wfs.inodeToPath.TouchDirectory(dirFullPath)
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -143,16 +143,21 @@ func (wfs *WFS) Unlink(cancel <-chan struct{}, header *fuse.InHeader, name strin
|
||||
glog.V(3).Infof("remove file: %v", entryFullPath)
|
||||
// Always let the filer decide whether to delete chunks based on its authoritative data.
|
||||
// The filer has the correct hard link count and will only delete chunks when appropriate.
|
||||
err := filer_pb.Remove(context.Background(), wfs, string(dirFullPath), name, true, false, false, false, []int32{wfs.signature})
|
||||
resp, err := filer_pb.RemoveWithResponse(context.Background(), wfs, string(dirFullPath), name, true, false, false, false, []int32{wfs.signature})
|
||||
if err != nil {
|
||||
glog.V(0).Infof("remove %s: %v", entryFullPath, err)
|
||||
return fuse.OK
|
||||
}
|
||||
|
||||
// then, delete meta cache
|
||||
if err = wfs.metaCache.DeleteEntry(context.Background(), entryFullPath); err != nil {
|
||||
glog.V(3).Infof("local DeleteEntry %s: %v", entryFullPath, err)
|
||||
return fuse.EIO
|
||||
var event *filer_pb.SubscribeMetadataResponse
|
||||
if resp != nil && resp.MetadataEvent != nil {
|
||||
event = resp.MetadataEvent
|
||||
} else {
|
||||
event = metadataDeleteEvent(string(dirFullPath), name, false)
|
||||
}
|
||||
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
|
||||
glog.Warningf("unlink %s: best-effort metadata apply failed: %v", entryFullPath, applyErr)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(dirFullPath)
|
||||
}
|
||||
wfs.inodeToPath.TouchDirectory(dirFullPath)
|
||||
|
||||
|
||||
@@ -161,16 +161,19 @@ func (wfs *WFS) doFlush(fh *FileHandle, uid, gid uint32) fuse.Status {
|
||||
wfs.mapPbIdFromLocalToFiler(request.Entry)
|
||||
defer wfs.mapPbIdFromFilerToLocal(request.Entry)
|
||||
|
||||
if err := filer_pb.CreateEntry(context.Background(), client, request); err != nil {
|
||||
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
|
||||
if err != nil {
|
||||
glog.Errorf("fh flush create %s: %v", fileFullPath, err)
|
||||
return fmt.Errorf("fh flush create %s: %v", fileFullPath, err)
|
||||
}
|
||||
|
||||
// Only update cache if the parent directory is cached
|
||||
if wfs.metaCache.IsDirectoryCached(util.FullPath(dir)) {
|
||||
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry(request.Directory, request.Entry)); err != nil {
|
||||
return fmt.Errorf("update meta cache for %s: %w", fileFullPath, err)
|
||||
}
|
||||
event := resp.GetMetadataEvent()
|
||||
if event == nil {
|
||||
event = metadataUpdateEvent(string(dir), request.Entry)
|
||||
}
|
||||
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
|
||||
glog.Warningf("flush %s: best-effort metadata apply failed: %v", fileFullPath, applyErr)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(dir))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+31
-13
@@ -2,7 +2,6 @@ package mount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -56,6 +55,8 @@ func (wfs *WFS) Link(cancel <-chan struct{}, in *fuse.LinkIn, name string, out *
|
||||
}
|
||||
|
||||
// update old file to hardlink mode
|
||||
origHardLinkId := oldEntry.HardLinkId
|
||||
origHardLinkCounter := oldEntry.HardLinkCounter
|
||||
if len(oldEntry.HardLinkId) == 0 {
|
||||
oldEntry.HardLinkId = filer.NewHardLinkId()
|
||||
oldEntry.HardLinkCounter = 1
|
||||
@@ -90,25 +91,42 @@ func (wfs *WFS) Link(cancel <-chan struct{}, in *fuse.LinkIn, name string, out *
|
||||
wfs.mapPbIdFromLocalToFiler(request.Entry)
|
||||
defer wfs.mapPbIdFromFilerToLocal(request.Entry)
|
||||
|
||||
if err := filer_pb.UpdateEntry(context.Background(), client, updateOldEntryRequest); err != nil {
|
||||
updateResp, err := filer_pb.UpdateEntryWithResponse(context.Background(), client, updateOldEntryRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only update cache if the directory is cached
|
||||
if wfs.metaCache.IsDirectoryCached(util.FullPath(updateOldEntryRequest.Directory)) {
|
||||
if err := wfs.metaCache.UpdateEntry(context.Background(), filer.FromPbEntry(updateOldEntryRequest.Directory, updateOldEntryRequest.Entry)); err != nil {
|
||||
return fmt.Errorf("update meta cache for %s: %w", oldEntryPath, err)
|
||||
}
|
||||
updateEvent := updateResp.GetMetadataEvent()
|
||||
if updateEvent == nil {
|
||||
updateEvent = metadataUpdateEvent(oldParentPath, updateOldEntryRequest.Entry)
|
||||
}
|
||||
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), updateEvent); applyErr != nil {
|
||||
glog.Warningf("link %s: best-effort metadata apply failed: %v", oldEntryPath, applyErr)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(oldParentPath))
|
||||
}
|
||||
|
||||
if err := filer_pb.CreateEntry(context.Background(), client, request); err != nil {
|
||||
createResp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
|
||||
if err != nil {
|
||||
// Rollback: restore original HardLinkId/Counter on the source entry
|
||||
oldEntry.HardLinkId = origHardLinkId
|
||||
oldEntry.HardLinkCounter = origHardLinkCounter
|
||||
rollbackReq := &filer_pb.UpdateEntryRequest{
|
||||
Directory: oldParentPath,
|
||||
Entry: oldEntry,
|
||||
Signatures: []int32{wfs.signature},
|
||||
}
|
||||
if _, rollbackErr := filer_pb.UpdateEntryWithResponse(context.Background(), client, rollbackReq); rollbackErr != nil {
|
||||
glog.Warningf("link rollback %s: %v", oldEntryPath, rollbackErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Only cache the entry if the parent directory is already cached.
|
||||
if wfs.metaCache.IsDirectoryCached(newParentPath) {
|
||||
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry(request.Directory, request.Entry)); err != nil {
|
||||
return fmt.Errorf("insert meta cache for %s: %w", newParentPath.Child(name), err)
|
||||
}
|
||||
createEvent := createResp.GetMetadataEvent()
|
||||
if createEvent == nil {
|
||||
createEvent = metadataCreateEvent(string(newParentPath), request.Entry)
|
||||
}
|
||||
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), createEvent); applyErr != nil {
|
||||
glog.Warningf("link %s: best-effort metadata apply failed: %v", newParentPath.Child(name), applyErr)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(newParentPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -2,7 +2,6 @@ package mount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -142,15 +141,18 @@ func (wfs *WFS) flushFileMetadata(fh *FileHandle) error {
|
||||
wfs.mapPbIdFromLocalToFiler(request.Entry)
|
||||
defer wfs.mapPbIdFromFilerToLocal(request.Entry)
|
||||
|
||||
if err := filer_pb.CreateEntry(context.Background(), client, request); err != nil {
|
||||
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only update cache if the parent directory is cached
|
||||
if wfs.metaCache.IsDirectoryCached(util.FullPath(dir)) {
|
||||
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry(request.Directory, request.Entry)); err != nil {
|
||||
return fmt.Errorf("update meta cache for %s: %w", fileFullPath, err)
|
||||
}
|
||||
event := resp.GetMetadataEvent()
|
||||
if event == nil {
|
||||
event = metadataUpdateEvent(string(dir), request.Entry)
|
||||
}
|
||||
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
|
||||
glog.Warningf("flushFileMetadata %s: best-effort metadata apply failed: %v", fileFullPath, applyErr)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(dir))
|
||||
}
|
||||
|
||||
glog.V(3).Infof("flushed metadata for %s with %d chunks", fileFullPath, len(entry.GetChunks()))
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/seaweedfs/go-fuse/v2/fs"
|
||||
"github.com/seaweedfs/go-fuse/v2/fuse"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
@@ -233,10 +232,12 @@ func (wfs *WFS) handleRenameResponse(ctx context.Context, resp *filer_pb.StreamR
|
||||
glog.V(4).Infof("dir Rename %+v", resp.EventNotification)
|
||||
|
||||
if resp.EventNotification.NewEntry != nil {
|
||||
// with new entry, the old entry name also exists. This is the first step to create new entry
|
||||
newEntry := filer.FromPbEntry(resp.EventNotification.NewParentPath, resp.EventNotification.NewEntry)
|
||||
if err := wfs.metaCache.AtomicUpdateEntryFromFiler(ctx, "", newEntry); err != nil {
|
||||
return err
|
||||
if err := wfs.applyLocalMetadataEvent(ctx, metadataEventFromRenameResponse(resp)); err != nil {
|
||||
glog.Warningf("rename apply metadata event: %v", err)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(resp.Directory))
|
||||
if resp.EventNotification.NewParentPath != "" {
|
||||
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(resp.EventNotification.NewParentPath))
|
||||
}
|
||||
}
|
||||
|
||||
oldParent, newParent := util.FullPath(resp.Directory), util.FullPath(resp.EventNotification.NewParentPath)
|
||||
@@ -245,14 +246,6 @@ func (wfs *WFS) handleRenameResponse(ctx context.Context, resp *filer_pb.StreamR
|
||||
oldPath := oldParent.Child(oldName)
|
||||
newPath := newParent.Child(newName)
|
||||
|
||||
// Keep the renamed destination immediately readable even when the directory
|
||||
// itself is not marked as fully cached.
|
||||
if !wfs.metaCache.IsDirectoryCached(newParent) {
|
||||
if err := wfs.metaCache.InsertEntry(ctx, newEntry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
sourceInode, targetInode := wfs.inodeToPath.MovePath(oldPath, newPath)
|
||||
if sourceInode != 0 {
|
||||
fh, foundFh := wfs.fhMap.FindFileHandle(sourceInode)
|
||||
@@ -271,8 +264,9 @@ func (wfs *WFS) handleRenameResponse(ctx context.Context, resp *filer_pb.StreamR
|
||||
|
||||
} else if resp.EventNotification.OldEntry != nil {
|
||||
// without new entry, only old entry name exists. This is the second step to delete old entry
|
||||
if err := wfs.metaCache.AtomicUpdateEntryFromFiler(ctx, util.NewFullPath(resp.Directory, resp.EventNotification.OldEntry.Name), nil); err != nil {
|
||||
return err
|
||||
if err := wfs.applyLocalMetadataEvent(ctx, metadataEventFromRenameResponse(resp)); err != nil {
|
||||
glog.Warningf("rename apply delete event: %v", err)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(resp.Directory))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
func TestHandleRenameResponseCachesTargetForUncachedDirectory(t *testing.T) {
|
||||
func TestHandleRenameResponseLeavesUncachedTargetOutOfCache(t *testing.T) {
|
||||
uidGidMapper, err := meta_cache.NewUidGidMapper("", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create uid/gid mapper: %v", err)
|
||||
@@ -73,14 +73,11 @@ func TestHandleRenameResponseCachesTargetForUncachedDirectory(t *testing.T) {
|
||||
}
|
||||
|
||||
entry, findErr := mc.FindEntry(context.Background(), targetPath)
|
||||
if findErr != nil {
|
||||
t.Fatalf("find target entry: %v", findErr)
|
||||
if findErr != filer_pb.ErrNotFound {
|
||||
t.Fatalf("find target entry error = %v, want %v", findErr, filer_pb.ErrNotFound)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatalf("target entry %s not cached", targetPath)
|
||||
}
|
||||
if entry.FileSize != 53 {
|
||||
t.Fatalf("cached file size = %d, want 53", entry.FileSize)
|
||||
if entry != nil {
|
||||
t.Fatalf("target entry %s should not be cached for an uncached directory", targetPath)
|
||||
}
|
||||
|
||||
updatedInode, found := inodeToPath.GetInode(targetPath)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/seaweedfs/go-fuse/v2/fuse"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
@@ -53,15 +52,18 @@ func (wfs *WFS) Symlink(cancel <-chan struct{}, header *fuse.InHeader, target st
|
||||
wfs.mapPbIdFromLocalToFiler(request.Entry)
|
||||
defer wfs.mapPbIdFromFilerToLocal(request.Entry)
|
||||
|
||||
if err := filer_pb.CreateEntry(context.Background(), client, request); err != nil {
|
||||
resp, err := filer_pb.CreateEntryWithResponse(context.Background(), client, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("symlink %s: %v", entryFullPath, err)
|
||||
}
|
||||
|
||||
// Only cache the entry if the parent directory is already cached.
|
||||
if wfs.metaCache.IsDirectoryCached(dirPath) {
|
||||
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry(request.Directory, request.Entry)); err != nil {
|
||||
return fmt.Errorf("insert meta cache for symlink %s: %w", entryFullPath, err)
|
||||
}
|
||||
event := resp.GetMetadataEvent()
|
||||
if event == nil {
|
||||
event = metadataCreateEvent(string(dirPath), request.Entry)
|
||||
}
|
||||
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
|
||||
glog.Warningf("symlink %s: best-effort metadata apply failed: %v", entryFullPath, applyErr)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(dirPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/seaweedfs/go-fuse/v2/fuse"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
@@ -28,13 +27,18 @@ func (wfs *WFS) saveEntry(path util.FullPath, entry *filer_pb.Entry) (code fuse.
|
||||
}
|
||||
|
||||
glog.V(1).Infof("save entry: %v", request)
|
||||
_, err := client.UpdateEntry(context.Background(), request)
|
||||
resp, err := filer_pb.UpdateEntryWithResponse(context.Background(), client, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("UpdateEntry dir %s: %v", path, err)
|
||||
}
|
||||
|
||||
if err := wfs.metaCache.UpdateEntry(context.Background(), filer.FromPbEntry(request.Directory, request.Entry)); err != nil {
|
||||
return fmt.Errorf("metaCache.UpdateEntry dir %s: %w", path, err)
|
||||
event := resp.GetMetadataEvent()
|
||||
if event == nil {
|
||||
event = metadataUpdateEvent(parentDir, entry)
|
||||
}
|
||||
if applyErr := wfs.applyLocalMetadataEvent(context.Background(), event); applyErr != nil {
|
||||
glog.Warningf("saveEntry %s: best-effort metadata apply failed: %v", path, applyErr)
|
||||
wfs.inodeToPath.InvalidateChildrenCache(util.FullPath(parentDir))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -107,7 +107,7 @@ func (b *MessageQueueBroker) ListTopics(ctx context.Context, request *mq_pb.List
|
||||
return err
|
||||
}
|
||||
|
||||
if !resp.Entry.IsDirectory {
|
||||
if resp.Entry == nil || !resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func (b *MessageQueueBroker) ListTopics(ctx context.Context, request *mq_pb.List
|
||||
break
|
||||
}
|
||||
|
||||
if !topicResp.Entry.IsDirectory {
|
||||
if topicResp.Entry == nil || !topicResp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ func (b *MessageQueueBroker) getOffsetRangeFromChunkMetadata(t topic.Topic, part
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Entry.IsDirectory && strings.HasPrefix(resp.Entry.Name, "v") {
|
||||
if resp.Entry != nil && resp.Entry.IsDirectory && strings.HasPrefix(resp.Entry.Name, "v") {
|
||||
if latestVersion == "" || resp.Entry.Name > latestVersion {
|
||||
latestVersion = resp.Entry.Name
|
||||
}
|
||||
@@ -290,7 +290,7 @@ func (b *MessageQueueBroker) getOffsetRangeFromChunkMetadata(t topic.Topic, part
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Entry.IsDirectory && resp.Entry.Name == targetPartitionName {
|
||||
if resp.Entry != nil && resp.Entry.IsDirectory && resp.Entry.Name == targetPartitionName {
|
||||
partitionDir = resp.Entry.Name
|
||||
break
|
||||
}
|
||||
@@ -327,7 +327,7 @@ func (b *MessageQueueBroker) getOffsetRangeFromChunkMetadata(t topic.Topic, part
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !resp.Entry.IsDirectory && resp.Entry.Name != "checkpoint.offset" {
|
||||
if resp.Entry != nil && !resp.Entry.IsDirectory && resp.Entry.Name != "checkpoint.offset" {
|
||||
// Check for offset ranges in Extended attributes (both log files and parquet files)
|
||||
if resp.Entry.Extended != nil {
|
||||
fileType := "log"
|
||||
|
||||
@@ -285,7 +285,7 @@ func (f *FilerStorage) listDirectory(path string) ([]string, error) {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.Entry.IsDirectory {
|
||||
if resp.Entry != nil && resp.Entry.IsDirectory {
|
||||
entries = append(entries, resp.Entry.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,7 +678,7 @@ func (cr *CoordinatorRegistry) loadCoordinatorAssignmentWithClient(consumerGroup
|
||||
err := clientAccessor.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Load from individual file: /topics/kafka/.meta/coordinators/<consumer-group>_assignments.json
|
||||
fileName := fmt.Sprintf("%s_assignments.json", consumerGroup)
|
||||
data, err := filer.ReadInsideFiler(client, CoordinatorAssignmentsDir, fileName)
|
||||
data, err := filer.ReadInsideFiler(context.Background(), client, CoordinatorAssignmentsDir, fileName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("assignment file not found for group %s: %w", consumerGroup, err)
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ func (bc *BrokerClient) getOffsetRangeFromChunkMetadata(topic string, partition
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Entry.IsDirectory && strings.HasPrefix(resp.Entry.Name, "v") {
|
||||
if resp.Entry != nil && resp.Entry.IsDirectory && strings.HasPrefix(resp.Entry.Name, "v") {
|
||||
if latestVersion == "" || resp.Entry.Name > latestVersion {
|
||||
latestVersion = resp.Entry.Name
|
||||
}
|
||||
@@ -267,9 +267,15 @@ func (bc *BrokerClient) getOffsetRangeFromChunkMetadata(topic string, partition
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Entry.IsDirectory && strings.Contains(resp.Entry.Name, "-") {
|
||||
partitionDir = resp.Entry.Name
|
||||
break // Use the first partition directory we find
|
||||
if resp.Entry != nil && resp.Entry.IsDirectory && strings.Contains(resp.Entry.Name, "-") {
|
||||
// Parse partition range (format: NNNN-NNNN) and match requested partition
|
||||
var pStart, pStop int32
|
||||
if n, scanErr := fmt.Sscanf(resp.Entry.Name, "%04d-%04d", &pStart, &pStop); n == 2 && scanErr == nil {
|
||||
if partition >= pStart && partition < pStop {
|
||||
partitionDir = resp.Entry.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -303,7 +309,7 @@ func (bc *BrokerClient) getOffsetRangeFromChunkMetadata(topic string, partition
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !resp.Entry.IsDirectory && resp.Entry.Name != "checkpoint.offset" {
|
||||
if resp.Entry != nil && !resp.Entry.IsDirectory && resp.Entry.Name != "checkpoint.offset" {
|
||||
// Check for offset ranges in Extended attributes (both log files and parquet files)
|
||||
if resp.Entry.Extended != nil {
|
||||
// Track maximum offset for high water mark
|
||||
|
||||
@@ -108,7 +108,7 @@ func (f *FilerConsumerGroupOffsetStorage) LoadConsumerGroupPosition(t topic.Topi
|
||||
|
||||
var position *ConsumerGroupPosition
|
||||
err := f.filerClientAccessor.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := filer.ReadInsideFiler(client, consumersDir, offsetFileName)
|
||||
data, err := filer.ReadInsideFiler(context.Background(), client, consumersDir, offsetFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package offset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -48,7 +49,7 @@ func (f *FilerOffsetStorage) LoadCheckpoint(namespace, topicName string, partiti
|
||||
|
||||
var offset int64 = -1
|
||||
err := f.filerClientAccessor.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
data, err := filer.ReadInsideFiler(client, partitionDir, fileName)
|
||||
data, err := filer.ReadInsideFiler(context.Background(), client, partitionDir, fileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func (t Topic) Dir() string {
|
||||
}
|
||||
|
||||
func (t Topic) ReadConfFile(client filer_pb.SeaweedFilerClient) (*mq_pb.ConfigureTopicResponse, error) {
|
||||
data, err := filer.ReadInsideFiler(client, t.Dir(), filer.TopicConfFile)
|
||||
data, err := filer.ReadInsideFiler(context.Background(), client, t.Dir(), filer.TopicConfFile)
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -100,10 +100,12 @@ message ListEntriesRequest {
|
||||
string startFromFileName = 3;
|
||||
bool inclusiveStartFrom = 4;
|
||||
uint32 limit = 5;
|
||||
int64 snapshot_ts_ns = 6;
|
||||
}
|
||||
|
||||
message ListEntriesResponse {
|
||||
Entry entry = 1;
|
||||
int64 snapshot_ts_ns = 2;
|
||||
}
|
||||
|
||||
message RemoteEntry {
|
||||
@@ -203,6 +205,7 @@ message CreateEntryRequest {
|
||||
|
||||
message CreateEntryResponse {
|
||||
string error = 1;
|
||||
SubscribeMetadataResponse metadata_event = 2;
|
||||
}
|
||||
|
||||
message UpdateEntryRequest {
|
||||
@@ -212,6 +215,7 @@ message UpdateEntryRequest {
|
||||
repeated int32 signatures = 4;
|
||||
}
|
||||
message UpdateEntryResponse {
|
||||
SubscribeMetadataResponse metadata_event = 1;
|
||||
}
|
||||
|
||||
message AppendToEntryRequest {
|
||||
@@ -236,6 +240,7 @@ message DeleteEntryRequest {
|
||||
|
||||
message DeleteEntryResponse {
|
||||
string error = 1;
|
||||
SubscribeMetadataResponse metadata_event = 2;
|
||||
}
|
||||
|
||||
message AtomicRenameEntryRequest {
|
||||
@@ -469,6 +474,7 @@ message CacheRemoteObjectToLocalClusterRequest {
|
||||
}
|
||||
message CacheRemoteObjectToLocalClusterResponse {
|
||||
Entry entry = 1;
|
||||
SubscribeMetadataResponse metadata_event = 2;
|
||||
}
|
||||
|
||||
/////////////////////////
|
||||
|
||||
+145
-87
@@ -176,6 +176,7 @@ type ListEntriesRequest struct {
|
||||
StartFromFileName string `protobuf:"bytes,3,opt,name=startFromFileName,proto3" json:"startFromFileName,omitempty"`
|
||||
InclusiveStartFrom bool `protobuf:"varint,4,opt,name=inclusiveStartFrom,proto3" json:"inclusiveStartFrom,omitempty"`
|
||||
Limit uint32 `protobuf:"varint,5,opt,name=limit,proto3" json:"limit,omitempty"`
|
||||
SnapshotTsNs int64 `protobuf:"varint,6,opt,name=snapshot_ts_ns,json=snapshotTsNs,proto3" json:"snapshot_ts_ns,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -245,9 +246,17 @@ func (x *ListEntriesRequest) GetLimit() uint32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ListEntriesRequest) GetSnapshotTsNs() int64 {
|
||||
if x != nil {
|
||||
return x.SnapshotTsNs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ListEntriesResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"`
|
||||
SnapshotTsNs int64 `protobuf:"varint,2,opt,name=snapshot_ts_ns,json=snapshotTsNs,proto3" json:"snapshot_ts_ns,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -289,6 +298,13 @@ func (x *ListEntriesResponse) GetEntry() *Entry {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ListEntriesResponse) GetSnapshotTsNs() int64 {
|
||||
if x != nil {
|
||||
return x.SnapshotTsNs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type RemoteEntry struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
StorageName string `protobuf:"bytes,1,opt,name=storage_name,json=storageName,proto3" json:"storage_name,omitempty"`
|
||||
@@ -1102,8 +1118,9 @@ func (x *CreateEntryRequest) GetSkipCheckParentDirectory() bool {
|
||||
}
|
||||
|
||||
type CreateEntryResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
||||
MetadataEvent *SubscribeMetadataResponse `protobuf:"bytes,2,opt,name=metadata_event,json=metadataEvent,proto3" json:"metadata_event,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1145,6 +1162,13 @@ func (x *CreateEntryResponse) GetError() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CreateEntryResponse) GetMetadataEvent() *SubscribeMetadataResponse {
|
||||
if x != nil {
|
||||
return x.MetadataEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdateEntryRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Directory string `protobuf:"bytes,1,opt,name=directory,proto3" json:"directory,omitempty"`
|
||||
@@ -1214,7 +1238,8 @@ func (x *UpdateEntryRequest) GetSignatures() []int32 {
|
||||
}
|
||||
|
||||
type UpdateEntryResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
MetadataEvent *SubscribeMetadataResponse `protobuf:"bytes,1,opt,name=metadata_event,json=metadataEvent,proto3" json:"metadata_event,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1249,6 +1274,13 @@ func (*UpdateEntryResponse) Descriptor() ([]byte, []int) {
|
||||
return file_filer_proto_rawDescGZIP(), []int{15}
|
||||
}
|
||||
|
||||
func (x *UpdateEntryResponse) GetMetadataEvent() *SubscribeMetadataResponse {
|
||||
if x != nil {
|
||||
return x.MetadataEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AppendToEntryRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Directory string `protobuf:"bytes,1,opt,name=directory,proto3" json:"directory,omitempty"`
|
||||
@@ -1447,8 +1479,9 @@ func (x *DeleteEntryRequest) GetIfNotModifiedAfter() int64 {
|
||||
}
|
||||
|
||||
type DeleteEntryResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
||||
MetadataEvent *SubscribeMetadataResponse `protobuf:"bytes,2,opt,name=metadata_event,json=metadataEvent,proto3" json:"metadata_event,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1490,6 +1523,13 @@ func (x *DeleteEntryResponse) GetError() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DeleteEntryResponse) GetMetadataEvent() *SubscribeMetadataResponse {
|
||||
if x != nil {
|
||||
return x.MetadataEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AtomicRenameEntryRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
OldDirectory string `protobuf:"bytes,1,opt,name=old_directory,json=oldDirectory,proto3" json:"old_directory,omitempty"`
|
||||
@@ -3628,8 +3668,9 @@ func (x *CacheRemoteObjectToLocalClusterRequest) GetName() string {
|
||||
}
|
||||
|
||||
type CacheRemoteObjectToLocalClusterResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"`
|
||||
MetadataEvent *SubscribeMetadataResponse `protobuf:"bytes,2,opt,name=metadata_event,json=metadataEvent,proto3" json:"metadata_event,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -3671,6 +3712,13 @@ func (x *CacheRemoteObjectToLocalClusterResponse) GetEntry() *Entry {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *CacheRemoteObjectToLocalClusterResponse) GetMetadataEvent() *SubscribeMetadataResponse {
|
||||
if x != nil {
|
||||
return x.MetadataEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ///////////////////////
|
||||
// distributed lock management
|
||||
// ///////////////////////
|
||||
@@ -4401,15 +4449,17 @@ const file_filer_proto_rawDesc = "" +
|
||||
"\tdirectory\x18\x01 \x01(\tR\tdirectory\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\"E\n" +
|
||||
"\x1cLookupDirectoryEntryResponse\x12%\n" +
|
||||
"\x05entry\x18\x01 \x01(\v2\x0f.filer_pb.EntryR\x05entry\"\xbe\x01\n" +
|
||||
"\x05entry\x18\x01 \x01(\v2\x0f.filer_pb.EntryR\x05entry\"\xe4\x01\n" +
|
||||
"\x12ListEntriesRequest\x12\x1c\n" +
|
||||
"\tdirectory\x18\x01 \x01(\tR\tdirectory\x12\x16\n" +
|
||||
"\x06prefix\x18\x02 \x01(\tR\x06prefix\x12,\n" +
|
||||
"\x11startFromFileName\x18\x03 \x01(\tR\x11startFromFileName\x12.\n" +
|
||||
"\x12inclusiveStartFrom\x18\x04 \x01(\bR\x12inclusiveStartFrom\x12\x14\n" +
|
||||
"\x05limit\x18\x05 \x01(\rR\x05limit\"<\n" +
|
||||
"\x05limit\x18\x05 \x01(\rR\x05limit\x12$\n" +
|
||||
"\x0esnapshot_ts_ns\x18\x06 \x01(\x03R\fsnapshotTsNs\"b\n" +
|
||||
"\x13ListEntriesResponse\x12%\n" +
|
||||
"\x05entry\x18\x01 \x01(\v2\x0f.filer_pb.EntryR\x05entry\"\xc8\x01\n" +
|
||||
"\x05entry\x18\x01 \x01(\v2\x0f.filer_pb.EntryR\x05entry\x12$\n" +
|
||||
"\x0esnapshot_ts_ns\x18\x02 \x01(\x03R\fsnapshotTsNs\"\xc8\x01\n" +
|
||||
"\vRemoteEntry\x12!\n" +
|
||||
"\fstorage_name\x18\x01 \x01(\tR\vstorageName\x120\n" +
|
||||
"\x15last_local_sync_ts_ns\x18\x02 \x01(\x03R\x11lastLocalSyncTsNs\x12 \n" +
|
||||
@@ -4497,17 +4547,19 @@ const file_filer_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"signatures\x18\x05 \x03(\x05R\n" +
|
||||
"signatures\x12=\n" +
|
||||
"\x1bskip_check_parent_directory\x18\x06 \x01(\bR\x18skipCheckParentDirectory\"+\n" +
|
||||
"\x1bskip_check_parent_directory\x18\x06 \x01(\bR\x18skipCheckParentDirectory\"w\n" +
|
||||
"\x13CreateEntryResponse\x12\x14\n" +
|
||||
"\x05error\x18\x01 \x01(\tR\x05error\"\xac\x01\n" +
|
||||
"\x05error\x18\x01 \x01(\tR\x05error\x12J\n" +
|
||||
"\x0emetadata_event\x18\x02 \x01(\v2#.filer_pb.SubscribeMetadataResponseR\rmetadataEvent\"\xac\x01\n" +
|
||||
"\x12UpdateEntryRequest\x12\x1c\n" +
|
||||
"\tdirectory\x18\x01 \x01(\tR\tdirectory\x12%\n" +
|
||||
"\x05entry\x18\x02 \x01(\v2\x0f.filer_pb.EntryR\x05entry\x121\n" +
|
||||
"\x15is_from_other_cluster\x18\x03 \x01(\bR\x12isFromOtherCluster\x12\x1e\n" +
|
||||
"\n" +
|
||||
"signatures\x18\x04 \x03(\x05R\n" +
|
||||
"signatures\"\x15\n" +
|
||||
"\x13UpdateEntryResponse\"\x80\x01\n" +
|
||||
"signatures\"a\n" +
|
||||
"\x13UpdateEntryResponse\x12J\n" +
|
||||
"\x0emetadata_event\x18\x01 \x01(\v2#.filer_pb.SubscribeMetadataResponseR\rmetadataEvent\"\x80\x01\n" +
|
||||
"\x14AppendToEntryRequest\x12\x1c\n" +
|
||||
"\tdirectory\x18\x01 \x01(\tR\tdirectory\x12\x1d\n" +
|
||||
"\n" +
|
||||
@@ -4524,9 +4576,10 @@ const file_filer_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"signatures\x18\b \x03(\x05R\n" +
|
||||
"signatures\x121\n" +
|
||||
"\x15if_not_modified_after\x18\t \x01(\x03R\x12ifNotModifiedAfter\"+\n" +
|
||||
"\x15if_not_modified_after\x18\t \x01(\x03R\x12ifNotModifiedAfter\"w\n" +
|
||||
"\x13DeleteEntryResponse\x12\x14\n" +
|
||||
"\x05error\x18\x01 \x01(\tR\x05error\"\xba\x01\n" +
|
||||
"\x05error\x18\x01 \x01(\tR\x05error\x12J\n" +
|
||||
"\x0emetadata_event\x18\x02 \x01(\v2#.filer_pb.SubscribeMetadataResponseR\rmetadataEvent\"\xba\x01\n" +
|
||||
"\x18AtomicRenameEntryRequest\x12#\n" +
|
||||
"\rold_directory\x18\x01 \x01(\tR\foldDirectory\x12\x19\n" +
|
||||
"\bold_name\x18\x02 \x01(\tR\aoldName\x12#\n" +
|
||||
@@ -4723,9 +4776,10 @@ const file_filer_proto_rawDesc = "" +
|
||||
"\x1bworm_retention_time_seconds\x18\x10 \x01(\x04R\x18wormRetentionTimeSeconds\"Z\n" +
|
||||
"&CacheRemoteObjectToLocalClusterRequest\x12\x1c\n" +
|
||||
"\tdirectory\x18\x01 \x01(\tR\tdirectory\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\"P\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\"\x9c\x01\n" +
|
||||
"'CacheRemoteObjectToLocalClusterResponse\x12%\n" +
|
||||
"\x05entry\x18\x01 \x01(\v2\x0f.filer_pb.EntryR\x05entry\"\x9b\x01\n" +
|
||||
"\x05entry\x18\x01 \x01(\v2\x0f.filer_pb.EntryR\x05entry\x12J\n" +
|
||||
"\x0emetadata_event\x18\x02 \x01(\v2#.filer_pb.SubscribeMetadataResponseR\rmetadataEvent\"\x9b\x01\n" +
|
||||
"\vLockRequest\x12\x12\n" +
|
||||
"\x04name\x18\x01 \x01(\tR\x04name\x12&\n" +
|
||||
"\x0fseconds_to_lock\x18\x02 \x01(\x03R\rsecondsToLock\x12\x1f\n" +
|
||||
@@ -4900,75 +4954,79 @@ var file_filer_proto_depIdxs = []int32{
|
||||
0, // 11: filer_pb.FileChunk.sse_type:type_name -> filer_pb.SSEType
|
||||
9, // 12: filer_pb.FileChunkManifest.chunks:type_name -> filer_pb.FileChunk
|
||||
6, // 13: filer_pb.CreateEntryRequest.entry:type_name -> filer_pb.Entry
|
||||
6, // 14: filer_pb.UpdateEntryRequest.entry:type_name -> filer_pb.Entry
|
||||
9, // 15: filer_pb.AppendToEntryRequest.chunks:type_name -> filer_pb.FileChunk
|
||||
8, // 16: filer_pb.StreamRenameEntryResponse.event_notification:type_name -> filer_pb.EventNotification
|
||||
29, // 17: filer_pb.AssignVolumeResponse.location:type_name -> filer_pb.Location
|
||||
29, // 18: filer_pb.Locations.locations:type_name -> filer_pb.Location
|
||||
68, // 19: filer_pb.LookupVolumeResponse.locations_map:type_name -> filer_pb.LookupVolumeResponse.LocationsMapEntry
|
||||
31, // 20: filer_pb.CollectionListResponse.collections:type_name -> filer_pb.Collection
|
||||
8, // 21: filer_pb.SubscribeMetadataResponse.event_notification:type_name -> filer_pb.EventNotification
|
||||
6, // 22: filer_pb.TraverseBfsMetadataResponse.entry:type_name -> filer_pb.Entry
|
||||
69, // 23: filer_pb.LocateBrokerResponse.resources:type_name -> filer_pb.LocateBrokerResponse.Resource
|
||||
70, // 24: filer_pb.FilerConf.locations:type_name -> filer_pb.FilerConf.PathConf
|
||||
6, // 25: filer_pb.CacheRemoteObjectToLocalClusterResponse.entry:type_name -> filer_pb.Entry
|
||||
64, // 26: filer_pb.TransferLocksRequest.locks:type_name -> filer_pb.Lock
|
||||
28, // 27: filer_pb.LookupVolumeResponse.LocationsMapEntry.value:type_name -> filer_pb.Locations
|
||||
1, // 28: filer_pb.SeaweedFiler.LookupDirectoryEntry:input_type -> filer_pb.LookupDirectoryEntryRequest
|
||||
3, // 29: filer_pb.SeaweedFiler.ListEntries:input_type -> filer_pb.ListEntriesRequest
|
||||
13, // 30: filer_pb.SeaweedFiler.CreateEntry:input_type -> filer_pb.CreateEntryRequest
|
||||
15, // 31: filer_pb.SeaweedFiler.UpdateEntry:input_type -> filer_pb.UpdateEntryRequest
|
||||
17, // 32: filer_pb.SeaweedFiler.AppendToEntry:input_type -> filer_pb.AppendToEntryRequest
|
||||
19, // 33: filer_pb.SeaweedFiler.DeleteEntry:input_type -> filer_pb.DeleteEntryRequest
|
||||
21, // 34: filer_pb.SeaweedFiler.AtomicRenameEntry:input_type -> filer_pb.AtomicRenameEntryRequest
|
||||
23, // 35: filer_pb.SeaweedFiler.StreamRenameEntry:input_type -> filer_pb.StreamRenameEntryRequest
|
||||
25, // 36: filer_pb.SeaweedFiler.AssignVolume:input_type -> filer_pb.AssignVolumeRequest
|
||||
27, // 37: filer_pb.SeaweedFiler.LookupVolume:input_type -> filer_pb.LookupVolumeRequest
|
||||
32, // 38: filer_pb.SeaweedFiler.CollectionList:input_type -> filer_pb.CollectionListRequest
|
||||
34, // 39: filer_pb.SeaweedFiler.DeleteCollection:input_type -> filer_pb.DeleteCollectionRequest
|
||||
36, // 40: filer_pb.SeaweedFiler.Statistics:input_type -> filer_pb.StatisticsRequest
|
||||
38, // 41: filer_pb.SeaweedFiler.Ping:input_type -> filer_pb.PingRequest
|
||||
40, // 42: filer_pb.SeaweedFiler.GetFilerConfiguration:input_type -> filer_pb.GetFilerConfigurationRequest
|
||||
44, // 43: filer_pb.SeaweedFiler.TraverseBfsMetadata:input_type -> filer_pb.TraverseBfsMetadataRequest
|
||||
42, // 44: filer_pb.SeaweedFiler.SubscribeMetadata:input_type -> filer_pb.SubscribeMetadataRequest
|
||||
42, // 45: filer_pb.SeaweedFiler.SubscribeLocalMetadata:input_type -> filer_pb.SubscribeMetadataRequest
|
||||
51, // 46: filer_pb.SeaweedFiler.KvGet:input_type -> filer_pb.KvGetRequest
|
||||
53, // 47: filer_pb.SeaweedFiler.KvPut:input_type -> filer_pb.KvPutRequest
|
||||
56, // 48: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:input_type -> filer_pb.CacheRemoteObjectToLocalClusterRequest
|
||||
58, // 49: filer_pb.SeaweedFiler.DistributedLock:input_type -> filer_pb.LockRequest
|
||||
60, // 50: filer_pb.SeaweedFiler.DistributedUnlock:input_type -> filer_pb.UnlockRequest
|
||||
62, // 51: filer_pb.SeaweedFiler.FindLockOwner:input_type -> filer_pb.FindLockOwnerRequest
|
||||
65, // 52: filer_pb.SeaweedFiler.TransferLocks:input_type -> filer_pb.TransferLocksRequest
|
||||
2, // 53: filer_pb.SeaweedFiler.LookupDirectoryEntry:output_type -> filer_pb.LookupDirectoryEntryResponse
|
||||
4, // 54: filer_pb.SeaweedFiler.ListEntries:output_type -> filer_pb.ListEntriesResponse
|
||||
14, // 55: filer_pb.SeaweedFiler.CreateEntry:output_type -> filer_pb.CreateEntryResponse
|
||||
16, // 56: filer_pb.SeaweedFiler.UpdateEntry:output_type -> filer_pb.UpdateEntryResponse
|
||||
18, // 57: filer_pb.SeaweedFiler.AppendToEntry:output_type -> filer_pb.AppendToEntryResponse
|
||||
20, // 58: filer_pb.SeaweedFiler.DeleteEntry:output_type -> filer_pb.DeleteEntryResponse
|
||||
22, // 59: filer_pb.SeaweedFiler.AtomicRenameEntry:output_type -> filer_pb.AtomicRenameEntryResponse
|
||||
24, // 60: filer_pb.SeaweedFiler.StreamRenameEntry:output_type -> filer_pb.StreamRenameEntryResponse
|
||||
26, // 61: filer_pb.SeaweedFiler.AssignVolume:output_type -> filer_pb.AssignVolumeResponse
|
||||
30, // 62: filer_pb.SeaweedFiler.LookupVolume:output_type -> filer_pb.LookupVolumeResponse
|
||||
33, // 63: filer_pb.SeaweedFiler.CollectionList:output_type -> filer_pb.CollectionListResponse
|
||||
35, // 64: filer_pb.SeaweedFiler.DeleteCollection:output_type -> filer_pb.DeleteCollectionResponse
|
||||
37, // 65: filer_pb.SeaweedFiler.Statistics:output_type -> filer_pb.StatisticsResponse
|
||||
39, // 66: filer_pb.SeaweedFiler.Ping:output_type -> filer_pb.PingResponse
|
||||
41, // 67: filer_pb.SeaweedFiler.GetFilerConfiguration:output_type -> filer_pb.GetFilerConfigurationResponse
|
||||
45, // 68: filer_pb.SeaweedFiler.TraverseBfsMetadata:output_type -> filer_pb.TraverseBfsMetadataResponse
|
||||
43, // 69: filer_pb.SeaweedFiler.SubscribeMetadata:output_type -> filer_pb.SubscribeMetadataResponse
|
||||
43, // 70: filer_pb.SeaweedFiler.SubscribeLocalMetadata:output_type -> filer_pb.SubscribeMetadataResponse
|
||||
52, // 71: filer_pb.SeaweedFiler.KvGet:output_type -> filer_pb.KvGetResponse
|
||||
54, // 72: filer_pb.SeaweedFiler.KvPut:output_type -> filer_pb.KvPutResponse
|
||||
57, // 73: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:output_type -> filer_pb.CacheRemoteObjectToLocalClusterResponse
|
||||
59, // 74: filer_pb.SeaweedFiler.DistributedLock:output_type -> filer_pb.LockResponse
|
||||
61, // 75: filer_pb.SeaweedFiler.DistributedUnlock:output_type -> filer_pb.UnlockResponse
|
||||
63, // 76: filer_pb.SeaweedFiler.FindLockOwner:output_type -> filer_pb.FindLockOwnerResponse
|
||||
66, // 77: filer_pb.SeaweedFiler.TransferLocks:output_type -> filer_pb.TransferLocksResponse
|
||||
53, // [53:78] is the sub-list for method output_type
|
||||
28, // [28:53] is the sub-list for method input_type
|
||||
28, // [28:28] is the sub-list for extension type_name
|
||||
28, // [28:28] is the sub-list for extension extendee
|
||||
0, // [0:28] is the sub-list for field type_name
|
||||
43, // 14: filer_pb.CreateEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
|
||||
6, // 15: filer_pb.UpdateEntryRequest.entry:type_name -> filer_pb.Entry
|
||||
43, // 16: filer_pb.UpdateEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
|
||||
9, // 17: filer_pb.AppendToEntryRequest.chunks:type_name -> filer_pb.FileChunk
|
||||
43, // 18: filer_pb.DeleteEntryResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
|
||||
8, // 19: filer_pb.StreamRenameEntryResponse.event_notification:type_name -> filer_pb.EventNotification
|
||||
29, // 20: filer_pb.AssignVolumeResponse.location:type_name -> filer_pb.Location
|
||||
29, // 21: filer_pb.Locations.locations:type_name -> filer_pb.Location
|
||||
68, // 22: filer_pb.LookupVolumeResponse.locations_map:type_name -> filer_pb.LookupVolumeResponse.LocationsMapEntry
|
||||
31, // 23: filer_pb.CollectionListResponse.collections:type_name -> filer_pb.Collection
|
||||
8, // 24: filer_pb.SubscribeMetadataResponse.event_notification:type_name -> filer_pb.EventNotification
|
||||
6, // 25: filer_pb.TraverseBfsMetadataResponse.entry:type_name -> filer_pb.Entry
|
||||
69, // 26: filer_pb.LocateBrokerResponse.resources:type_name -> filer_pb.LocateBrokerResponse.Resource
|
||||
70, // 27: filer_pb.FilerConf.locations:type_name -> filer_pb.FilerConf.PathConf
|
||||
6, // 28: filer_pb.CacheRemoteObjectToLocalClusterResponse.entry:type_name -> filer_pb.Entry
|
||||
43, // 29: filer_pb.CacheRemoteObjectToLocalClusterResponse.metadata_event:type_name -> filer_pb.SubscribeMetadataResponse
|
||||
64, // 30: filer_pb.TransferLocksRequest.locks:type_name -> filer_pb.Lock
|
||||
28, // 31: filer_pb.LookupVolumeResponse.LocationsMapEntry.value:type_name -> filer_pb.Locations
|
||||
1, // 32: filer_pb.SeaweedFiler.LookupDirectoryEntry:input_type -> filer_pb.LookupDirectoryEntryRequest
|
||||
3, // 33: filer_pb.SeaweedFiler.ListEntries:input_type -> filer_pb.ListEntriesRequest
|
||||
13, // 34: filer_pb.SeaweedFiler.CreateEntry:input_type -> filer_pb.CreateEntryRequest
|
||||
15, // 35: filer_pb.SeaweedFiler.UpdateEntry:input_type -> filer_pb.UpdateEntryRequest
|
||||
17, // 36: filer_pb.SeaweedFiler.AppendToEntry:input_type -> filer_pb.AppendToEntryRequest
|
||||
19, // 37: filer_pb.SeaweedFiler.DeleteEntry:input_type -> filer_pb.DeleteEntryRequest
|
||||
21, // 38: filer_pb.SeaweedFiler.AtomicRenameEntry:input_type -> filer_pb.AtomicRenameEntryRequest
|
||||
23, // 39: filer_pb.SeaweedFiler.StreamRenameEntry:input_type -> filer_pb.StreamRenameEntryRequest
|
||||
25, // 40: filer_pb.SeaweedFiler.AssignVolume:input_type -> filer_pb.AssignVolumeRequest
|
||||
27, // 41: filer_pb.SeaweedFiler.LookupVolume:input_type -> filer_pb.LookupVolumeRequest
|
||||
32, // 42: filer_pb.SeaweedFiler.CollectionList:input_type -> filer_pb.CollectionListRequest
|
||||
34, // 43: filer_pb.SeaweedFiler.DeleteCollection:input_type -> filer_pb.DeleteCollectionRequest
|
||||
36, // 44: filer_pb.SeaweedFiler.Statistics:input_type -> filer_pb.StatisticsRequest
|
||||
38, // 45: filer_pb.SeaweedFiler.Ping:input_type -> filer_pb.PingRequest
|
||||
40, // 46: filer_pb.SeaweedFiler.GetFilerConfiguration:input_type -> filer_pb.GetFilerConfigurationRequest
|
||||
44, // 47: filer_pb.SeaweedFiler.TraverseBfsMetadata:input_type -> filer_pb.TraverseBfsMetadataRequest
|
||||
42, // 48: filer_pb.SeaweedFiler.SubscribeMetadata:input_type -> filer_pb.SubscribeMetadataRequest
|
||||
42, // 49: filer_pb.SeaweedFiler.SubscribeLocalMetadata:input_type -> filer_pb.SubscribeMetadataRequest
|
||||
51, // 50: filer_pb.SeaweedFiler.KvGet:input_type -> filer_pb.KvGetRequest
|
||||
53, // 51: filer_pb.SeaweedFiler.KvPut:input_type -> filer_pb.KvPutRequest
|
||||
56, // 52: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:input_type -> filer_pb.CacheRemoteObjectToLocalClusterRequest
|
||||
58, // 53: filer_pb.SeaweedFiler.DistributedLock:input_type -> filer_pb.LockRequest
|
||||
60, // 54: filer_pb.SeaweedFiler.DistributedUnlock:input_type -> filer_pb.UnlockRequest
|
||||
62, // 55: filer_pb.SeaweedFiler.FindLockOwner:input_type -> filer_pb.FindLockOwnerRequest
|
||||
65, // 56: filer_pb.SeaweedFiler.TransferLocks:input_type -> filer_pb.TransferLocksRequest
|
||||
2, // 57: filer_pb.SeaweedFiler.LookupDirectoryEntry:output_type -> filer_pb.LookupDirectoryEntryResponse
|
||||
4, // 58: filer_pb.SeaweedFiler.ListEntries:output_type -> filer_pb.ListEntriesResponse
|
||||
14, // 59: filer_pb.SeaweedFiler.CreateEntry:output_type -> filer_pb.CreateEntryResponse
|
||||
16, // 60: filer_pb.SeaweedFiler.UpdateEntry:output_type -> filer_pb.UpdateEntryResponse
|
||||
18, // 61: filer_pb.SeaweedFiler.AppendToEntry:output_type -> filer_pb.AppendToEntryResponse
|
||||
20, // 62: filer_pb.SeaweedFiler.DeleteEntry:output_type -> filer_pb.DeleteEntryResponse
|
||||
22, // 63: filer_pb.SeaweedFiler.AtomicRenameEntry:output_type -> filer_pb.AtomicRenameEntryResponse
|
||||
24, // 64: filer_pb.SeaweedFiler.StreamRenameEntry:output_type -> filer_pb.StreamRenameEntryResponse
|
||||
26, // 65: filer_pb.SeaweedFiler.AssignVolume:output_type -> filer_pb.AssignVolumeResponse
|
||||
30, // 66: filer_pb.SeaweedFiler.LookupVolume:output_type -> filer_pb.LookupVolumeResponse
|
||||
33, // 67: filer_pb.SeaweedFiler.CollectionList:output_type -> filer_pb.CollectionListResponse
|
||||
35, // 68: filer_pb.SeaweedFiler.DeleteCollection:output_type -> filer_pb.DeleteCollectionResponse
|
||||
37, // 69: filer_pb.SeaweedFiler.Statistics:output_type -> filer_pb.StatisticsResponse
|
||||
39, // 70: filer_pb.SeaweedFiler.Ping:output_type -> filer_pb.PingResponse
|
||||
41, // 71: filer_pb.SeaweedFiler.GetFilerConfiguration:output_type -> filer_pb.GetFilerConfigurationResponse
|
||||
45, // 72: filer_pb.SeaweedFiler.TraverseBfsMetadata:output_type -> filer_pb.TraverseBfsMetadataResponse
|
||||
43, // 73: filer_pb.SeaweedFiler.SubscribeMetadata:output_type -> filer_pb.SubscribeMetadataResponse
|
||||
43, // 74: filer_pb.SeaweedFiler.SubscribeLocalMetadata:output_type -> filer_pb.SubscribeMetadataResponse
|
||||
52, // 75: filer_pb.SeaweedFiler.KvGet:output_type -> filer_pb.KvGetResponse
|
||||
54, // 76: filer_pb.SeaweedFiler.KvPut:output_type -> filer_pb.KvPutResponse
|
||||
57, // 77: filer_pb.SeaweedFiler.CacheRemoteObjectToLocalCluster:output_type -> filer_pb.CacheRemoteObjectToLocalClusterResponse
|
||||
59, // 78: filer_pb.SeaweedFiler.DistributedLock:output_type -> filer_pb.LockResponse
|
||||
61, // 79: filer_pb.SeaweedFiler.DistributedUnlock:output_type -> filer_pb.UnlockResponse
|
||||
63, // 80: filer_pb.SeaweedFiler.FindLockOwner:output_type -> filer_pb.FindLockOwnerResponse
|
||||
66, // 81: filer_pb.SeaweedFiler.TransferLocks:output_type -> filer_pb.TransferLocksResponse
|
||||
57, // [57:82] is the sub-list for method output_type
|
||||
32, // [32:57] is the sub-list for method input_type
|
||||
32, // [32:32] is the sub-list for extension type_name
|
||||
32, // [32:32] is the sub-list for extension extendee
|
||||
0, // [0:32] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_filer_proto_init() }
|
||||
|
||||
@@ -58,7 +58,11 @@ func GetEntry(ctx context.Context, filerClient FilerClient, fullFilePath util.Fu
|
||||
type EachEntryFunction func(entry *Entry, isLast bool) error
|
||||
|
||||
func ReadDirAllEntries(ctx context.Context, filerClient FilerClient, fullDirPath util.FullPath, prefix string, fn EachEntryFunction) (err error) {
|
||||
_, err = ReadDirAllEntriesWithSnapshot(ctx, filerClient, fullDirPath, prefix, fn)
|
||||
return err
|
||||
}
|
||||
|
||||
func ReadDirAllEntriesWithSnapshot(ctx context.Context, filerClient FilerClient, fullDirPath util.FullPath, prefix string, fn EachEntryFunction) (snapshotTsNs int64, err error) {
|
||||
var counter uint32
|
||||
var startFrom string
|
||||
var counterFunc = func(entry *Entry, isLast bool) error {
|
||||
@@ -69,18 +73,18 @@ func ReadDirAllEntries(ctx context.Context, filerClient FilerClient, fullDirPath
|
||||
|
||||
var paginationLimit uint32 = 10000
|
||||
|
||||
if err = doList(ctx, filerClient, fullDirPath, prefix, counterFunc, "", false, paginationLimit); err != nil {
|
||||
return err
|
||||
if snapshotTsNs, err = doListWithSnapshot(ctx, filerClient, fullDirPath, prefix, counterFunc, "", false, paginationLimit, 0); err != nil {
|
||||
return snapshotTsNs, err
|
||||
}
|
||||
|
||||
for counter == paginationLimit {
|
||||
counter = 0
|
||||
if err = doList(ctx, filerClient, fullDirPath, prefix, counterFunc, startFrom, false, paginationLimit); err != nil {
|
||||
return err
|
||||
if _, err = doListWithSnapshot(ctx, filerClient, fullDirPath, prefix, counterFunc, startFrom, false, paginationLimit, snapshotTsNs); err != nil {
|
||||
return snapshotTsNs, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return snapshotTsNs, nil
|
||||
}
|
||||
|
||||
func List(ctx context.Context, filerClient FilerClient, parentDirectoryPath, prefix string, fn EachEntryFunction, startFrom string, inclusive bool, limit uint32) (err error) {
|
||||
@@ -90,9 +94,16 @@ func List(ctx context.Context, filerClient FilerClient, parentDirectoryPath, pre
|
||||
}
|
||||
|
||||
func doList(ctx context.Context, filerClient FilerClient, fullDirPath util.FullPath, prefix string, fn EachEntryFunction, startFrom string, inclusive bool, limit uint32) (err error) {
|
||||
return filerClient.WithFilerClient(false, func(client SeaweedFilerClient) error {
|
||||
return doSeaweedList(ctx, client, fullDirPath, prefix, fn, startFrom, inclusive, limit)
|
||||
_, err = doListWithSnapshot(ctx, filerClient, fullDirPath, prefix, fn, startFrom, inclusive, limit, 0)
|
||||
return err
|
||||
}
|
||||
|
||||
func doListWithSnapshot(ctx context.Context, filerClient FilerClient, fullDirPath util.FullPath, prefix string, fn EachEntryFunction, startFrom string, inclusive bool, limit uint32, snapshotTsNs int64) (actualSnapshotTsNs int64, err error) {
|
||||
err = filerClient.WithFilerClient(false, func(client SeaweedFilerClient) error {
|
||||
actualSnapshotTsNs, err = DoSeaweedListWithSnapshot(ctx, client, fullDirPath, prefix, fn, startFrom, inclusive, limit, snapshotTsNs)
|
||||
return err
|
||||
})
|
||||
return actualSnapshotTsNs, err
|
||||
}
|
||||
|
||||
func SeaweedList(ctx context.Context, client SeaweedFilerClient, parentDirectoryPath, prefix string, fn EachEntryFunction, startFrom string, inclusive bool, limit uint32) (err error) {
|
||||
@@ -100,6 +111,11 @@ func SeaweedList(ctx context.Context, client SeaweedFilerClient, parentDirectory
|
||||
}
|
||||
|
||||
func doSeaweedList(ctx context.Context, client SeaweedFilerClient, fullDirPath util.FullPath, prefix string, fn EachEntryFunction, startFrom string, inclusive bool, limit uint32) (err error) {
|
||||
_, err = DoSeaweedListWithSnapshot(ctx, client, fullDirPath, prefix, fn, startFrom, inclusive, limit, 0)
|
||||
return err
|
||||
}
|
||||
|
||||
func DoSeaweedListWithSnapshot(ctx context.Context, client SeaweedFilerClient, fullDirPath util.FullPath, prefix string, fn EachEntryFunction, startFrom string, inclusive bool, limit uint32, snapshotTsNs int64) (actualSnapshotTsNs int64, err error) {
|
||||
// Redundancy limit to make it correctly judge whether it is the last file.
|
||||
redLimit := limit
|
||||
|
||||
@@ -115,14 +131,23 @@ func doSeaweedList(ctx context.Context, client SeaweedFilerClient, fullDirPath u
|
||||
StartFromFileName: startFrom,
|
||||
Limit: redLimit,
|
||||
InclusiveStartFrom: inclusive,
|
||||
SnapshotTsNs: snapshotTsNs,
|
||||
}
|
||||
|
||||
// Preserve the caller-requested snapshot so pagination uses the same
|
||||
// boundary across pages. For first requests (snapshotTsNs==0) we do NOT
|
||||
// synthesize a client-side timestamp — if the server returns no entries,
|
||||
// we return 0 so callers like CompleteDirectoryBuild know no server
|
||||
// snapshot was received and can replay all buffered events without
|
||||
// clock-skew-sensitive filtering.
|
||||
actualSnapshotTsNs = snapshotTsNs
|
||||
|
||||
glog.V(4).InfofCtx(ctx, "read directory: %v", request)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
stream, err := client.ListEntries(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list %s: %v", fullDirPath, err)
|
||||
return actualSnapshotTsNs, fmt.Errorf("list %s: %v", fullDirPath, err)
|
||||
}
|
||||
|
||||
var prevEntry *Entry
|
||||
@@ -133,17 +158,20 @@ func doSeaweedList(ctx context.Context, client SeaweedFilerClient, fullDirPath u
|
||||
if recvErr == io.EOF {
|
||||
if prevEntry != nil {
|
||||
if err := fn(prevEntry, true); err != nil {
|
||||
return err
|
||||
return actualSnapshotTsNs, err
|
||||
}
|
||||
}
|
||||
break
|
||||
} else {
|
||||
return recvErr
|
||||
return actualSnapshotTsNs, recvErr
|
||||
}
|
||||
}
|
||||
if resp.SnapshotTsNs != 0 {
|
||||
actualSnapshotTsNs = resp.SnapshotTsNs
|
||||
}
|
||||
if prevEntry != nil {
|
||||
if err := fn(prevEntry, false); err != nil {
|
||||
return err
|
||||
return actualSnapshotTsNs, err
|
||||
}
|
||||
}
|
||||
prevEntry = resp.Entry
|
||||
@@ -153,7 +181,7 @@ func doSeaweedList(ctx context.Context, client SeaweedFilerClient, fullDirPath u
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return actualSnapshotTsNs, nil
|
||||
}
|
||||
|
||||
func Exists(ctx context.Context, filerClient FilerClient, parentDirectoryPath string, entryName string, isDirectory bool) (exists bool, err error) {
|
||||
@@ -277,12 +305,26 @@ func MkFile(ctx context.Context, filerClient FilerClient, parentDirectoryPath st
|
||||
}
|
||||
|
||||
func Remove(ctx context.Context, filerClient FilerClient, parentDirectoryPath, name string, isDeleteData, isRecursive, ignoreRecursiveErr, isFromOtherCluster bool, signatures []int32) error {
|
||||
return filerClient.WithFilerClient(false, func(client SeaweedFilerClient) error {
|
||||
return DoRemove(ctx, client, parentDirectoryPath, name, isDeleteData, isRecursive, ignoreRecursiveErr, isFromOtherCluster, signatures)
|
||||
})
|
||||
_, err := RemoveWithResponse(ctx, filerClient, parentDirectoryPath, name, isDeleteData, isRecursive, ignoreRecursiveErr, isFromOtherCluster, signatures)
|
||||
return err
|
||||
}
|
||||
|
||||
func DoRemove(ctx context.Context, client SeaweedFilerClient, parentDirectoryPath string, name string, isDeleteData bool, isRecursive bool, ignoreRecursiveErr bool, isFromOtherCluster bool, signatures []int32) error {
|
||||
_, err := DoRemoveWithResponse(ctx, client, parentDirectoryPath, name, isDeleteData, isRecursive, ignoreRecursiveErr, isFromOtherCluster, signatures)
|
||||
return err
|
||||
}
|
||||
|
||||
func RemoveWithResponse(ctx context.Context, filerClient FilerClient, parentDirectoryPath, name string, isDeleteData, isRecursive, ignoreRecursiveErr, isFromOtherCluster bool, signatures []int32) (*DeleteEntryResponse, error) {
|
||||
var resp *DeleteEntryResponse
|
||||
err := filerClient.WithFilerClient(false, func(client SeaweedFilerClient) error {
|
||||
var innerErr error
|
||||
resp, innerErr = DoRemoveWithResponse(ctx, client, parentDirectoryPath, name, isDeleteData, isRecursive, ignoreRecursiveErr, isFromOtherCluster, signatures)
|
||||
return innerErr
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func DoRemoveWithResponse(ctx context.Context, client SeaweedFilerClient, parentDirectoryPath string, name string, isDeleteData bool, isRecursive bool, ignoreRecursiveErr bool, isFromOtherCluster bool, signatures []int32) (*DeleteEntryResponse, error) {
|
||||
deleteEntryRequest := &DeleteEntryRequest{
|
||||
Directory: parentDirectoryPath,
|
||||
Name: name,
|
||||
@@ -294,19 +336,18 @@ func DoRemove(ctx context.Context, client SeaweedFilerClient, parentDirectoryPat
|
||||
}
|
||||
if resp, err := client.DeleteEntry(ctx, deleteEntryRequest); err != nil {
|
||||
if strings.Contains(err.Error(), ErrNotFound.Error()) {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
return err
|
||||
return nil, err
|
||||
} else {
|
||||
if resp.Error != "" {
|
||||
if strings.Contains(resp.Error, ErrNotFound.Error()) {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
return errors.New(resp.Error)
|
||||
return nil, errors.New(resp.Error)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DoDeleteEmptyParentDirectories recursively deletes empty parent directories.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package filer_pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type snapshotListStream struct {
|
||||
responses []*ListEntriesResponse
|
||||
index int
|
||||
}
|
||||
|
||||
func (s *snapshotListStream) Recv() (*ListEntriesResponse, error) {
|
||||
if s.index >= len(s.responses) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
resp := s.responses[s.index]
|
||||
s.index++
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *snapshotListStream) Header() (metadata.MD, error) { return metadata.MD{}, nil }
|
||||
func (s *snapshotListStream) Trailer() metadata.MD { return metadata.MD{} }
|
||||
func (s *snapshotListStream) CloseSend() error { return nil }
|
||||
func (s *snapshotListStream) Context() context.Context { return context.Background() }
|
||||
func (s *snapshotListStream) SendMsg(any) error { return nil }
|
||||
func (s *snapshotListStream) RecvMsg(any) error { return nil }
|
||||
|
||||
type snapshotListClient struct {
|
||||
SeaweedFilerClient
|
||||
entries []*Entry
|
||||
requests []*ListEntriesRequest
|
||||
snapshotTs int64
|
||||
listCalled bool
|
||||
}
|
||||
|
||||
func (c *snapshotListClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ListEntriesResponse], error) {
|
||||
c.listCalled = true
|
||||
c.requests = append(c.requests, proto.Clone(in).(*ListEntriesRequest))
|
||||
|
||||
start := 0
|
||||
if in.StartFromFileName != "" {
|
||||
start = len(c.entries)
|
||||
for i, entry := range c.entries {
|
||||
if entry.Name == in.StartFromFileName {
|
||||
start = i
|
||||
if !in.InclusiveStartFrom {
|
||||
start++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
end := len(c.entries)
|
||||
if in.Limit > 0 && start+int(in.Limit) < end {
|
||||
end = start + int(in.Limit)
|
||||
}
|
||||
|
||||
snapshotTs := in.SnapshotTsNs
|
||||
if snapshotTs == 0 {
|
||||
snapshotTs = c.snapshotTs
|
||||
}
|
||||
|
||||
responses := make([]*ListEntriesResponse, 0, end-start)
|
||||
for i, entry := range c.entries[start:end] {
|
||||
resp := &ListEntriesResponse{
|
||||
Entry: entry,
|
||||
}
|
||||
if i == 0 {
|
||||
resp.SnapshotTsNs = snapshotTs
|
||||
}
|
||||
responses = append(responses, resp)
|
||||
}
|
||||
|
||||
return &snapshotListStream{responses: responses}, nil
|
||||
}
|
||||
|
||||
type snapshotFilerAccessor struct {
|
||||
client SeaweedFilerClient
|
||||
}
|
||||
|
||||
func (a *snapshotFilerAccessor) WithFilerClient(_ bool, fn func(SeaweedFilerClient) error) error {
|
||||
return fn(a.client)
|
||||
}
|
||||
|
||||
func (a *snapshotFilerAccessor) AdjustedUrl(*Location) string { return "" }
|
||||
func (a *snapshotFilerAccessor) GetDataCenter() string { return "" }
|
||||
|
||||
func TestReadDirAllEntriesWithSnapshotCarriesSnapshotAcrossPages(t *testing.T) {
|
||||
entries := make([]*Entry, 0, 10001)
|
||||
for i := 0; i < 10001; i++ {
|
||||
entries = append(entries, &Entry{Name: fmt.Sprintf("entry-%05d", i), Attributes: &FuseAttributes{}})
|
||||
}
|
||||
|
||||
client := &snapshotListClient{
|
||||
entries: entries,
|
||||
snapshotTs: 123456789,
|
||||
}
|
||||
accessor := &snapshotFilerAccessor{client: client}
|
||||
|
||||
var listed []string
|
||||
snapshotTs, err := ReadDirAllEntriesWithSnapshot(context.Background(), accessor, util.FullPath("/dir"), "", func(entry *Entry, isLast bool) error {
|
||||
listed = append(listed, entry.Name)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDirAllEntriesWithSnapshot: %v", err)
|
||||
}
|
||||
|
||||
if got := len(listed); got != len(entries) {
|
||||
t.Fatalf("listed %d entries, want %d", got, len(entries))
|
||||
}
|
||||
if snapshotTs != client.snapshotTs {
|
||||
t.Fatalf("snapshotTs = %d, want %d", snapshotTs, client.snapshotTs)
|
||||
}
|
||||
if got := len(client.requests); got != 2 {
|
||||
t.Fatalf("request count = %d, want 2", got)
|
||||
}
|
||||
if client.requests[0].SnapshotTsNs != 0 {
|
||||
t.Fatalf("first request snapshot = %d, want 0", client.requests[0].SnapshotTsNs)
|
||||
}
|
||||
if client.requests[1].SnapshotTsNs != client.snapshotTs {
|
||||
t.Fatalf("second request snapshot = %d, want %d", client.requests[1].SnapshotTsNs, client.snapshotTs)
|
||||
}
|
||||
if client.requests[1].StartFromFileName != entries[9999].Name {
|
||||
t.Fatalf("second request marker = %q, want %q", client.requests[1].StartFromFileName, entries[9999].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDirAllEntriesWithSnapshotEmptyDirectory(t *testing.T) {
|
||||
client := &snapshotListClient{
|
||||
entries: nil, // empty directory
|
||||
snapshotTs: 999888777,
|
||||
}
|
||||
accessor := &snapshotFilerAccessor{client: client}
|
||||
|
||||
var listed []string
|
||||
snapshotTs, err := ReadDirAllEntriesWithSnapshot(context.Background(), accessor, util.FullPath("/empty"), "", func(entry *Entry, isLast bool) error {
|
||||
listed = append(listed, entry.Name)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDirAllEntriesWithSnapshot: %v", err)
|
||||
}
|
||||
if len(listed) != 0 {
|
||||
t.Fatalf("listed %d entries, want 0", len(listed))
|
||||
}
|
||||
// When the server sends no entries (empty directory), no snapshot is
|
||||
// received. The client returns 0 so callers like CompleteDirectoryBuild
|
||||
// know to replay all buffered events without clock-skew filtering.
|
||||
if snapshotTs != 0 {
|
||||
t.Fatalf("snapshotTs = %d, want 0 for empty directory", snapshotTs)
|
||||
}
|
||||
if !client.listCalled {
|
||||
t.Fatal("ListEntries was not invoked for the empty directory")
|
||||
}
|
||||
}
|
||||
@@ -135,25 +135,35 @@ func AfterEntryDeserialization(chunks []*FileChunk) {
|
||||
}
|
||||
|
||||
func CreateEntry(ctx context.Context, client SeaweedFilerClient, request *CreateEntryRequest) error {
|
||||
_, err := CreateEntryWithResponse(ctx, client, request)
|
||||
return err
|
||||
}
|
||||
|
||||
func CreateEntryWithResponse(ctx context.Context, client SeaweedFilerClient, request *CreateEntryRequest) (*CreateEntryResponse, error) {
|
||||
resp, err := client.CreateEntry(ctx, request)
|
||||
if err != nil {
|
||||
glog.V(1).InfofCtx(ctx, "create entry %s/%s %v: %v", request.Directory, request.Entry.Name, request.OExcl, err)
|
||||
return fmt.Errorf("CreateEntry: %w", err)
|
||||
return nil, fmt.Errorf("CreateEntry: %w", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
glog.V(1).InfofCtx(ctx, "create entry %s/%s %v: %v", request.Directory, request.Entry.Name, request.OExcl, resp.Error)
|
||||
return fmt.Errorf("CreateEntry : %v", resp.Error)
|
||||
return nil, fmt.Errorf("CreateEntry: %w", errors.New(resp.Error))
|
||||
}
|
||||
return nil
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func UpdateEntry(ctx context.Context, client SeaweedFilerClient, request *UpdateEntryRequest) error {
|
||||
_, err := client.UpdateEntry(ctx, request)
|
||||
_, err := UpdateEntryWithResponse(ctx, client, request)
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateEntryWithResponse(ctx context.Context, client SeaweedFilerClient, request *UpdateEntryRequest) (*UpdateEntryResponse, error) {
|
||||
resp, err := client.UpdateEntry(ctx, request)
|
||||
if err != nil {
|
||||
glog.V(1).InfofCtx(ctx, "update entry %s/%s :%v", request.Directory, request.Entry.Name, err)
|
||||
return fmt.Errorf("UpdateEntry: %w", err)
|
||||
return nil, fmt.Errorf("UpdateEntry: %w", err)
|
||||
}
|
||||
return nil
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func LookupEntry(ctx context.Context, client SeaweedFilerClient, request *LookupDirectoryEntryRequest) (*LookupDirectoryEntryResponse, error) {
|
||||
|
||||
@@ -33,6 +33,17 @@ s3.clean.uploads -timeAgo=24h`
|
||||
|
||||
var adminScriptTokenRegex = regexp.MustCompile(`'.*?'|".*?"|\S+`)
|
||||
|
||||
func init() {
|
||||
RegisterHandler(HandlerFactory{
|
||||
JobType: "admin_script",
|
||||
Category: CategoryDefault,
|
||||
Aliases: []string{"admin-script", "admin.script", "script", "admin"},
|
||||
Build: func(opts HandlerBuildOptions) (JobHandler, error) {
|
||||
return NewAdminScriptHandler(opts.GrpcDialOption), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type AdminScriptHandler struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
}
|
||||
@@ -131,8 +142,8 @@ func (h *AdminScriptHandler) Detect(ctx context.Context, request *plugin_pb.RunD
|
||||
script := normalizeAdminScript(readStringConfig(request.GetAdminConfigValues(), "script", ""))
|
||||
scriptName := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "script_name", ""))
|
||||
runIntervalMinutes := readAdminScriptRunIntervalMinutes(request.GetAdminConfigValues())
|
||||
if shouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), runIntervalMinutes*60) {
|
||||
_ = sender.SendActivity(buildDetectorActivity(
|
||||
if ShouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), runIntervalMinutes*60) {
|
||||
_ = sender.SendActivity(BuildDetectorActivity(
|
||||
"skipped_by_interval",
|
||||
fmt.Sprintf("ADMIN SCRIPT: Detection skipped due to run interval (%dm)", runIntervalMinutes),
|
||||
map[string]*plugin_pb.ConfigValue{
|
||||
@@ -158,7 +169,7 @@ func (h *AdminScriptHandler) Detect(ctx context.Context, request *plugin_pb.RunD
|
||||
commands := parseAdminScriptCommands(script)
|
||||
execCount := countExecutableCommands(commands)
|
||||
if execCount == 0 {
|
||||
_ = sender.SendActivity(buildDetectorActivity(
|
||||
_ = sender.SendActivity(BuildDetectorActivity(
|
||||
"no_script",
|
||||
"ADMIN SCRIPT: No executable commands configured",
|
||||
map[string]*plugin_pb.ConfigValue{
|
||||
@@ -251,7 +262,7 @@ func (h *AdminScriptHandler) Execute(ctx context.Context, request *plugin_pb.Exe
|
||||
Stage: "assigned",
|
||||
Message: "admin script job accepted",
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("assigned", "admin script job accepted"),
|
||||
BuildExecutorActivity("assigned", "admin script job accepted"),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
@@ -287,7 +298,7 @@ func (h *AdminScriptHandler) Execute(ctx context.Context, request *plugin_pb.Exe
|
||||
Stage: "error",
|
||||
Message: msg,
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("error", msg),
|
||||
BuildExecutorActivity("error", msg),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -303,7 +314,7 @@ func (h *AdminScriptHandler) Execute(ctx context.Context, request *plugin_pb.Exe
|
||||
Stage: "error",
|
||||
Message: msg,
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("error", msg),
|
||||
BuildExecutorActivity("error", msg),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -316,7 +327,7 @@ func (h *AdminScriptHandler) Execute(ctx context.Context, request *plugin_pb.Exe
|
||||
Stage: "running",
|
||||
Message: fmt.Sprintf("executed %d/%d command(s)", executed, len(execCommands)),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("running", commandLine),
|
||||
BuildExecutorActivity("running", commandLine),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -382,7 +393,7 @@ func (h *AdminScriptHandler) Execute(ctx context.Context, request *plugin_pb.Exe
|
||||
OutputValues: outputValues,
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("completed", resultSummary),
|
||||
BuildExecutorActivity("completed", resultSummary),
|
||||
},
|
||||
CompletedAt: timestamppb.Now(),
|
||||
})
|
||||
|
||||
@@ -20,6 +20,17 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterHandler(HandlerFactory{
|
||||
JobType: "erasure_coding",
|
||||
Category: CategoryHeavy,
|
||||
Aliases: []string{"erasure-coding", "erasure.coding", "ec"},
|
||||
Build: func(opts HandlerBuildOptions) (JobHandler, error) {
|
||||
return NewErasureCodingHandler(opts.GrpcDialOption, opts.WorkingDir), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type erasureCodingWorkerConfig struct {
|
||||
TaskConfig *erasurecodingtask.Config
|
||||
MinIntervalSeconds int
|
||||
@@ -205,9 +216,9 @@ func (h *ErasureCodingHandler) Detect(
|
||||
}
|
||||
|
||||
workerConfig := deriveErasureCodingWorkerConfig(request.GetWorkerConfigValues())
|
||||
if shouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), workerConfig.MinIntervalSeconds) {
|
||||
if ShouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), workerConfig.MinIntervalSeconds) {
|
||||
minInterval := time.Duration(workerConfig.MinIntervalSeconds) * time.Second
|
||||
_ = sender.SendActivity(buildDetectorActivity(
|
||||
_ = sender.SendActivity(BuildDetectorActivity(
|
||||
"skipped_by_interval",
|
||||
fmt.Sprintf("ERASURE CODING: Detection skipped due to min interval (%s)", minInterval),
|
||||
map[string]*plugin_pb.ConfigValue{
|
||||
@@ -380,7 +391,7 @@ func emitErasureCodingDetectionDecisionTrace(
|
||||
)
|
||||
}
|
||||
|
||||
if err := sender.SendActivity(buildDetectorActivity("decision_summary", summaryMessage, map[string]*plugin_pb.ConfigValue{
|
||||
if err := sender.SendActivity(BuildDetectorActivity("decision_summary", summaryMessage, map[string]*plugin_pb.ConfigValue{
|
||||
"total_volumes": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(totalVolumes)},
|
||||
},
|
||||
@@ -437,7 +448,7 @@ func emitErasureCodingDetectionDecisionTrace(
|
||||
metric.FullnessRatio*100,
|
||||
taskConfig.FullnessRatio*100,
|
||||
)
|
||||
if err := sender.SendActivity(buildDetectorActivity("decision_volume", message, map[string]*plugin_pb.ConfigValue{
|
||||
if err := sender.SendActivity(BuildDetectorActivity("decision_volume", message, map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(metric.VolumeID)},
|
||||
},
|
||||
@@ -520,7 +531,7 @@ func (h *ErasureCodingHandler) Execute(
|
||||
Stage: stage,
|
||||
Message: message,
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity(stage, message),
|
||||
BuildExecutorActivity(stage, message),
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -533,7 +544,7 @@ func (h *ErasureCodingHandler) Execute(
|
||||
Stage: "assigned",
|
||||
Message: "erasure coding job accepted",
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("assigned", "erasure coding job accepted"),
|
||||
BuildExecutorActivity("assigned", "erasure coding job accepted"),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
@@ -548,7 +559,7 @@ func (h *ErasureCodingHandler) Execute(
|
||||
Stage: "failed",
|
||||
Message: err.Error(),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("failed", err.Error()),
|
||||
BuildExecutorActivity("failed", err.Error()),
|
||||
},
|
||||
})
|
||||
return err
|
||||
@@ -576,7 +587,7 @@ func (h *ErasureCodingHandler) Execute(
|
||||
},
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("completed", resultSummary),
|
||||
BuildExecutorActivity("completed", resultSummary),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package pluginworker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// JobCategory groups job types by resource profile so that workers can be
|
||||
// configured with a category name instead of an explicit list of job types.
|
||||
type JobCategory string
|
||||
|
||||
const (
|
||||
CategoryAll JobCategory = "all" // pseudo-category matching every handler
|
||||
CategoryDefault JobCategory = "default" // lightweight, safe for any worker
|
||||
CategoryHeavy JobCategory = "heavy" // resource-intensive jobs
|
||||
)
|
||||
|
||||
// HandlerFactory describes how to build a JobHandler for a single job type.
|
||||
type HandlerFactory struct {
|
||||
// JobType is the canonical job type string (e.g. "vacuum").
|
||||
JobType string
|
||||
// Category controls which category label selects this handler.
|
||||
Category JobCategory
|
||||
// Aliases are alternative CLI names that resolve to this job type
|
||||
// (e.g. "ec" for "erasure_coding").
|
||||
Aliases []string
|
||||
// Build constructs the JobHandler.
|
||||
Build func(opts HandlerBuildOptions) (JobHandler, error)
|
||||
}
|
||||
|
||||
// HandlerBuildOptions carries parameters forwarded from the CLI to handler
|
||||
// constructors.
|
||||
type HandlerBuildOptions struct {
|
||||
GrpcDialOption grpc.DialOption
|
||||
MaxExecute int
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
var (
|
||||
registryMu sync.Mutex
|
||||
registry []HandlerFactory
|
||||
)
|
||||
|
||||
// RegisterHandler adds a handler factory to the global registry.
|
||||
// It is intended to be called from handler init() functions.
|
||||
func RegisterHandler(f HandlerFactory) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
registry = append(registry, f)
|
||||
}
|
||||
|
||||
// ResolveHandlerFactories takes a comma-separated token list that can contain
|
||||
// category names ("all", "default", "heavy") and/or explicit job type names
|
||||
// (including aliases). It returns a deduplicated, ordered slice of factories.
|
||||
func ResolveHandlerFactories(tokens string) ([]HandlerFactory, error) {
|
||||
registryMu.Lock()
|
||||
snapshot := make([]HandlerFactory, len(registry))
|
||||
copy(snapshot, registry)
|
||||
registryMu.Unlock()
|
||||
|
||||
parts := strings.Split(tokens, ",")
|
||||
result := make([]HandlerFactory, 0, len(snapshot))
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, raw := range parts {
|
||||
tok := strings.ToLower(strings.TrimSpace(raw))
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if cat, ok := tokenAsCategory(tok); ok {
|
||||
for _, f := range snapshot {
|
||||
if cat == CategoryAll || f.Category == cat {
|
||||
if !seen[f.JobType] {
|
||||
seen[f.JobType] = true
|
||||
result = append(result, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
f, err := findFactory(snapshot, tok)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !seen[f.JobType] {
|
||||
seen[f.JobType] = true
|
||||
result = append(result, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("no job types resolved from %q", tokens)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// tokenAsCategory returns the category and true when tok is a known category
|
||||
// keyword. "all" is treated as a special pseudo-category that matches every
|
||||
// registered handler.
|
||||
func tokenAsCategory(tok string) (JobCategory, bool) {
|
||||
switch tok {
|
||||
case string(CategoryAll):
|
||||
return CategoryAll, true
|
||||
case string(CategoryDefault):
|
||||
return CategoryDefault, true
|
||||
case string(CategoryHeavy):
|
||||
return CategoryHeavy, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func findFactory(factories []HandlerFactory, tok string) (HandlerFactory, error) {
|
||||
for _, f := range factories {
|
||||
if strings.EqualFold(f.JobType, tok) {
|
||||
return f, nil
|
||||
}
|
||||
for _, alias := range f.Aliases {
|
||||
if strings.EqualFold(alias, tok) {
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return HandlerFactory{}, fmt.Errorf("unknown job type %q", tok)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package handlers is an aggregator that blank-imports every plugin worker
|
||||
// handler subpackage so their init() functions register with the handler
|
||||
// registry. Import this package instead of individual subpackages when you
|
||||
// need all handlers available.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/plugin/worker/iceberg" // register iceberg_maintenance handler
|
||||
)
|
||||
@@ -0,0 +1,551 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/parquet-go/parquet-go"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// compactionBin groups small data files from the same partition for merging.
|
||||
type compactionBin struct {
|
||||
PartitionKey string
|
||||
Partition map[int]any
|
||||
Entries []iceberg.ManifestEntry
|
||||
TotalSize int64
|
||||
}
|
||||
|
||||
// compactDataFiles reads manifests to find small Parquet data files, groups
|
||||
// them by partition, reads and merges them using parquet-go, and commits new
|
||||
// manifest entries.
|
||||
func (h *Handler) compactDataFiles(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, error) {
|
||||
meta, metadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
if currentSnap == nil || currentSnap.ManifestList == "" {
|
||||
return "no current snapshot", nil
|
||||
}
|
||||
|
||||
// Read manifest list
|
||||
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, currentSnap.ManifestList)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read manifest list: %w", err)
|
||||
}
|
||||
manifests, err := iceberg.ReadManifestList(bytes.NewReader(manifestListData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse manifest list: %w", err)
|
||||
}
|
||||
|
||||
// Abort if delete manifests exist — the compactor does not apply deletes,
|
||||
// so carrying them through could produce incorrect results.
|
||||
// Also detect multiple partition specs — the compactor writes a single
|
||||
// manifest under the current spec which is invalid for spec-evolved tables.
|
||||
specIDs := make(map[int32]struct{})
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() != iceberg.ManifestContentData {
|
||||
return "compaction skipped: delete manifests present (not yet supported)", nil
|
||||
}
|
||||
specIDs[mf.PartitionSpecID()] = struct{}{}
|
||||
}
|
||||
if len(specIDs) > 1 {
|
||||
return "compaction skipped: multiple partition specs present (not yet supported)", nil
|
||||
}
|
||||
|
||||
// Collect data file entries from data manifests
|
||||
var allEntries []iceberg.ManifestEntry
|
||||
for _, mf := range manifests {
|
||||
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, mf.FilePath())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
allEntries = append(allEntries, entries...)
|
||||
}
|
||||
|
||||
// Build compaction bins: group small files by partition
|
||||
// MinInputFiles is clamped by ParseConfig to [2, ...] so int conversion is safe.
|
||||
bins := buildCompactionBins(allEntries, config.TargetFileSizeBytes, int(config.MinInputFiles))
|
||||
if len(bins) == 0 {
|
||||
return "no files eligible for compaction", nil
|
||||
}
|
||||
|
||||
spec := meta.PartitionSpec()
|
||||
schema := meta.CurrentSchema()
|
||||
version := meta.Version()
|
||||
snapshotID := currentSnap.SnapshotID
|
||||
|
||||
// Compute the snapshot ID for the commit up front so all manifest entries
|
||||
// reference the same snapshot that will actually be committed.
|
||||
newSnapID := time.Now().UnixMilli()
|
||||
|
||||
// Process each bin: read source Parquet files, merge, write output
|
||||
var newManifestEntries []iceberg.ManifestEntry
|
||||
var deletedManifestEntries []iceberg.ManifestEntry
|
||||
totalMerged := 0
|
||||
|
||||
metaDir := path.Join(s3tables.TablesPath, bucketName, tablePath, "metadata")
|
||||
dataDir := path.Join(s3tables.TablesPath, bucketName, tablePath, "data")
|
||||
|
||||
// Track written artifacts so we can clean them up if the commit fails.
|
||||
type artifact struct {
|
||||
dir, fileName string
|
||||
}
|
||||
var writtenArtifacts []artifact
|
||||
committed := false
|
||||
|
||||
defer func() {
|
||||
if committed || len(writtenArtifacts) == 0 {
|
||||
return
|
||||
}
|
||||
// Use a detached context so cleanup completes even if ctx was canceled.
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
for _, a := range writtenArtifacts {
|
||||
if err := deleteFilerFile(cleanupCtx, filerClient, a.dir, a.fileName); err != nil {
|
||||
glog.Warningf("iceberg compact: failed to clean up artifact %s/%s: %v", a.dir, a.fileName, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for binIdx, bin := range bins {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
mergedFileName := fmt.Sprintf("compact-%d-%d-%d.parquet", snapshotID, newSnapID, binIdx)
|
||||
mergedFilePath := path.Join("data", mergedFileName)
|
||||
|
||||
mergedData, recordCount, err := mergeParquetFiles(ctx, filerClient, bucketName, tablePath, bin.Entries)
|
||||
if err != nil {
|
||||
glog.Warningf("iceberg compact: failed to merge bin %d (%d files): %v", binIdx, len(bin.Entries), err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Write merged file to filer
|
||||
if err := ensureFilerDir(ctx, filerClient, dataDir); err != nil {
|
||||
return "", fmt.Errorf("ensure data dir: %w", err)
|
||||
}
|
||||
if err := saveFilerFile(ctx, filerClient, dataDir, mergedFileName, mergedData); err != nil {
|
||||
return "", fmt.Errorf("save merged file: %w", err)
|
||||
}
|
||||
|
||||
// Create new DataFile entry for the merged file
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentData,
|
||||
mergedFilePath,
|
||||
iceberg.ParquetFile,
|
||||
bin.Partition,
|
||||
nil, nil,
|
||||
recordCount,
|
||||
int64(len(mergedData)),
|
||||
)
|
||||
if err != nil {
|
||||
glog.Warningf("iceberg compact: failed to build data file entry for bin %d: %v", binIdx, err)
|
||||
// Clean up the written file
|
||||
_ = deleteFilerFile(ctx, filerClient, dataDir, mergedFileName)
|
||||
continue
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: dataDir, fileName: mergedFileName})
|
||||
|
||||
newEntry := iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusADDED,
|
||||
&newSnapID,
|
||||
nil, nil,
|
||||
dfBuilder.Build(),
|
||||
)
|
||||
newManifestEntries = append(newManifestEntries, newEntry)
|
||||
|
||||
// Mark original entries as deleted
|
||||
for _, entry := range bin.Entries {
|
||||
delEntry := iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusDELETED,
|
||||
&newSnapID,
|
||||
nil, nil,
|
||||
entry.DataFile(),
|
||||
)
|
||||
deletedManifestEntries = append(deletedManifestEntries, delEntry)
|
||||
}
|
||||
|
||||
totalMerged += len(bin.Entries)
|
||||
}
|
||||
|
||||
if len(newManifestEntries) == 0 {
|
||||
return "no bins successfully compacted", nil
|
||||
}
|
||||
|
||||
// Build entries for the new manifest:
|
||||
// - ADDED entries for merged files
|
||||
// - DELETED entries for original files
|
||||
// - EXISTING entries for files that weren't compacted
|
||||
compactedPaths := make(map[string]struct{})
|
||||
for _, entry := range deletedManifestEntries {
|
||||
compactedPaths[entry.DataFile().FilePath()] = struct{}{}
|
||||
}
|
||||
|
||||
var manifestEntries []iceberg.ManifestEntry
|
||||
manifestEntries = append(manifestEntries, newManifestEntries...)
|
||||
manifestEntries = append(manifestEntries, deletedManifestEntries...)
|
||||
|
||||
// Keep existing entries that weren't compacted
|
||||
for _, entry := range allEntries {
|
||||
if _, compacted := compactedPaths[entry.DataFile().FilePath()]; !compacted {
|
||||
existingEntry := iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusEXISTING,
|
||||
func() *int64 { id := entry.SnapshotID(); return &id }(),
|
||||
nil, nil,
|
||||
entry.DataFile(),
|
||||
)
|
||||
manifestEntries = append(manifestEntries, existingEntry)
|
||||
}
|
||||
}
|
||||
|
||||
// Write new manifest
|
||||
var manifestBuf bytes.Buffer
|
||||
manifestFileName := fmt.Sprintf("compact-%d.avro", newSnapID)
|
||||
newManifest, err := iceberg.WriteManifest(
|
||||
path.Join("metadata", manifestFileName),
|
||||
&manifestBuf,
|
||||
version,
|
||||
spec,
|
||||
schema,
|
||||
newSnapID,
|
||||
manifestEntries,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write compact manifest: %w", err)
|
||||
}
|
||||
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, manifestFileName, manifestBuf.Bytes()); err != nil {
|
||||
return "", fmt.Errorf("save compact manifest: %w", err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestFileName})
|
||||
|
||||
// Build manifest list with only the new manifest (the early abort at the
|
||||
// top of this function guarantees no delete manifests are present).
|
||||
allManifests := []iceberg.ManifestFile{newManifest}
|
||||
|
||||
// Write new manifest list
|
||||
var manifestListBuf bytes.Buffer
|
||||
seqNum := currentSnap.SequenceNumber + 1
|
||||
err = iceberg.WriteManifestList(version, &manifestListBuf, newSnapID, &snapshotID, &seqNum, 0, allManifests)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write compact manifest list: %w", err)
|
||||
}
|
||||
|
||||
manifestListFileName := fmt.Sprintf("snap-%d.avro", newSnapID)
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, manifestListFileName, manifestListBuf.Bytes()); err != nil {
|
||||
return "", fmt.Errorf("save compact manifest list: %w", err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestListFileName})
|
||||
|
||||
// Commit: add new snapshot and update main branch ref
|
||||
manifestListLocation := path.Join("metadata", manifestListFileName)
|
||||
err = h.commitWithRetry(ctx, filerClient, bucketName, tablePath, metadataFileName, config, func(currentMeta table.Metadata, builder *table.MetadataBuilder) error {
|
||||
// Guard: verify table head hasn't advanced since we planned.
|
||||
cs := currentMeta.CurrentSnapshot()
|
||||
if cs == nil || cs.SnapshotID != snapshotID {
|
||||
return errStalePlan
|
||||
}
|
||||
|
||||
newSnapshot := &table.Snapshot{
|
||||
SnapshotID: newSnapID,
|
||||
ParentSnapshotID: &snapshotID,
|
||||
SequenceNumber: seqNum,
|
||||
TimestampMs: newSnapID,
|
||||
ManifestList: manifestListLocation,
|
||||
Summary: &table.Summary{
|
||||
Operation: table.OpReplace,
|
||||
Properties: map[string]string{
|
||||
"maintenance": "compact_data_files",
|
||||
"merged-files": fmt.Sprintf("%d", totalMerged),
|
||||
"new-files": fmt.Sprintf("%d", len(newManifestEntries)),
|
||||
"compaction-bins": fmt.Sprintf("%d", len(bins)),
|
||||
},
|
||||
},
|
||||
SchemaID: func() *int {
|
||||
id := schema.ID
|
||||
return &id
|
||||
}(),
|
||||
}
|
||||
if err := builder.AddSnapshot(newSnapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
return builder.SetSnapshotRef(table.MainBranch, newSnapID, table.BranchRef)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("commit compaction: %w", err)
|
||||
}
|
||||
|
||||
committed = true
|
||||
return fmt.Sprintf("compacted %d files into %d (across %d bins)", totalMerged, len(newManifestEntries), len(bins)), nil
|
||||
}
|
||||
|
||||
// buildCompactionBins groups small data files by partition for bin-packing.
|
||||
// A file is "small" if it's below targetSize. A bin must have at least
|
||||
// minFiles entries to be worth compacting.
|
||||
func buildCompactionBins(entries []iceberg.ManifestEntry, targetSize int64, minFiles int) []compactionBin {
|
||||
if minFiles < 2 {
|
||||
minFiles = 2
|
||||
}
|
||||
|
||||
// Group entries by partition key
|
||||
groups := make(map[string]*compactionBin)
|
||||
for _, entry := range entries {
|
||||
df := entry.DataFile()
|
||||
if df.FileFormat() != iceberg.ParquetFile {
|
||||
continue
|
||||
}
|
||||
if df.FileSizeBytes() >= targetSize {
|
||||
continue
|
||||
}
|
||||
|
||||
partKey := partitionKey(df.Partition())
|
||||
bin, ok := groups[partKey]
|
||||
if !ok {
|
||||
bin = &compactionBin{
|
||||
PartitionKey: partKey,
|
||||
Partition: df.Partition(),
|
||||
}
|
||||
groups[partKey] = bin
|
||||
}
|
||||
bin.Entries = append(bin.Entries, entry)
|
||||
bin.TotalSize += df.FileSizeBytes()
|
||||
}
|
||||
|
||||
// Filter to bins with enough files, splitting oversized bins
|
||||
var result []compactionBin
|
||||
for _, bin := range groups {
|
||||
if len(bin.Entries) < minFiles {
|
||||
continue
|
||||
}
|
||||
if bin.TotalSize <= targetSize {
|
||||
result = append(result, *bin)
|
||||
} else {
|
||||
result = append(result, splitOversizedBin(*bin, targetSize, minFiles)...)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by partition key for deterministic order
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].PartitionKey < result[j].PartitionKey
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// splitOversizedBin splits a bin whose total size exceeds targetSize into
|
||||
// sub-bins that each stay under targetSize while meeting minFiles.
|
||||
func splitOversizedBin(bin compactionBin, targetSize int64, minFiles int) []compactionBin {
|
||||
var bins []compactionBin
|
||||
current := compactionBin{
|
||||
PartitionKey: bin.PartitionKey,
|
||||
Partition: bin.Partition,
|
||||
}
|
||||
for _, entry := range bin.Entries {
|
||||
if current.TotalSize > 0 && current.TotalSize+entry.DataFile().FileSizeBytes() > targetSize && len(current.Entries) >= minFiles {
|
||||
bins = append(bins, current)
|
||||
current = compactionBin{
|
||||
PartitionKey: bin.PartitionKey,
|
||||
Partition: bin.Partition,
|
||||
}
|
||||
}
|
||||
current.Entries = append(current.Entries, entry)
|
||||
current.TotalSize += entry.DataFile().FileSizeBytes()
|
||||
}
|
||||
if len(current.Entries) >= minFiles {
|
||||
bins = append(bins, current)
|
||||
}
|
||||
return bins
|
||||
}
|
||||
|
||||
// partitionKey creates a string key from a partition map for grouping.
|
||||
// Values are JSON-encoded to avoid ambiguity when values contain commas or '='.
|
||||
func partitionKey(partition map[int]any) string {
|
||||
if len(partition) == 0 {
|
||||
return "__unpartitioned__"
|
||||
}
|
||||
|
||||
// Sort field IDs for deterministic key
|
||||
ids := make([]int, 0, len(partition))
|
||||
for id := range partition {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Ints(ids)
|
||||
|
||||
var parts []string
|
||||
for _, id := range ids {
|
||||
v, err := json.Marshal(partition[id])
|
||||
if err != nil {
|
||||
v = []byte(fmt.Sprintf("%x", fmt.Sprintf("%v", partition[id])))
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d=%s", id, v))
|
||||
}
|
||||
return strings.Join(parts, "\x00")
|
||||
}
|
||||
|
||||
// mergeParquetFiles reads multiple small Parquet files and merges them into
|
||||
// a single Parquet file. It reads rows from each source and writes them to
|
||||
// the output using the schema from the first file.
|
||||
//
|
||||
// Files are loaded into memory (appropriate for compacting small files).
|
||||
func mergeParquetFiles(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
entries []iceberg.ManifestEntry,
|
||||
) ([]byte, int64, error) {
|
||||
if len(entries) == 0 {
|
||||
return nil, 0, fmt.Errorf("no entries to merge")
|
||||
}
|
||||
|
||||
// Read all source files and create parquet readers
|
||||
type sourceFile struct {
|
||||
reader *parquet.Reader
|
||||
data []byte
|
||||
}
|
||||
var sources []sourceFile
|
||||
defer func() {
|
||||
for _, src := range sources {
|
||||
if src.reader != nil {
|
||||
src.reader.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var parquetSchema *parquet.Schema
|
||||
for _, entry := range entries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, 0, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
data, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, entry.DataFile().FilePath())
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read parquet file %s: %w", entry.DataFile().FilePath(), err)
|
||||
}
|
||||
|
||||
reader := parquet.NewReader(bytes.NewReader(data))
|
||||
readerSchema := reader.Schema()
|
||||
if parquetSchema == nil {
|
||||
parquetSchema = readerSchema
|
||||
} else if !schemasEqual(parquetSchema, readerSchema) {
|
||||
return nil, 0, fmt.Errorf("schema mismatch in %s: cannot merge files with different schemas", entry.DataFile().FilePath())
|
||||
}
|
||||
sources = append(sources, sourceFile{reader: reader, data: data})
|
||||
}
|
||||
|
||||
if parquetSchema == nil {
|
||||
return nil, 0, fmt.Errorf("no parquet schema found")
|
||||
}
|
||||
|
||||
// Write merged output
|
||||
var outputBuf bytes.Buffer
|
||||
writer := parquet.NewWriter(&outputBuf, parquetSchema)
|
||||
|
||||
var totalRows int64
|
||||
rows := make([]parquet.Row, 256)
|
||||
|
||||
for _, src := range sources {
|
||||
for {
|
||||
n, err := src.reader.ReadRows(rows)
|
||||
if n > 0 {
|
||||
if _, writeErr := writer.WriteRows(rows[:n]); writeErr != nil {
|
||||
writer.Close()
|
||||
return nil, 0, fmt.Errorf("write rows: %w", writeErr)
|
||||
}
|
||||
totalRows += int64(n)
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
writer.Close()
|
||||
return nil, 0, fmt.Errorf("read rows: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, 0, fmt.Errorf("close writer: %w", err)
|
||||
}
|
||||
|
||||
return outputBuf.Bytes(), totalRows, nil
|
||||
}
|
||||
|
||||
// schemasEqual compares two parquet schemas structurally.
|
||||
func schemasEqual(a, b *parquet.Schema) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return parquet.EqualNodes(a, b)
|
||||
}
|
||||
|
||||
// ensureFilerDir ensures a directory exists in the filer.
|
||||
func ensureFilerDir(ctx context.Context, client filer_pb.SeaweedFilerClient, dirPath string) error {
|
||||
parentDir := path.Dir(dirPath)
|
||||
dirName := path.Base(dirPath)
|
||||
|
||||
_, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: parentDir,
|
||||
Name: dirName,
|
||||
})
|
||||
if err == nil {
|
||||
return nil // already exists
|
||||
}
|
||||
if !errors.Is(err, filer_pb.ErrNotFound) && status.Code(err) != codes.NotFound {
|
||||
return fmt.Errorf("lookup dir %s: %w", dirPath, err)
|
||||
}
|
||||
|
||||
resp, createErr := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: parentDir,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: dirName,
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0755),
|
||||
},
|
||||
},
|
||||
})
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if resp.Error != "" && !strings.Contains(resp.Error, "exist") {
|
||||
return fmt.Errorf("create dir %s: %s", dirPath, resp.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
)
|
||||
|
||||
const (
|
||||
jobType = "iceberg_maintenance"
|
||||
|
||||
defaultSnapshotRetentionHours = 168 // 7 days
|
||||
defaultMaxSnapshotsToKeep = 5
|
||||
defaultOrphanOlderThanHours = 72
|
||||
defaultMaxCommitRetries = 5
|
||||
defaultTargetFileSizeBytes = 256 * 1024 * 1024
|
||||
defaultMinInputFiles = 5
|
||||
defaultOperations = "all"
|
||||
)
|
||||
|
||||
// Config holds parsed worker config values.
|
||||
type Config struct {
|
||||
SnapshotRetentionHours int64
|
||||
MaxSnapshotsToKeep int64
|
||||
OrphanOlderThanHours int64
|
||||
MaxCommitRetries int64
|
||||
TargetFileSizeBytes int64
|
||||
MinInputFiles int64
|
||||
Operations string
|
||||
}
|
||||
|
||||
// ParseConfig extracts an iceberg maintenance Config from plugin config values.
|
||||
// Values are clamped to safe minimums to prevent misconfiguration.
|
||||
func ParseConfig(values map[string]*plugin_pb.ConfigValue) Config {
|
||||
cfg := Config{
|
||||
SnapshotRetentionHours: readInt64Config(values, "snapshot_retention_hours", defaultSnapshotRetentionHours),
|
||||
MaxSnapshotsToKeep: readInt64Config(values, "max_snapshots_to_keep", defaultMaxSnapshotsToKeep),
|
||||
OrphanOlderThanHours: readInt64Config(values, "orphan_older_than_hours", defaultOrphanOlderThanHours),
|
||||
MaxCommitRetries: readInt64Config(values, "max_commit_retries", defaultMaxCommitRetries),
|
||||
TargetFileSizeBytes: readInt64Config(values, "target_file_size_bytes", defaultTargetFileSizeBytes),
|
||||
MinInputFiles: readInt64Config(values, "min_input_files", defaultMinInputFiles),
|
||||
Operations: readStringConfig(values, "operations", defaultOperations),
|
||||
}
|
||||
|
||||
// Clamp to safe minimums using the default constants
|
||||
if cfg.SnapshotRetentionHours <= 0 {
|
||||
cfg.SnapshotRetentionHours = defaultSnapshotRetentionHours
|
||||
}
|
||||
if cfg.MaxSnapshotsToKeep <= 0 {
|
||||
cfg.MaxSnapshotsToKeep = defaultMaxSnapshotsToKeep
|
||||
}
|
||||
if cfg.OrphanOlderThanHours <= 0 {
|
||||
cfg.OrphanOlderThanHours = defaultOrphanOlderThanHours
|
||||
}
|
||||
if cfg.MaxCommitRetries <= 0 {
|
||||
cfg.MaxCommitRetries = defaultMaxCommitRetries
|
||||
}
|
||||
if cfg.TargetFileSizeBytes <= 0 {
|
||||
cfg.TargetFileSizeBytes = defaultTargetFileSizeBytes
|
||||
}
|
||||
if cfg.MinInputFiles < 2 {
|
||||
cfg.MinInputFiles = defaultMinInputFiles
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// parseOperations returns the ordered list of maintenance operations to execute.
|
||||
// Order follows Iceberg best practices: compact → expire_snapshots → remove_orphans → rewrite_manifests.
|
||||
// Returns an error if any unknown operation is specified or the result would be empty.
|
||||
func parseOperations(ops string) ([]string, error) {
|
||||
ops = strings.TrimSpace(strings.ToLower(ops))
|
||||
if ops == "" || ops == "all" {
|
||||
return []string{"compact", "expire_snapshots", "remove_orphans", "rewrite_manifests"}, nil
|
||||
}
|
||||
|
||||
validOps := map[string]struct{}{
|
||||
"compact": {},
|
||||
"expire_snapshots": {},
|
||||
"remove_orphans": {},
|
||||
"rewrite_manifests": {},
|
||||
}
|
||||
|
||||
requested := make(map[string]struct{})
|
||||
for _, op := range strings.Split(ops, ",") {
|
||||
op = strings.TrimSpace(op)
|
||||
if op == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := validOps[op]; !ok {
|
||||
return nil, fmt.Errorf("unknown maintenance operation %q (valid: compact, expire_snapshots, remove_orphans, rewrite_manifests)", op)
|
||||
}
|
||||
requested[op] = struct{}{}
|
||||
}
|
||||
|
||||
// Return in canonical order: compact → expire_snapshots → remove_orphans → rewrite_manifests
|
||||
canonicalOrder := []string{"compact", "expire_snapshots", "remove_orphans", "rewrite_manifests"}
|
||||
var result []string
|
||||
for _, op := range canonicalOrder {
|
||||
if _, ok := requested[op]; ok {
|
||||
result = append(result, op)
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("no valid maintenance operations specified")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func extractMetadataVersion(metadataFileName string) int {
|
||||
// Parse "v3.metadata.json" or "v3-{nonce}.metadata.json" → 3
|
||||
name := strings.TrimPrefix(metadataFileName, "v")
|
||||
name = strings.TrimSuffix(name, ".metadata.json")
|
||||
// Strip any nonce suffix (e.g. "3-1709766000" → "3")
|
||||
if dashIdx := strings.Index(name, "-"); dashIdx > 0 {
|
||||
name = name[:dashIdx]
|
||||
}
|
||||
version, _ := strconv.Atoi(name)
|
||||
return version
|
||||
}
|
||||
|
||||
// readStringConfig reads a string value from plugin config, with fallback.
|
||||
func readStringConfig(values map[string]*plugin_pb.ConfigValue, field string, fallback string) string {
|
||||
if values == nil {
|
||||
return fallback
|
||||
}
|
||||
value := values[field]
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
switch kind := value.Kind.(type) {
|
||||
case *plugin_pb.ConfigValue_StringValue:
|
||||
return kind.StringValue
|
||||
case *plugin_pb.ConfigValue_Int64Value:
|
||||
return strconv.FormatInt(kind.Int64Value, 10)
|
||||
case *plugin_pb.ConfigValue_DoubleValue:
|
||||
return strconv.FormatFloat(kind.DoubleValue, 'f', -1, 64)
|
||||
case *plugin_pb.ConfigValue_BoolValue:
|
||||
return strconv.FormatBool(kind.BoolValue)
|
||||
default:
|
||||
glog.V(1).Infof("readStringConfig: unexpected config value type %T for field %q, using fallback", value.Kind, field)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// readInt64Config reads an int64 value from plugin config, with fallback.
|
||||
func readInt64Config(values map[string]*plugin_pb.ConfigValue, field string, fallback int64) int64 {
|
||||
if values == nil {
|
||||
return fallback
|
||||
}
|
||||
value := values[field]
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
switch kind := value.Kind.(type) {
|
||||
case *plugin_pb.ConfigValue_Int64Value:
|
||||
return kind.Int64Value
|
||||
case *plugin_pb.ConfigValue_DoubleValue:
|
||||
return int64(kind.DoubleValue)
|
||||
case *plugin_pb.ConfigValue_StringValue:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(kind.StringValue), 10, 64)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
case *plugin_pb.ConfigValue_BoolValue:
|
||||
if kind.BoolValue {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
glog.V(1).Infof("readInt64Config: unexpected config value type %T for field %q, using fallback", value.Kind, field)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
)
|
||||
|
||||
// tableInfo captures metadata about a table for detection/execution.
|
||||
type tableInfo struct {
|
||||
BucketName string
|
||||
Namespace string
|
||||
TableName string
|
||||
TablePath string // namespace/tableName
|
||||
Metadata table.Metadata
|
||||
}
|
||||
|
||||
// scanTablesForMaintenance enumerates table buckets and their tables,
|
||||
// evaluating which ones need maintenance based on metadata thresholds.
|
||||
// When limit > 0 the scan stops after collecting limit+1 results so the
|
||||
// caller can determine whether more tables remain (HasMore).
|
||||
func (h *Handler) scanTablesForMaintenance(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
config Config,
|
||||
bucketFilter, namespaceFilter, tableFilter string,
|
||||
limit int,
|
||||
) ([]tableInfo, error) {
|
||||
var tables []tableInfo
|
||||
|
||||
// Compile wildcard matchers once (nil = match all)
|
||||
bucketMatchers := wildcard.CompileWildcardMatchers(bucketFilter)
|
||||
nsMatchers := wildcard.CompileWildcardMatchers(namespaceFilter)
|
||||
tableMatchers := wildcard.CompileWildcardMatchers(tableFilter)
|
||||
|
||||
// List entries under /buckets to find table buckets
|
||||
bucketsPath := s3tables.TablesPath
|
||||
bucketEntries, err := listFilerEntries(ctx, filerClient, bucketsPath, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list buckets: %w", err)
|
||||
}
|
||||
|
||||
for _, bucketEntry := range bucketEntries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return tables, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if !bucketEntry.IsDirectory || !s3tables.IsTableBucketEntry(bucketEntry) {
|
||||
continue
|
||||
}
|
||||
bucketName := bucketEntry.Name
|
||||
if !wildcard.MatchesAnyWildcard(bucketMatchers, bucketName) {
|
||||
continue
|
||||
}
|
||||
|
||||
// List namespaces within the bucket
|
||||
bucketPath := path.Join(bucketsPath, bucketName)
|
||||
nsEntries, err := listFilerEntries(ctx, filerClient, bucketPath, "")
|
||||
if err != nil {
|
||||
glog.Warningf("iceberg maintenance: failed to list namespaces in bucket %s: %v", bucketName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, nsEntry := range nsEntries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return tables, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if !nsEntry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
nsName := nsEntry.Name
|
||||
if !wildcard.MatchesAnyWildcard(nsMatchers, nsName) {
|
||||
continue
|
||||
}
|
||||
// Skip internal directories
|
||||
if strings.HasPrefix(nsName, ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
// List tables within the namespace
|
||||
nsPath := path.Join(bucketPath, nsName)
|
||||
tableEntries, err := listFilerEntries(ctx, filerClient, nsPath, "")
|
||||
if err != nil {
|
||||
glog.Warningf("iceberg maintenance: failed to list tables in %s/%s: %v", bucketName, nsName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, tableEntry := range tableEntries {
|
||||
if !tableEntry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
tblName := tableEntry.Name
|
||||
if !wildcard.MatchesAnyWildcard(tableMatchers, tblName) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this entry has table metadata
|
||||
metadataBytes, ok := tableEntry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
if !ok || len(metadataBytes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the internal metadata to get FullMetadata
|
||||
var internalMeta struct {
|
||||
Metadata *struct {
|
||||
FullMetadata json.RawMessage `json:"fullMetadata,omitempty"`
|
||||
} `json:"metadata,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(metadataBytes, &internalMeta); err != nil {
|
||||
glog.V(2).Infof("iceberg maintenance: skipping %s/%s/%s: cannot parse metadata: %v", bucketName, nsName, tblName, err)
|
||||
continue
|
||||
}
|
||||
if internalMeta.Metadata == nil || len(internalMeta.Metadata.FullMetadata) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
icebergMeta, err := table.ParseMetadataBytes(internalMeta.Metadata.FullMetadata)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("iceberg maintenance: skipping %s/%s/%s: cannot parse iceberg metadata: %v", bucketName, nsName, tblName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if needsMaintenance(icebergMeta, config) {
|
||||
tables = append(tables, tableInfo{
|
||||
BucketName: bucketName,
|
||||
Namespace: nsName,
|
||||
TableName: tblName,
|
||||
TablePath: path.Join(nsName, tblName),
|
||||
Metadata: icebergMeta,
|
||||
})
|
||||
if limit > 0 && len(tables) > limit {
|
||||
return tables, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
// needsMaintenance checks if a table needs any maintenance based on
|
||||
// metadata-only thresholds (no manifest reading).
|
||||
func needsMaintenance(meta table.Metadata, config Config) bool {
|
||||
snapshots := meta.Snapshots()
|
||||
if len(snapshots) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check snapshot count
|
||||
if int64(len(snapshots)) > config.MaxSnapshotsToKeep {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check oldest snapshot age
|
||||
retentionMs := config.SnapshotRetentionHours * 3600 * 1000
|
||||
nowMs := time.Now().UnixMilli()
|
||||
for _, snap := range snapshots {
|
||||
if nowMs-snap.TimestampMs > retentionMs {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// buildMaintenanceProposal creates a JobProposal for a table needing maintenance.
|
||||
func (h *Handler) buildMaintenanceProposal(t tableInfo, filerAddress string) *plugin_pb.JobProposal {
|
||||
dedupeKey := fmt.Sprintf("iceberg_maintenance:%s/%s/%s", t.BucketName, t.Namespace, t.TableName)
|
||||
|
||||
snapshotCount := len(t.Metadata.Snapshots())
|
||||
summary := fmt.Sprintf("Maintain %s/%s/%s (%d snapshots)", t.BucketName, t.Namespace, t.TableName, snapshotCount)
|
||||
|
||||
return &plugin_pb.JobProposal{
|
||||
ProposalId: fmt.Sprintf("iceberg-%s-%s-%s-%d", t.BucketName, t.Namespace, t.TableName, time.Now().UnixMilli()),
|
||||
DedupeKey: dedupeKey,
|
||||
JobType: jobType,
|
||||
Priority: plugin_pb.JobPriority_JOB_PRIORITY_NORMAL,
|
||||
Summary: summary,
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"bucket_name": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.BucketName}},
|
||||
"namespace": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.Namespace}},
|
||||
"table_name": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.TableName}},
|
||||
"table_path": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: t.TablePath}},
|
||||
"filer_address": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: filerAddress}},
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"bucket": t.BucketName,
|
||||
"namespace": t.Namespace,
|
||||
"table": t.TableName,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake filer server for execution tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fakeFilerServer is an in-memory filer that implements the gRPC methods used
|
||||
// by the iceberg maintenance handler.
|
||||
type fakeFilerServer struct {
|
||||
filer_pb.UnimplementedSeaweedFilerServer
|
||||
|
||||
mu sync.Mutex
|
||||
entries map[string]map[string]*filer_pb.Entry // dir → name → entry
|
||||
|
||||
// Counters for assertions
|
||||
createCalls int
|
||||
updateCalls int
|
||||
deleteCalls int
|
||||
}
|
||||
|
||||
func newFakeFilerServer() *fakeFilerServer {
|
||||
return &fakeFilerServer{
|
||||
entries: make(map[string]map[string]*filer_pb.Entry),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeFilerServer) putEntry(dir, name string, entry *filer_pb.Entry) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.entries[dir]; !ok {
|
||||
f.entries[dir] = make(map[string]*filer_pb.Entry)
|
||||
}
|
||||
f.entries[dir][name] = entry
|
||||
}
|
||||
|
||||
func (f *fakeFilerServer) getEntry(dir, name string) *filer_pb.Entry {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if dirEntries, ok := f.entries[dir]; ok {
|
||||
return dirEntries[name]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeFilerServer) listDir(dir string) []*filer_pb.Entry {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
dirEntries, ok := f.entries[dir]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]*filer_pb.Entry, 0, len(dirEntries))
|
||||
for _, e := range dirEntries {
|
||||
result = append(result, e)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func (f *fakeFilerServer) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
entry := f.getEntry(req.Directory, req.Name)
|
||||
if entry == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "entry not found: %s/%s", req.Directory, req.Name)
|
||||
}
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: entry}, nil
|
||||
}
|
||||
|
||||
func (f *fakeFilerServer) ListEntries(req *filer_pb.ListEntriesRequest, stream grpc.ServerStreamingServer[filer_pb.ListEntriesResponse]) error {
|
||||
entries := f.listDir(req.Directory)
|
||||
if entries == nil {
|
||||
return nil // empty directory
|
||||
}
|
||||
|
||||
var sent uint32
|
||||
for _, entry := range entries {
|
||||
if req.Prefix != "" && !strings.HasPrefix(entry.Name, req.Prefix) {
|
||||
continue
|
||||
}
|
||||
if req.StartFromFileName != "" {
|
||||
if req.InclusiveStartFrom {
|
||||
if entry.Name < req.StartFromFileName {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if entry.Name <= req.StartFromFileName {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: entry}); err != nil {
|
||||
return err
|
||||
}
|
||||
sent++
|
||||
if req.Limit > 0 && sent >= req.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeFilerServer) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
||||
f.mu.Lock()
|
||||
f.createCalls++
|
||||
f.mu.Unlock()
|
||||
|
||||
f.putEntry(req.Directory, req.Entry.Name, req.Entry)
|
||||
return &filer_pb.CreateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeFilerServer) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
|
||||
f.mu.Lock()
|
||||
f.updateCalls++
|
||||
f.mu.Unlock()
|
||||
|
||||
f.putEntry(req.Directory, req.Entry.Name, req.Entry)
|
||||
return &filer_pb.UpdateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeFilerServer) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
|
||||
f.mu.Lock()
|
||||
f.deleteCalls++
|
||||
|
||||
if dirEntries, ok := f.entries[req.Directory]; ok {
|
||||
delete(dirEntries, req.Name)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
return &filer_pb.DeleteEntryResponse{}, nil
|
||||
}
|
||||
|
||||
// startFakeFiler starts a gRPC server and returns a connected client.
|
||||
func startFakeFiler(t *testing.T) (*fakeFilerServer, filer_pb.SeaweedFilerClient) {
|
||||
t.Helper()
|
||||
fakeServer := newFakeFilerServer()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
server := grpc.NewServer()
|
||||
filer_pb.RegisterSeaweedFilerServer(server, fakeServer)
|
||||
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(server.GracefulStop)
|
||||
|
||||
conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
|
||||
return fakeServer, filer_pb.NewSeaweedFilerClient(conn)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers to populate the fake filer with Iceberg table state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// tableSetup holds the state needed to set up a test table in the fake filer.
|
||||
type tableSetup struct {
|
||||
BucketName string
|
||||
Namespace string
|
||||
TableName string
|
||||
Snapshots []table.Snapshot
|
||||
}
|
||||
|
||||
func (ts tableSetup) tablePath() string {
|
||||
return path.Join(ts.Namespace, ts.TableName)
|
||||
}
|
||||
|
||||
// populateTable creates the directory hierarchy and metadata entries in the
|
||||
// fake filer for a table, writes manifest files referenced by snapshots,
|
||||
// and returns the built metadata.
|
||||
func populateTable(t *testing.T, fs *fakeFilerServer, setup tableSetup) table.Metadata {
|
||||
t.Helper()
|
||||
|
||||
meta := buildTestMetadata(t, setup.Snapshots)
|
||||
fullMetadataJSON, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal metadata: %v", err)
|
||||
}
|
||||
|
||||
// Build internal metadata xattr
|
||||
internalMeta := map[string]interface{}{
|
||||
"metadataVersion": 1,
|
||||
"metadata": map[string]interface{}{
|
||||
"fullMetadata": json.RawMessage(fullMetadataJSON),
|
||||
},
|
||||
}
|
||||
xattr, err := json.Marshal(internalMeta)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal xattr: %v", err)
|
||||
}
|
||||
|
||||
bucketsPath := s3tables.TablesPath // "/buckets"
|
||||
bucketPath := path.Join(bucketsPath, setup.BucketName)
|
||||
nsPath := path.Join(bucketPath, setup.Namespace)
|
||||
tableFilerPath := path.Join(nsPath, setup.TableName)
|
||||
|
||||
// Register bucket entry (marked as table bucket)
|
||||
fs.putEntry(bucketsPath, setup.BucketName, &filer_pb.Entry{
|
||||
Name: setup.BucketName,
|
||||
IsDirectory: true,
|
||||
Extended: map[string][]byte{
|
||||
s3tables.ExtendedKeyTableBucket: []byte("true"),
|
||||
},
|
||||
})
|
||||
|
||||
// Register namespace entry
|
||||
fs.putEntry(bucketPath, setup.Namespace, &filer_pb.Entry{
|
||||
Name: setup.Namespace,
|
||||
IsDirectory: true,
|
||||
})
|
||||
|
||||
// Register table entry with metadata xattr
|
||||
fs.putEntry(nsPath, setup.TableName, &filer_pb.Entry{
|
||||
Name: setup.TableName,
|
||||
IsDirectory: true,
|
||||
Extended: map[string][]byte{
|
||||
s3tables.ExtendedKeyMetadata: xattr,
|
||||
},
|
||||
})
|
||||
|
||||
// Create metadata/ and data/ directory placeholders
|
||||
metaDir := path.Join(tableFilerPath, "metadata")
|
||||
dataDir := path.Join(tableFilerPath, "data")
|
||||
|
||||
// Write manifest files for each snapshot that has a ManifestList
|
||||
schema := meta.CurrentSchema()
|
||||
spec := meta.PartitionSpec()
|
||||
version := meta.Version()
|
||||
|
||||
for _, snap := range setup.Snapshots {
|
||||
if snap.ManifestList == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a minimal manifest with one dummy entry for this snapshot
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentData,
|
||||
fmt.Sprintf("data/snap-%d-data.parquet", snap.SnapshotID),
|
||||
iceberg.ParquetFile,
|
||||
map[int]any{},
|
||||
nil, nil,
|
||||
10, // recordCount
|
||||
4096, // fileSizeBytes
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build data file for snap %d: %v", snap.SnapshotID, err)
|
||||
}
|
||||
snapID := snap.SnapshotID
|
||||
entry := iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusADDED,
|
||||
&snapID,
|
||||
nil, nil,
|
||||
dfBuilder.Build(),
|
||||
)
|
||||
|
||||
// Write manifest
|
||||
manifestFileName := fmt.Sprintf("manifest-%d.avro", snap.SnapshotID)
|
||||
manifestPath := path.Join("metadata", manifestFileName)
|
||||
var manifestBuf bytes.Buffer
|
||||
mf, err := iceberg.WriteManifest(manifestPath, &manifestBuf, version, spec, schema, snap.SnapshotID, []iceberg.ManifestEntry{entry})
|
||||
if err != nil {
|
||||
t.Fatalf("write manifest for snap %d: %v", snap.SnapshotID, err)
|
||||
}
|
||||
|
||||
fs.putEntry(metaDir, manifestFileName, &filer_pb.Entry{
|
||||
Name: manifestFileName,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
FileSize: uint64(manifestBuf.Len()),
|
||||
},
|
||||
Content: manifestBuf.Bytes(),
|
||||
})
|
||||
|
||||
// Write manifest list
|
||||
manifestListFileName := path.Base(snap.ManifestList)
|
||||
var mlBuf bytes.Buffer
|
||||
parentSnap := snap.ParentSnapshotID
|
||||
seqNum := snap.SequenceNumber
|
||||
if err := iceberg.WriteManifestList(version, &mlBuf, snap.SnapshotID, parentSnap, &seqNum, 0, []iceberg.ManifestFile{mf}); err != nil {
|
||||
t.Fatalf("write manifest list for snap %d: %v", snap.SnapshotID, err)
|
||||
}
|
||||
|
||||
fs.putEntry(metaDir, manifestListFileName, &filer_pb.Entry{
|
||||
Name: manifestListFileName,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
FileSize: uint64(mlBuf.Len()),
|
||||
},
|
||||
Content: mlBuf.Bytes(),
|
||||
})
|
||||
|
||||
// Write a dummy data file
|
||||
dataFileName := fmt.Sprintf("snap-%d-data.parquet", snap.SnapshotID)
|
||||
fs.putEntry(dataDir, dataFileName, &filer_pb.Entry{
|
||||
Name: dataFileName,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
FileSize: 4096,
|
||||
},
|
||||
Content: []byte("fake-parquet-data"),
|
||||
})
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recording senders for Execute tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type recordingExecutionSender struct {
|
||||
mu sync.Mutex
|
||||
progress []*plugin_pb.JobProgressUpdate
|
||||
completed *plugin_pb.JobCompleted
|
||||
}
|
||||
|
||||
func (r *recordingExecutionSender) SendProgress(p *plugin_pb.JobProgressUpdate) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.progress = append(r.progress, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingExecutionSender) SendCompleted(c *plugin_pb.JobCompleted) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.completed = c
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExpireSnapshotsExecution(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
setup := tableSetup{
|
||||
BucketName: "test-bucket",
|
||||
Namespace: "analytics",
|
||||
TableName: "events",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
{SnapshotID: 2, TimestampMs: now + 1, ManifestList: "metadata/snap-2.avro"},
|
||||
{SnapshotID: 3, TimestampMs: now + 2, ManifestList: "metadata/snap-3.avro"},
|
||||
},
|
||||
}
|
||||
populateTable(t, fs, setup)
|
||||
|
||||
handler := NewHandler(nil)
|
||||
config := Config{
|
||||
SnapshotRetentionHours: 0, // expire everything eligible
|
||||
MaxSnapshotsToKeep: 1, // keep only 1
|
||||
MaxCommitRetries: 3,
|
||||
Operations: "expire_snapshots",
|
||||
}
|
||||
|
||||
result, err := handler.expireSnapshots(context.Background(), client, setup.BucketName, setup.tablePath(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("expireSnapshots failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "expired") {
|
||||
t.Errorf("expected result to mention expiration, got %q", result)
|
||||
}
|
||||
t.Logf("expireSnapshots result: %s", result)
|
||||
|
||||
// Verify the metadata was updated (update calls > 0)
|
||||
fs.mu.Lock()
|
||||
updates := fs.updateCalls
|
||||
fs.mu.Unlock()
|
||||
if updates == 0 {
|
||||
t.Error("expected at least one UpdateEntry call for xattr update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireSnapshotsNothingToExpire(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
setup := tableSetup{
|
||||
BucketName: "test-bucket",
|
||||
Namespace: "ns",
|
||||
TableName: "tbl",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
},
|
||||
}
|
||||
populateTable(t, fs, setup)
|
||||
|
||||
handler := NewHandler(nil)
|
||||
config := Config{
|
||||
SnapshotRetentionHours: 24 * 365, // very long retention
|
||||
MaxSnapshotsToKeep: 10,
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
result, err := handler.expireSnapshots(context.Background(), client, setup.BucketName, setup.tablePath(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("expireSnapshots failed: %v", err)
|
||||
}
|
||||
if result != "no snapshots expired" {
|
||||
t.Errorf("expected 'no snapshots expired', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveOrphansExecution(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
setup := tableSetup{
|
||||
BucketName: "test-bucket",
|
||||
Namespace: "analytics",
|
||||
TableName: "events",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
},
|
||||
}
|
||||
populateTable(t, fs, setup)
|
||||
|
||||
// Add orphan files (old enough to be removed)
|
||||
metaDir := path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath(), "metadata")
|
||||
dataDir := path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath(), "data")
|
||||
oldTime := time.Now().Add(-200 * time.Hour).Unix()
|
||||
|
||||
fs.putEntry(metaDir, "orphan-old.avro", &filer_pb.Entry{
|
||||
Name: "orphan-old.avro",
|
||||
Attributes: &filer_pb.FuseAttributes{Mtime: oldTime},
|
||||
})
|
||||
fs.putEntry(dataDir, "orphan-data.parquet", &filer_pb.Entry{
|
||||
Name: "orphan-data.parquet",
|
||||
Attributes: &filer_pb.FuseAttributes{Mtime: oldTime},
|
||||
})
|
||||
// Add a recent orphan that should NOT be removed (within safety window)
|
||||
fs.putEntry(dataDir, "recent-orphan.parquet", &filer_pb.Entry{
|
||||
Name: "recent-orphan.parquet",
|
||||
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()},
|
||||
})
|
||||
|
||||
handler := NewHandler(nil)
|
||||
config := Config{
|
||||
OrphanOlderThanHours: 72,
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
result, err := handler.removeOrphans(context.Background(), client, setup.BucketName, setup.tablePath(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("removeOrphans failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "removed 2 orphan") {
|
||||
t.Errorf("expected 2 orphans removed, got %q", result)
|
||||
}
|
||||
|
||||
// Verify orphan files were deleted
|
||||
if fs.getEntry(metaDir, "orphan-old.avro") != nil {
|
||||
t.Error("orphan-old.avro should have been deleted")
|
||||
}
|
||||
if fs.getEntry(dataDir, "orphan-data.parquet") != nil {
|
||||
t.Error("orphan-data.parquet should have been deleted")
|
||||
}
|
||||
// Recent orphan should still exist
|
||||
if fs.getEntry(dataDir, "recent-orphan.parquet") == nil {
|
||||
t.Error("recent-orphan.parquet should NOT have been deleted (within safety window)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveOrphansPreservesReferencedFiles(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
setup := tableSetup{
|
||||
BucketName: "test-bucket",
|
||||
Namespace: "ns",
|
||||
TableName: "tbl",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
},
|
||||
}
|
||||
populateTable(t, fs, setup)
|
||||
|
||||
handler := NewHandler(nil)
|
||||
config := Config{
|
||||
OrphanOlderThanHours: 0, // no safety window — remove immediately
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
result, err := handler.removeOrphans(context.Background(), client, setup.BucketName, setup.tablePath(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("removeOrphans failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "removed 0 orphan") {
|
||||
t.Errorf("expected 0 orphans removed (all files are referenced), got %q", result)
|
||||
}
|
||||
|
||||
// Verify referenced files are still present
|
||||
metaDir := path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath(), "metadata")
|
||||
if fs.getEntry(metaDir, "snap-1.avro") == nil {
|
||||
t.Error("snap-1.avro (referenced manifest list) should not have been deleted")
|
||||
}
|
||||
if fs.getEntry(metaDir, "manifest-1.avro") == nil {
|
||||
t.Error("manifest-1.avro (referenced manifest) should not have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteManifestsExecution(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// Create a table with a single snapshot — we'll add extra small manifests
|
||||
// to the manifest list so there's something to rewrite.
|
||||
setup := tableSetup{
|
||||
BucketName: "test-bucket",
|
||||
Namespace: "analytics",
|
||||
TableName: "events",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
},
|
||||
}
|
||||
meta := populateTable(t, fs, setup)
|
||||
schema := meta.CurrentSchema()
|
||||
spec := meta.PartitionSpec()
|
||||
version := meta.Version()
|
||||
|
||||
// Build 5 small manifests and write them + a manifest list pointing to all of them
|
||||
metaDir := path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath(), "metadata")
|
||||
var allManifests []iceberg.ManifestFile
|
||||
|
||||
for i := 1; i <= 5; i++ {
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentData,
|
||||
fmt.Sprintf("data/rewrite-%d.parquet", i),
|
||||
iceberg.ParquetFile,
|
||||
map[int]any{},
|
||||
nil, nil,
|
||||
1,
|
||||
1024,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build data file %d: %v", i, err)
|
||||
}
|
||||
snapID := int64(1)
|
||||
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build())
|
||||
|
||||
manifestName := fmt.Sprintf("small-manifest-%d.avro", i)
|
||||
var buf bytes.Buffer
|
||||
mf, err := iceberg.WriteManifest(path.Join("metadata", manifestName), &buf, version, spec, schema, 1, []iceberg.ManifestEntry{entry})
|
||||
if err != nil {
|
||||
t.Fatalf("write small manifest %d: %v", i, err)
|
||||
}
|
||||
fs.putEntry(metaDir, manifestName, &filer_pb.Entry{
|
||||
Name: manifestName,
|
||||
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()},
|
||||
Content: buf.Bytes(),
|
||||
})
|
||||
allManifests = append(allManifests, mf)
|
||||
}
|
||||
|
||||
// Overwrite the manifest list with all 5 manifests
|
||||
var mlBuf bytes.Buffer
|
||||
seqNum := int64(1)
|
||||
if err := iceberg.WriteManifestList(version, &mlBuf, 1, nil, &seqNum, 0, allManifests); err != nil {
|
||||
t.Fatalf("write manifest list: %v", err)
|
||||
}
|
||||
fs.putEntry(metaDir, "snap-1.avro", &filer_pb.Entry{
|
||||
Name: "snap-1.avro",
|
||||
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()},
|
||||
Content: mlBuf.Bytes(),
|
||||
})
|
||||
|
||||
handler := NewHandler(nil)
|
||||
config := Config{
|
||||
MinInputFiles: 3, // threshold to trigger rewrite (5 >= 3)
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
result, err := handler.rewriteManifests(context.Background(), client, setup.BucketName, setup.tablePath(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("rewriteManifests failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "rewrote 5 manifests into 1") {
|
||||
t.Errorf("expected '5 manifests into 1', got %q", result)
|
||||
}
|
||||
t.Logf("rewriteManifests result: %s", result)
|
||||
|
||||
// Verify a new metadata file and merged manifest were written
|
||||
fs.mu.Lock()
|
||||
creates := fs.createCalls
|
||||
updates := fs.updateCalls
|
||||
fs.mu.Unlock()
|
||||
|
||||
if creates < 3 {
|
||||
// At minimum: merged manifest, manifest list, new metadata file
|
||||
t.Errorf("expected at least 3 CreateEntry calls, got %d", creates)
|
||||
}
|
||||
if updates == 0 {
|
||||
t.Error("expected at least one UpdateEntry call for xattr update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteManifestsBelowThreshold(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
setup := tableSetup{
|
||||
BucketName: "test-bucket",
|
||||
Namespace: "ns",
|
||||
TableName: "tbl",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
},
|
||||
}
|
||||
populateTable(t, fs, setup)
|
||||
|
||||
handler := NewHandler(nil)
|
||||
config := Config{
|
||||
MinInputFiles: 10, // threshold higher than actual count (1)
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
result, err := handler.rewriteManifests(context.Background(), client, setup.BucketName, setup.tablePath(), config)
|
||||
if err != nil {
|
||||
t.Fatalf("rewriteManifests failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "below threshold") {
|
||||
t.Errorf("expected 'below threshold', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullExecuteFlow(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
setup := tableSetup{
|
||||
BucketName: "test-bucket",
|
||||
Namespace: "analytics",
|
||||
TableName: "events",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
{SnapshotID: 2, TimestampMs: now + 1, ManifestList: "metadata/snap-2.avro"},
|
||||
{SnapshotID: 3, TimestampMs: now + 2, ManifestList: "metadata/snap-3.avro"},
|
||||
},
|
||||
}
|
||||
populateTable(t, fs, setup)
|
||||
|
||||
// Add an orphan
|
||||
metaDir := path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath(), "metadata")
|
||||
fs.putEntry(metaDir, "orphan.avro", &filer_pb.Entry{
|
||||
Name: "orphan.avro",
|
||||
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Add(-200 * time.Hour).Unix()},
|
||||
})
|
||||
|
||||
handler := NewHandler(nil)
|
||||
|
||||
// We need to build the request manually since Execute takes gRPC types
|
||||
// but we're connecting directly
|
||||
request := &plugin_pb.ExecuteJobRequest{
|
||||
Job: &plugin_pb.JobSpec{
|
||||
JobId: "test-job-1",
|
||||
JobType: jobType,
|
||||
Parameters: map[string]*plugin_pb.ConfigValue{
|
||||
"bucket_name": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: setup.BucketName}},
|
||||
"namespace": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: setup.Namespace}},
|
||||
"table_name": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: setup.TableName}},
|
||||
"table_path": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: setup.tablePath()}},
|
||||
"filer_address": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "not-used"}},
|
||||
},
|
||||
},
|
||||
WorkerConfigValues: map[string]*plugin_pb.ConfigValue{
|
||||
"snapshot_retention_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
||||
"max_snapshots_to_keep": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
"orphan_older_than_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 72}},
|
||||
"max_commit_retries": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 3}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 100}}, // high threshold to skip rewrite
|
||||
"operations": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "expire_snapshots,remove_orphans"}},
|
||||
},
|
||||
}
|
||||
|
||||
// Execute uses grpc.NewClient internally, but we need to pass our existing
|
||||
// client. Call operations directly instead of full Execute to avoid the
|
||||
// grpc.NewClient call which requires a real address.
|
||||
workerConfig := ParseConfig(request.GetWorkerConfigValues())
|
||||
ops, err := parseOperations(workerConfig.Operations)
|
||||
if err != nil {
|
||||
t.Fatalf("parseOperations: %v", err)
|
||||
}
|
||||
|
||||
var results []string
|
||||
for _, op := range ops {
|
||||
var opResult string
|
||||
var opErr error
|
||||
switch op {
|
||||
case "expire_snapshots":
|
||||
opResult, opErr = handler.expireSnapshots(context.Background(), client, setup.BucketName, setup.tablePath(), workerConfig)
|
||||
case "remove_orphans":
|
||||
opResult, opErr = handler.removeOrphans(context.Background(), client, setup.BucketName, setup.tablePath(), workerConfig)
|
||||
case "rewrite_manifests":
|
||||
opResult, opErr = handler.rewriteManifests(context.Background(), client, setup.BucketName, setup.tablePath(), workerConfig)
|
||||
}
|
||||
if opErr != nil {
|
||||
t.Fatalf("operation %s failed: %v", op, opErr)
|
||||
}
|
||||
results = append(results, fmt.Sprintf("%s: %s", op, opResult))
|
||||
}
|
||||
|
||||
t.Logf("Full execution results: %s", strings.Join(results, "; "))
|
||||
|
||||
// Verify snapshots were expired
|
||||
if !strings.Contains(results[0], "expired") {
|
||||
t.Errorf("expected snapshot expiration, got %q", results[0])
|
||||
}
|
||||
|
||||
// Verify orphan was removed
|
||||
if !strings.Contains(results[1], "removed") {
|
||||
t.Errorf("expected orphan removal, got %q", results[1])
|
||||
}
|
||||
if fs.getEntry(metaDir, "orphan.avro") != nil {
|
||||
t.Error("orphan.avro should have been deleted")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestDetectWithFakeFiler(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
setup := tableSetup{
|
||||
BucketName: "test-bucket",
|
||||
Namespace: "analytics",
|
||||
TableName: "events",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
{SnapshotID: 2, TimestampMs: now + 1, ManifestList: "metadata/snap-2.avro"},
|
||||
{SnapshotID: 3, TimestampMs: now + 2, ManifestList: "metadata/snap-3.avro"},
|
||||
},
|
||||
}
|
||||
populateTable(t, fs, setup)
|
||||
|
||||
handler := NewHandler(nil)
|
||||
|
||||
config := Config{
|
||||
SnapshotRetentionHours: 0, // everything is expired
|
||||
MaxSnapshotsToKeep: 2, // 3 > 2, needs maintenance
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
tables, err := handler.scanTablesForMaintenance(
|
||||
context.Background(),
|
||||
client,
|
||||
config,
|
||||
"", "", "", // no filters
|
||||
0, // no limit
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("scanTablesForMaintenance failed: %v", err)
|
||||
}
|
||||
|
||||
if len(tables) != 1 {
|
||||
t.Fatalf("expected 1 table needing maintenance, got %d", len(tables))
|
||||
}
|
||||
if tables[0].BucketName != setup.BucketName {
|
||||
t.Errorf("expected bucket %q, got %q", setup.BucketName, tables[0].BucketName)
|
||||
}
|
||||
if tables[0].TableName != setup.TableName {
|
||||
t.Errorf("expected table %q, got %q", setup.TableName, tables[0].TableName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectWithFilters(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
// Create two tables in different buckets
|
||||
setup1 := tableSetup{
|
||||
BucketName: "bucket-a",
|
||||
Namespace: "ns",
|
||||
TableName: "table1",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
{SnapshotID: 2, TimestampMs: now + 1, ManifestList: "metadata/snap-2.avro"},
|
||||
{SnapshotID: 3, TimestampMs: now + 2, ManifestList: "metadata/snap-3.avro"},
|
||||
},
|
||||
}
|
||||
setup2 := tableSetup{
|
||||
BucketName: "bucket-b",
|
||||
Namespace: "ns",
|
||||
TableName: "table2",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 4, TimestampMs: now + 3, ManifestList: "metadata/snap-4.avro"},
|
||||
{SnapshotID: 5, TimestampMs: now + 4, ManifestList: "metadata/snap-5.avro"},
|
||||
{SnapshotID: 6, TimestampMs: now + 5, ManifestList: "metadata/snap-6.avro"},
|
||||
},
|
||||
}
|
||||
populateTable(t, fs, setup1)
|
||||
populateTable(t, fs, setup2)
|
||||
|
||||
handler := NewHandler(nil)
|
||||
config := Config{
|
||||
SnapshotRetentionHours: 0,
|
||||
MaxSnapshotsToKeep: 2,
|
||||
MaxCommitRetries: 3,
|
||||
}
|
||||
|
||||
// Without filter: should find both
|
||||
tables, err := handler.scanTablesForMaintenance(context.Background(), client, config, "", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("scan failed: %v", err)
|
||||
}
|
||||
if len(tables) != 2 {
|
||||
t.Fatalf("expected 2 tables without filter, got %d", len(tables))
|
||||
}
|
||||
|
||||
// With bucket filter: should find only one
|
||||
tables, err = handler.scanTablesForMaintenance(context.Background(), client, config, "bucket-a", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("scan with filter failed: %v", err)
|
||||
}
|
||||
if len(tables) != 1 {
|
||||
t.Fatalf("expected 1 table with bucket filter, got %d", len(tables))
|
||||
}
|
||||
if tables[0].BucketName != "bucket-a" {
|
||||
t.Errorf("expected bucket-a, got %q", tables[0].BucketName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalePlanGuard(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
setup := tableSetup{
|
||||
BucketName: "test-bucket",
|
||||
Namespace: "ns",
|
||||
TableName: "tbl",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
},
|
||||
}
|
||||
populateTable(t, fs, setup)
|
||||
|
||||
handler := NewHandler(nil)
|
||||
|
||||
// Call commitWithRetry with a stale plan that expects a different snapshot
|
||||
config := Config{MaxCommitRetries: 1}
|
||||
staleSnapshotID := int64(999)
|
||||
|
||||
err := handler.commitWithRetry(context.Background(), client, setup.BucketName, setup.tablePath(), "v1.metadata.json", config, func(currentMeta table.Metadata, builder *table.MetadataBuilder) error {
|
||||
cs := currentMeta.CurrentSnapshot()
|
||||
if cs == nil || cs.SnapshotID != staleSnapshotID {
|
||||
return errStalePlan
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected stale plan error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stale plan") {
|
||||
t.Errorf("expected stale plan in error, got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataVersionCAS(t *testing.T) {
|
||||
fs, client := startFakeFiler(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
setup := tableSetup{
|
||||
BucketName: "test-bucket",
|
||||
Namespace: "ns",
|
||||
TableName: "tbl",
|
||||
Snapshots: []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
},
|
||||
}
|
||||
populateTable(t, fs, setup)
|
||||
|
||||
// The table xattr has metadataVersion=1. Try updating with wrong expected version.
|
||||
tableDir := path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath())
|
||||
err := updateTableMetadataXattr(context.Background(), client, tableDir, 99, []byte(`{}`), "metadata/v100.metadata.json")
|
||||
if err == nil {
|
||||
t.Fatal("expected version conflict error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "metadata version conflict") {
|
||||
t.Errorf("expected version conflict in error, got %q", err)
|
||||
}
|
||||
|
||||
// Correct expected version should succeed
|
||||
err = updateTableMetadataXattr(context.Background(), client, tableDir, 1, []byte(`{}`), "metadata/v2.metadata.json")
|
||||
if err != nil {
|
||||
t.Fatalf("expected success with correct version, got: %v", err)
|
||||
}
|
||||
|
||||
// Verify version was incremented to 2
|
||||
entry := fs.getEntry(path.Dir(tableDir), path.Base(tableDir))
|
||||
if entry == nil {
|
||||
t.Fatal("table entry not found after update")
|
||||
}
|
||||
var internalMeta map[string]json.RawMessage
|
||||
if err := json.Unmarshal(entry.Extended[s3tables.ExtendedKeyMetadata], &internalMeta); err != nil {
|
||||
t.Fatalf("unmarshal xattr: %v", err)
|
||||
}
|
||||
var version int
|
||||
if err := json.Unmarshal(internalMeta["metadataVersion"], &version); err != nil {
|
||||
t.Fatalf("unmarshal version: %v", err)
|
||||
}
|
||||
if version != 2 {
|
||||
t.Errorf("expected version 2 after update, got %d", version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// filerFileEntry holds a non-directory entry with its full directory path.
|
||||
type filerFileEntry struct {
|
||||
Dir string
|
||||
Entry *filer_pb.Entry
|
||||
}
|
||||
|
||||
// listFilerEntries lists all entries in a directory.
|
||||
func listFilerEntries(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, prefix string) ([]*filer_pb.Entry, error) {
|
||||
var entries []*filer_pb.Entry
|
||||
var lastFileName string
|
||||
limit := uint32(10000)
|
||||
|
||||
for {
|
||||
resp, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
||||
Directory: dir,
|
||||
Prefix: prefix,
|
||||
StartFromFileName: lastFileName,
|
||||
InclusiveStartFrom: lastFileName == "",
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
// Treat not-found as empty directory; propagate other errors.
|
||||
if status.Code(err) == codes.NotFound {
|
||||
return entries, nil
|
||||
}
|
||||
return entries, fmt.Errorf("list entries in %s: %w", dir, err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for {
|
||||
entry, recvErr := resp.Recv()
|
||||
if recvErr != nil {
|
||||
if recvErr == io.EOF {
|
||||
break
|
||||
}
|
||||
return entries, fmt.Errorf("recv entry in %s: %w", dir, recvErr)
|
||||
}
|
||||
if entry.Entry != nil {
|
||||
entries = append(entries, entry.Entry)
|
||||
lastFileName = entry.Entry.Name
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count < int(limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// walkFilerEntries recursively lists all non-directory entries under dir.
|
||||
func walkFilerEntries(ctx context.Context, client filer_pb.SeaweedFilerClient, dir string) ([]filerFileEntry, error) {
|
||||
entries, err := listFilerEntries(ctx, client, dir, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []filerFileEntry
|
||||
for _, entry := range entries {
|
||||
if entry.IsDirectory {
|
||||
subDir := path.Join(dir, entry.Name)
|
||||
subEntries, err := walkFilerEntries(ctx, client, subDir)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("iceberg maintenance: cannot walk %s: %v", subDir, err)
|
||||
continue
|
||||
}
|
||||
result = append(result, subEntries...)
|
||||
} else {
|
||||
result = append(result, filerFileEntry{Dir: dir, Entry: entry})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadCurrentMetadata loads and parses the current Iceberg metadata from the table entry's xattr.
|
||||
func loadCurrentMetadata(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath string) (table.Metadata, string, error) {
|
||||
dir := path.Join(s3tables.TablesPath, bucketName, path.Dir(tablePath))
|
||||
name := path.Base(tablePath)
|
||||
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("lookup table entry %s/%s: %w", dir, name, err)
|
||||
}
|
||||
if resp == nil || resp.Entry == nil {
|
||||
return nil, "", fmt.Errorf("table entry not found: %s/%s", dir, name)
|
||||
}
|
||||
|
||||
metadataBytes, ok := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
if !ok || len(metadataBytes) == 0 {
|
||||
return nil, "", fmt.Errorf("no metadata xattr on table entry %s/%s", dir, name)
|
||||
}
|
||||
|
||||
// Parse internal metadata to extract FullMetadata
|
||||
var internalMeta struct {
|
||||
MetadataVersion int `json:"metadataVersion"`
|
||||
MetadataLocation string `json:"metadataLocation,omitempty"`
|
||||
Metadata *struct {
|
||||
FullMetadata json.RawMessage `json:"fullMetadata,omitempty"`
|
||||
} `json:"metadata,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(metadataBytes, &internalMeta); err != nil {
|
||||
return nil, "", fmt.Errorf("unmarshal internal metadata: %w", err)
|
||||
}
|
||||
if internalMeta.Metadata == nil || len(internalMeta.Metadata.FullMetadata) == 0 {
|
||||
return nil, "", fmt.Errorf("no fullMetadata in table xattr")
|
||||
}
|
||||
|
||||
meta, err := table.ParseMetadataBytes(internalMeta.Metadata.FullMetadata)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("parse iceberg metadata: %w", err)
|
||||
}
|
||||
|
||||
// Use metadataLocation from xattr if available (includes nonce suffix),
|
||||
// otherwise fall back to the canonical name derived from metadataVersion.
|
||||
metadataFileName := path.Base(internalMeta.MetadataLocation)
|
||||
if metadataFileName == "" || metadataFileName == "." {
|
||||
metadataFileName = fmt.Sprintf("v%d.metadata.json", internalMeta.MetadataVersion)
|
||||
}
|
||||
return meta, metadataFileName, nil
|
||||
}
|
||||
|
||||
// loadFileByIcebergPath loads a file from the filer given an Iceberg-style path.
|
||||
// Paths may be absolute filer paths, relative (metadata/..., data/...), or
|
||||
// location-based (s3://bucket/ns/table/metadata/...).
|
||||
//
|
||||
// The function normalises the path to a relative form under the table root
|
||||
// (e.g. "metadata/snap-1.avro" or "data/region=us/file.parquet") and splits
|
||||
// it into the correct filer directory + entry name, so nested sub-directories
|
||||
// are resolved properly.
|
||||
func loadFileByIcebergPath(ctx context.Context, client filer_pb.SeaweedFilerClient, bucketName, tablePath, icebergPath string) ([]byte, error) {
|
||||
relPath := path.Clean(normalizeIcebergPath(icebergPath, bucketName, tablePath))
|
||||
relPath = strings.TrimPrefix(relPath, "/")
|
||||
if relPath == "." || relPath == "" || strings.HasPrefix(relPath, "../") {
|
||||
return nil, fmt.Errorf("invalid iceberg path %q", icebergPath)
|
||||
}
|
||||
|
||||
dir := path.Join(s3tables.TablesPath, bucketName, tablePath, path.Dir(relPath))
|
||||
fileName := path.Base(relPath)
|
||||
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir,
|
||||
Name: fileName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup %s/%s: %w", dir, fileName, err)
|
||||
}
|
||||
if resp == nil || resp.Entry == nil {
|
||||
return nil, fmt.Errorf("file not found: %s/%s", dir, fileName)
|
||||
}
|
||||
|
||||
// Inline content is available for small files (metadata, manifests, and
|
||||
// manifest lists written by saveFilerFile). Larger files uploaded via S3
|
||||
// are stored as chunks with empty Content — detect this and return a
|
||||
// clear error rather than silently returning empty data.
|
||||
if len(resp.Entry.Content) == 0 && len(resp.Entry.Chunks) > 0 {
|
||||
return nil, fmt.Errorf("file %s/%s is stored in chunks; only inline content is supported", dir, fileName)
|
||||
}
|
||||
|
||||
return resp.Entry.Content, nil
|
||||
}
|
||||
|
||||
// normalizeIcebergPath converts an Iceberg path (which may be an S3 URL, an
|
||||
// absolute filer path, or a plain relative path) into a relative path under the
|
||||
// table root, e.g. "metadata/snap-1.avro" or "data/region=us/file.parquet".
|
||||
func normalizeIcebergPath(icebergPath, bucketName, tablePath string) string {
|
||||
p := icebergPath
|
||||
|
||||
// Strip scheme (e.g. "s3://bucket/ns/table/metadata/file" → "bucket/ns/table/metadata/file")
|
||||
if idx := strings.Index(p, "://"); idx >= 0 {
|
||||
p = p[idx+3:]
|
||||
}
|
||||
|
||||
// Strip any leading slash
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
|
||||
// Strip bucket+tablePath prefix if present
|
||||
// e.g. "mybucket/ns/table/metadata/file" → "metadata/file"
|
||||
tablePrefix := path.Join(bucketName, tablePath) + "/"
|
||||
if strings.HasPrefix(p, tablePrefix) {
|
||||
return p[len(tablePrefix):]
|
||||
}
|
||||
|
||||
// Strip filer TablesPath prefix if present
|
||||
// e.g. "buckets/mybucket/ns/table/metadata/file" → "metadata/file"
|
||||
filerPrefix := strings.TrimPrefix(s3tables.TablesPath, "/")
|
||||
fullPrefix := path.Join(filerPrefix, bucketName, tablePath) + "/"
|
||||
if strings.HasPrefix(p, fullPrefix) {
|
||||
return p[len(fullPrefix):]
|
||||
}
|
||||
|
||||
// Already relative (e.g. "metadata/snap-1.avro")
|
||||
return p
|
||||
}
|
||||
|
||||
// saveFilerFile saves a file to the filer.
|
||||
func saveFilerFile(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, fileName string, content []byte) error {
|
||||
resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: fileName,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0644),
|
||||
FileSize: uint64(len(content)),
|
||||
},
|
||||
Content: content,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create entry %s/%s: %w", dir, fileName, err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("create entry %s/%s: %s", dir, fileName, resp.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteFilerFile deletes a file from the filer.
|
||||
func deleteFilerFile(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, fileName string) error {
|
||||
return filer_pb.DoRemove(ctx, client, dir, fileName, true, false, true, false, nil)
|
||||
}
|
||||
|
||||
// updateTableMetadataXattr updates the table entry's metadata xattr with
|
||||
// the new Iceberg metadata. It performs a compare-and-swap: if the stored
|
||||
// metadataVersion does not match expectedVersion, it returns
|
||||
// errMetadataVersionConflict so the caller can retry.
|
||||
// newMetadataLocation is the table-relative path to the new metadata file
|
||||
// (e.g. "metadata/v3.metadata.json").
|
||||
func updateTableMetadataXattr(ctx context.Context, client filer_pb.SeaweedFilerClient, tableDir string, expectedVersion int, newFullMetadata []byte, newMetadataLocation string) error {
|
||||
tableName := path.Base(tableDir)
|
||||
parentDir := path.Dir(tableDir)
|
||||
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: parentDir,
|
||||
Name: tableName,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup table entry: %w", err)
|
||||
}
|
||||
if resp == nil || resp.Entry == nil {
|
||||
return fmt.Errorf("table entry not found")
|
||||
}
|
||||
|
||||
existingXattr, ok := resp.Entry.Extended[s3tables.ExtendedKeyMetadata]
|
||||
if !ok {
|
||||
return fmt.Errorf("no metadata xattr on table entry")
|
||||
}
|
||||
|
||||
// Parse existing xattr, update fullMetadata
|
||||
var internalMeta map[string]json.RawMessage
|
||||
if err := json.Unmarshal(existingXattr, &internalMeta); err != nil {
|
||||
return fmt.Errorf("unmarshal existing xattr: %w", err)
|
||||
}
|
||||
|
||||
// Compare-and-swap: verify the stored metadataVersion matches what we expect.
|
||||
// NOTE: This is a client-side CAS — two workers could both read the same
|
||||
// version, pass this check, and race at UpdateEntry (last-write-wins).
|
||||
// The proper fix is server-side precondition support on UpdateEntryRequest
|
||||
// (e.g. expect-version or If-Match semantics). Until then, commitWithRetry
|
||||
// with exponential backoff mitigates but does not eliminate the race.
|
||||
// Avoid scheduling concurrent maintenance on the same table.
|
||||
versionRaw, ok := internalMeta["metadataVersion"]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: metadataVersion field missing from xattr", errMetadataVersionConflict)
|
||||
}
|
||||
var storedVersion int
|
||||
if err := json.Unmarshal(versionRaw, &storedVersion); err != nil {
|
||||
return fmt.Errorf("%w: cannot parse metadataVersion: %v", errMetadataVersionConflict, err)
|
||||
}
|
||||
if storedVersion != expectedVersion {
|
||||
return fmt.Errorf("%w: expected version %d, found %d", errMetadataVersionConflict, expectedVersion, storedVersion)
|
||||
}
|
||||
|
||||
// Update the metadata.fullMetadata field
|
||||
var metadataObj map[string]json.RawMessage
|
||||
if raw, ok := internalMeta["metadata"]; ok {
|
||||
if err := json.Unmarshal(raw, &metadataObj); err != nil {
|
||||
return fmt.Errorf("unmarshal metadata object: %w", err)
|
||||
}
|
||||
} else {
|
||||
metadataObj = make(map[string]json.RawMessage)
|
||||
}
|
||||
metadataObj["fullMetadata"] = newFullMetadata
|
||||
metadataJSON, err := json.Marshal(metadataObj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal metadata object: %w", err)
|
||||
}
|
||||
internalMeta["metadata"] = metadataJSON
|
||||
|
||||
// Increment version
|
||||
newVersion := expectedVersion + 1
|
||||
versionJSON, _ := json.Marshal(newVersion)
|
||||
internalMeta["metadataVersion"] = versionJSON
|
||||
|
||||
// Update modifiedAt
|
||||
modifiedAt, _ := json.Marshal(time.Now().Format(time.RFC3339Nano))
|
||||
internalMeta["modifiedAt"] = modifiedAt
|
||||
|
||||
// Update metadataLocation to point to the new metadata file
|
||||
metaLocJSON, _ := json.Marshal(newMetadataLocation)
|
||||
internalMeta["metadataLocation"] = metaLocJSON
|
||||
|
||||
// Regenerate versionToken for consistency with the S3 Tables catalog
|
||||
tokenJSON, _ := json.Marshal(generateIcebergVersionToken())
|
||||
internalMeta["versionToken"] = tokenJSON
|
||||
|
||||
updatedXattr, err := json.Marshal(internalMeta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal updated xattr: %w", err)
|
||||
}
|
||||
|
||||
resp.Entry.Extended[s3tables.ExtendedKeyMetadata] = updatedXattr
|
||||
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: parentDir,
|
||||
Entry: resp.Entry,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("update table entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateIcebergVersionToken produces a random hex token, mirroring the
|
||||
// logic in s3tables.generateVersionToken (which is unexported).
|
||||
func generateIcebergVersionToken() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func init() {
|
||||
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
|
||||
JobType: jobType,
|
||||
Category: pluginworker.CategoryHeavy,
|
||||
Aliases: []string{"iceberg-maintenance", "iceberg.maintenance", "iceberg"},
|
||||
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
|
||||
return NewHandler(opts.GrpcDialOption), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handler implements the JobHandler interface for Iceberg table maintenance:
|
||||
// snapshot expiration, orphan file removal, and manifest rewriting.
|
||||
type Handler struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
}
|
||||
|
||||
// NewHandler creates a new handler for iceberg table maintenance.
|
||||
func NewHandler(grpcDialOption grpc.DialOption) *Handler {
|
||||
return &Handler{grpcDialOption: grpcDialOption}
|
||||
}
|
||||
|
||||
func (h *Handler) Capability() *plugin_pb.JobTypeCapability {
|
||||
return &plugin_pb.JobTypeCapability{
|
||||
JobType: jobType,
|
||||
CanDetect: true,
|
||||
CanExecute: true,
|
||||
MaxDetectionConcurrency: 1,
|
||||
MaxExecutionConcurrency: 4,
|
||||
DisplayName: "Iceberg Maintenance",
|
||||
Description: "Compacts, expires snapshots, removes orphans, and rewrites manifests for Iceberg tables in S3 table buckets",
|
||||
Weight: 50,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
||||
return &plugin_pb.JobTypeDescriptor{
|
||||
JobType: jobType,
|
||||
DisplayName: "Iceberg Maintenance",
|
||||
Description: "Automated maintenance for Iceberg tables: snapshot expiration, orphan removal, manifest rewriting",
|
||||
Icon: "fas fa-snowflake",
|
||||
DescriptorVersion: 1,
|
||||
AdminConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "iceberg-maintenance-admin",
|
||||
Title: "Iceberg Maintenance Admin Config",
|
||||
Description: "Admin-side controls for Iceberg table maintenance scope.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "scope",
|
||||
Title: "Scope",
|
||||
Description: "Filters to restrict which tables are scanned for maintenance.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "bucket_filter",
|
||||
Label: "Bucket Filter",
|
||||
Description: "Comma-separated wildcard patterns for table buckets (* and ? supported). Blank = all.",
|
||||
Placeholder: "prod-*, staging-*",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "namespace_filter",
|
||||
Label: "Namespace Filter",
|
||||
Description: "Comma-separated wildcard patterns for namespaces (* and ? supported). Blank = all.",
|
||||
Placeholder: "analytics, events-*",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
{
|
||||
Name: "table_filter",
|
||||
Label: "Table Filter",
|
||||
Description: "Comma-separated wildcard patterns for table names (* and ? supported). Blank = all.",
|
||||
Placeholder: "clicks, orders-*",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"bucket_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"namespace_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
"table_filter": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""}},
|
||||
},
|
||||
},
|
||||
WorkerConfigForm: &plugin_pb.ConfigForm{
|
||||
FormId: "iceberg-maintenance-worker",
|
||||
Title: "Iceberg Maintenance Worker Config",
|
||||
Description: "Worker-side thresholds for maintenance operations.",
|
||||
Sections: []*plugin_pb.ConfigSection{
|
||||
{
|
||||
SectionId: "snapshots",
|
||||
Title: "Snapshot Expiration",
|
||||
Description: "Controls for automatic snapshot cleanup.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "snapshot_retention_hours",
|
||||
Label: "Retention (hours)",
|
||||
Description: "Expire snapshots older than this many hours.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
{
|
||||
Name: "max_snapshots_to_keep",
|
||||
Label: "Max Snapshots",
|
||||
Description: "Always keep at least this many most recent snapshots.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SectionId: "compaction",
|
||||
Title: "Data Compaction",
|
||||
Description: "Controls for bin-packing small Parquet data files.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "target_file_size_bytes",
|
||||
Label: "Target File Size (bytes)",
|
||||
Description: "Files smaller than this are candidates for compaction.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1024 * 1024}},
|
||||
},
|
||||
{
|
||||
Name: "min_input_files",
|
||||
Label: "Min Input Files",
|
||||
Description: "Minimum number of small files in a partition to trigger compaction.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 2}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SectionId: "orphans",
|
||||
Title: "Orphan Removal",
|
||||
Description: "Controls for orphan file cleanup.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "orphan_older_than_hours",
|
||||
Label: "Safety Window (hours)",
|
||||
Description: "Only remove orphan files older than this many hours.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
SectionId: "general",
|
||||
Title: "General",
|
||||
Description: "General maintenance settings.",
|
||||
Fields: []*plugin_pb.ConfigField{
|
||||
{
|
||||
Name: "max_commit_retries",
|
||||
Label: "Max Commit Retries",
|
||||
Description: "Maximum number of commit retries on version conflict.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
||||
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
||||
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 20}},
|
||||
},
|
||||
{
|
||||
Name: "operations",
|
||||
Label: "Operations",
|
||||
Description: "Comma-separated list of operations to run: compact, expire_snapshots, remove_orphans, rewrite_manifests, or 'all'.",
|
||||
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
|
||||
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"target_file_size_bytes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeBytes}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"snapshot_retention_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSnapshotRetentionHours}},
|
||||
"max_snapshots_to_keep": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxSnapshotsToKeep}},
|
||||
"orphan_older_than_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultOrphanOlderThanHours}},
|
||||
"max_commit_retries": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxCommitRetries}},
|
||||
"operations": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultOperations}},
|
||||
},
|
||||
},
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
Enabled: false, // disabled by default
|
||||
DetectionIntervalSeconds: 3600, // 1 hour
|
||||
DetectionTimeoutSeconds: 300,
|
||||
MaxJobsPerDetection: 100,
|
||||
GlobalExecutionConcurrency: 4,
|
||||
PerWorkerExecutionConcurrency: 2,
|
||||
RetryLimit: 1,
|
||||
RetryBackoffSeconds: 60,
|
||||
JobTypeMaxRuntimeSeconds: 3600, // 1 hour max
|
||||
},
|
||||
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
|
||||
"target_file_size_bytes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultTargetFileSizeBytes}},
|
||||
"min_input_files": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMinInputFiles}},
|
||||
"snapshot_retention_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultSnapshotRetentionHours}},
|
||||
"max_snapshots_to_keep": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxSnapshotsToKeep}},
|
||||
"orphan_older_than_hours": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultOrphanOlderThanHours}},
|
||||
"max_commit_retries": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxCommitRetries}},
|
||||
"operations": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultOperations}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Detect(ctx context.Context, request *plugin_pb.RunDetectionRequest, sender pluginworker.DetectionSender) error {
|
||||
if request == nil {
|
||||
return fmt.Errorf("run detection request is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("detection sender is nil")
|
||||
}
|
||||
if request.JobType != "" && request.JobType != jobType {
|
||||
return fmt.Errorf("job type %q is not handled by iceberg maintenance handler", request.JobType)
|
||||
}
|
||||
|
||||
workerConfig := ParseConfig(request.GetWorkerConfigValues())
|
||||
if _, err := parseOperations(workerConfig.Operations); err != nil {
|
||||
return fmt.Errorf("invalid operations config: %w", err)
|
||||
}
|
||||
|
||||
// Detection interval is managed by the scheduler via AdminRuntimeDefaults.DetectionIntervalSeconds.
|
||||
|
||||
// Get filer addresses from cluster context
|
||||
filerAddresses := make([]string, 0)
|
||||
if request.ClusterContext != nil {
|
||||
filerAddresses = append(filerAddresses, request.ClusterContext.FilerGrpcAddresses...)
|
||||
}
|
||||
if len(filerAddresses) == 0 {
|
||||
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("skipped", "no filer addresses in cluster context", nil))
|
||||
return h.sendEmptyDetection(sender)
|
||||
}
|
||||
|
||||
// Read scope filters
|
||||
bucketFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "bucket_filter", ""))
|
||||
namespaceFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "namespace_filter", ""))
|
||||
tableFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "table_filter", ""))
|
||||
|
||||
// Connect to filer to scan table buckets
|
||||
filerAddress := filerAddresses[0]
|
||||
conn, err := grpc.NewClient(filerAddress, h.grpcDialOption)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to filer %s: %w", filerAddress, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
filerClient := filer_pb.NewSeaweedFilerClient(conn)
|
||||
|
||||
maxResults := int(request.MaxResults)
|
||||
tables, err := h.scanTablesForMaintenance(ctx, filerClient, workerConfig, bucketFilter, namespaceFilter, tableFilter, maxResults)
|
||||
if err != nil {
|
||||
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("scan_error", fmt.Sprintf("error scanning tables: %v", err), nil))
|
||||
return fmt.Errorf("scan tables: %w", err)
|
||||
}
|
||||
|
||||
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("scan_complete",
|
||||
fmt.Sprintf("found %d table(s) needing maintenance", len(tables)),
|
||||
map[string]*plugin_pb.ConfigValue{
|
||||
"tables_found": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(tables))}},
|
||||
}))
|
||||
|
||||
hasMore := false
|
||||
if maxResults > 0 && len(tables) > maxResults {
|
||||
hasMore = true
|
||||
tables = tables[:maxResults]
|
||||
}
|
||||
|
||||
proposals := make([]*plugin_pb.JobProposal, 0, len(tables))
|
||||
for _, t := range tables {
|
||||
proposal := h.buildMaintenanceProposal(t, filerAddress)
|
||||
proposals = append(proposals, proposal)
|
||||
}
|
||||
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: jobType,
|
||||
Proposals: proposals,
|
||||
HasMore: hasMore,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: jobType,
|
||||
Success: true,
|
||||
TotalProposals: int32(len(proposals)),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequest, sender pluginworker.ExecutionSender) error {
|
||||
if request == nil || request.Job == nil {
|
||||
return fmt.Errorf("execute request/job is nil")
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("execution sender is nil")
|
||||
}
|
||||
if request.Job.JobType != "" && request.Job.JobType != jobType {
|
||||
return fmt.Errorf("job type %q is not handled by iceberg maintenance handler", request.Job.JobType)
|
||||
}
|
||||
canonicalJobType := request.Job.JobType
|
||||
if canonicalJobType == "" {
|
||||
canonicalJobType = jobType
|
||||
}
|
||||
|
||||
params := request.Job.Parameters
|
||||
bucketName := readStringConfig(params, "bucket_name", "")
|
||||
namespace := readStringConfig(params, "namespace", "")
|
||||
tableName := readStringConfig(params, "table_name", "")
|
||||
tablePath := readStringConfig(params, "table_path", "")
|
||||
filerAddress := readStringConfig(params, "filer_address", "")
|
||||
|
||||
if bucketName == "" || namespace == "" || tableName == "" || filerAddress == "" {
|
||||
return fmt.Errorf("missing required parameters: bucket_name=%q, namespace=%q, table_name=%q, filer_address=%q", bucketName, namespace, tableName, filerAddress)
|
||||
}
|
||||
if tablePath == "" {
|
||||
tablePath = path.Join(namespace, tableName)
|
||||
}
|
||||
// Sanitize tablePath to prevent directory traversal.
|
||||
tablePath = path.Clean(tablePath)
|
||||
expected := path.Join(namespace, tableName)
|
||||
if tablePath != expected && !strings.HasPrefix(tablePath, expected+"/") {
|
||||
return fmt.Errorf("invalid table_path %q: must be %q or a subpath", tablePath, expected)
|
||||
}
|
||||
|
||||
workerConfig := ParseConfig(request.GetWorkerConfigValues())
|
||||
ops, opsErr := parseOperations(workerConfig.Operations)
|
||||
if opsErr != nil {
|
||||
return fmt.Errorf("invalid operations config: %w", opsErr)
|
||||
}
|
||||
|
||||
// Send initial progress
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: canonicalJobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_ASSIGNED,
|
||||
ProgressPercent: 0,
|
||||
Stage: "assigned",
|
||||
Message: fmt.Sprintf("maintenance job accepted for %s/%s/%s", bucketName, namespace, tableName),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("assigned", fmt.Sprintf("maintenance job accepted for %s/%s/%s", bucketName, namespace, tableName)),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Connect to filer
|
||||
conn, err := grpc.NewClient(filerAddress, h.grpcDialOption)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to filer %s: %w", filerAddress, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
filerClient := filer_pb.NewSeaweedFilerClient(conn)
|
||||
|
||||
var results []string
|
||||
var lastErr error
|
||||
totalOps := len(ops)
|
||||
completedOps := 0
|
||||
|
||||
// Execute operations in correct Iceberg maintenance order:
|
||||
// expire_snapshots → remove_orphans → rewrite_manifests
|
||||
for _, op := range ops {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
progress := float64(completedOps) / float64(totalOps) * 100
|
||||
if err := sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: canonicalJobType,
|
||||
State: plugin_pb.JobState_JOB_STATE_RUNNING,
|
||||
ProgressPercent: progress,
|
||||
Stage: op,
|
||||
Message: fmt.Sprintf("running %s", op),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity(op, fmt.Sprintf("starting %s for %s/%s/%s", op, bucketName, namespace, tableName)),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var opResult string
|
||||
var opErr error
|
||||
|
||||
switch op {
|
||||
case "compact":
|
||||
opResult, opErr = h.compactDataFiles(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
case "expire_snapshots":
|
||||
opResult, opErr = h.expireSnapshots(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
case "remove_orphans":
|
||||
opResult, opErr = h.removeOrphans(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
case "rewrite_manifests":
|
||||
opResult, opErr = h.rewriteManifests(ctx, filerClient, bucketName, tablePath, workerConfig)
|
||||
default:
|
||||
glog.Warningf("unknown maintenance operation: %s", op)
|
||||
continue
|
||||
}
|
||||
|
||||
completedOps++
|
||||
if opErr != nil {
|
||||
glog.Warningf("iceberg maintenance %s failed for %s/%s/%s: %v", op, bucketName, namespace, tableName, opErr)
|
||||
results = append(results, fmt.Sprintf("%s: error: %v", op, opErr))
|
||||
lastErr = opErr
|
||||
} else {
|
||||
results = append(results, fmt.Sprintf("%s: %s", op, opResult))
|
||||
}
|
||||
}
|
||||
|
||||
resultSummary := strings.Join(results, "; ")
|
||||
success := lastErr == nil
|
||||
|
||||
return sender.SendCompleted(&plugin_pb.JobCompleted{
|
||||
JobId: request.Job.JobId,
|
||||
JobType: canonicalJobType,
|
||||
Success: success,
|
||||
ErrorMessage: func() string {
|
||||
if lastErr != nil {
|
||||
return lastErr.Error()
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Result: &plugin_pb.JobResult{
|
||||
Summary: resultSummary,
|
||||
OutputValues: map[string]*plugin_pb.ConfigValue{
|
||||
"bucket": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: bucketName}},
|
||||
"namespace": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: namespace}},
|
||||
"table": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: tableName}},
|
||||
},
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
pluginworker.BuildExecutorActivity("completed", resultSummary),
|
||||
},
|
||||
CompletedAt: timestamppb.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) sendEmptyDetection(sender pluginworker.DetectionSender) error {
|
||||
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
||||
JobType: jobType,
|
||||
Proposals: []*plugin_pb.JobProposal{},
|
||||
HasMore: false,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
||||
JobType: jobType,
|
||||
Success: true,
|
||||
TotalProposals: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure Handler implements JobHandler.
|
||||
var _ pluginworker.JobHandler = (*Handler)(nil)
|
||||
@@ -0,0 +1,613 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
)
|
||||
|
||||
func TestParseConfig(t *testing.T) {
|
||||
config := ParseConfig(nil)
|
||||
|
||||
if config.SnapshotRetentionHours != defaultSnapshotRetentionHours {
|
||||
t.Errorf("expected SnapshotRetentionHours=%d, got %d", defaultSnapshotRetentionHours, config.SnapshotRetentionHours)
|
||||
}
|
||||
if config.MaxSnapshotsToKeep != defaultMaxSnapshotsToKeep {
|
||||
t.Errorf("expected MaxSnapshotsToKeep=%d, got %d", defaultMaxSnapshotsToKeep, config.MaxSnapshotsToKeep)
|
||||
}
|
||||
if config.OrphanOlderThanHours != defaultOrphanOlderThanHours {
|
||||
t.Errorf("expected OrphanOlderThanHours=%d, got %d", defaultOrphanOlderThanHours, config.OrphanOlderThanHours)
|
||||
}
|
||||
if config.MaxCommitRetries != defaultMaxCommitRetries {
|
||||
t.Errorf("expected MaxCommitRetries=%d, got %d", defaultMaxCommitRetries, config.MaxCommitRetries)
|
||||
}
|
||||
if config.Operations != defaultOperations {
|
||||
t.Errorf("expected Operations=%q, got %q", defaultOperations, config.Operations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOperations(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected []string
|
||||
wantErr bool
|
||||
}{
|
||||
{"all", []string{"compact", "expire_snapshots", "remove_orphans", "rewrite_manifests"}, false},
|
||||
{"", []string{"compact", "expire_snapshots", "remove_orphans", "rewrite_manifests"}, false},
|
||||
{"expire_snapshots", []string{"expire_snapshots"}, false},
|
||||
{"compact", []string{"compact"}, false},
|
||||
{"rewrite_manifests,expire_snapshots", []string{"expire_snapshots", "rewrite_manifests"}, false},
|
||||
{"compact,expire_snapshots", []string{"compact", "expire_snapshots"}, false},
|
||||
{"remove_orphans, rewrite_manifests", []string{"remove_orphans", "rewrite_manifests"}, false},
|
||||
{"expire_snapshots,remove_orphans,rewrite_manifests", []string{"expire_snapshots", "remove_orphans", "rewrite_manifests"}, false},
|
||||
{"compact,expire_snapshots,remove_orphans,rewrite_manifests", []string{"compact", "expire_snapshots", "remove_orphans", "rewrite_manifests"}, false},
|
||||
{"unknown_op", nil, true},
|
||||
{"expire_snapshots,bad_op", nil, true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
result, err := parseOperations(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseOperations(%q) expected error, got %v", tc.input, result)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("parseOperations(%q) unexpected error: %v", tc.input, err)
|
||||
continue
|
||||
}
|
||||
if len(result) != len(tc.expected) {
|
||||
t.Errorf("parseOperations(%q) = %v, want %v", tc.input, result, tc.expected)
|
||||
continue
|
||||
}
|
||||
for i := range result {
|
||||
if result[i] != tc.expected[i] {
|
||||
t.Errorf("parseOperations(%q)[%d] = %q, want %q", tc.input, i, result[i], tc.expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMetadataVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected int
|
||||
}{
|
||||
{"v1.metadata.json", 1},
|
||||
{"v5.metadata.json", 5},
|
||||
{"v100.metadata.json", 100},
|
||||
{"v0.metadata.json", 0},
|
||||
{"invalid.metadata.json", 0},
|
||||
{"metadata.json", 0},
|
||||
{"", 0},
|
||||
{"v.metadata.json", 0},
|
||||
{"v7-1709766000.metadata.json", 7},
|
||||
{"v42-abc123.metadata.json", 42},
|
||||
{"v5-.metadata.json", 5},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
result := extractMetadataVersion(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("extractMetadataVersion(%q) = %d, want %d", tc.input, result, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsMaintenanceNoSnapshots(t *testing.T) {
|
||||
config := Config{
|
||||
SnapshotRetentionHours: 24,
|
||||
MaxSnapshotsToKeep: 2,
|
||||
}
|
||||
|
||||
meta := buildTestMetadata(t, nil)
|
||||
if needsMaintenance(meta, config) {
|
||||
t.Error("expected no maintenance for table with no snapshots")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsMaintenanceExceedsMaxSnapshots(t *testing.T) {
|
||||
config := Config{
|
||||
SnapshotRetentionHours: 24 * 365, // very long retention
|
||||
MaxSnapshotsToKeep: 2,
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
{SnapshotID: 2, TimestampMs: now + 1, ManifestList: "metadata/snap-2.avro"},
|
||||
{SnapshotID: 3, TimestampMs: now + 2, ManifestList: "metadata/snap-3.avro"},
|
||||
}
|
||||
meta := buildTestMetadata(t, snapshots)
|
||||
if !needsMaintenance(meta, config) {
|
||||
t.Error("expected maintenance for table exceeding max snapshots")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsMaintenanceWithinLimits(t *testing.T) {
|
||||
config := Config{
|
||||
SnapshotRetentionHours: 24 * 365, // very long retention
|
||||
MaxSnapshotsToKeep: 5,
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now, ManifestList: "metadata/snap-1.avro"},
|
||||
}
|
||||
meta := buildTestMetadata(t, snapshots)
|
||||
if needsMaintenance(meta, config) {
|
||||
t.Error("expected no maintenance for table within limits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsMaintenanceOldSnapshot(t *testing.T) {
|
||||
// Use a retention of 0 hours so that any snapshot is considered "old"
|
||||
config := Config{
|
||||
SnapshotRetentionHours: 0, // instant expiry
|
||||
MaxSnapshotsToKeep: 10,
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now - 1, ManifestList: "metadata/snap-1.avro"},
|
||||
}
|
||||
meta := buildTestMetadata(t, snapshots)
|
||||
// With 0 retention, any snapshot with timestamp < now should need maintenance
|
||||
if !needsMaintenance(meta, config) {
|
||||
t.Error("expected maintenance for table with expired snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityAndDescriptor(t *testing.T) {
|
||||
handler := NewHandler(nil)
|
||||
|
||||
cap := handler.Capability()
|
||||
if cap.JobType != jobType {
|
||||
t.Errorf("expected job type %q, got %q", jobType, cap.JobType)
|
||||
}
|
||||
if !cap.CanDetect {
|
||||
t.Error("expected CanDetect=true")
|
||||
}
|
||||
if !cap.CanExecute {
|
||||
t.Error("expected CanExecute=true")
|
||||
}
|
||||
|
||||
desc := handler.Descriptor()
|
||||
if desc.JobType != jobType {
|
||||
t.Errorf("expected job type %q, got %q", jobType, desc.JobType)
|
||||
}
|
||||
if desc.AdminConfigForm == nil {
|
||||
t.Error("expected admin config form")
|
||||
}
|
||||
if desc.WorkerConfigForm == nil {
|
||||
t.Error("expected worker config form")
|
||||
}
|
||||
if desc.AdminRuntimeDefaults == nil {
|
||||
t.Error("expected admin runtime defaults")
|
||||
}
|
||||
if desc.AdminRuntimeDefaults.Enabled {
|
||||
t.Error("expected disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMaintenanceProposal(t *testing.T) {
|
||||
handler := NewHandler(nil)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
snapshots := []table.Snapshot{
|
||||
{SnapshotID: 1, TimestampMs: now},
|
||||
{SnapshotID: 2, TimestampMs: now + 1},
|
||||
}
|
||||
meta := buildTestMetadata(t, snapshots)
|
||||
|
||||
info := tableInfo{
|
||||
BucketName: "my-bucket",
|
||||
Namespace: "analytics",
|
||||
TableName: "events",
|
||||
TablePath: "analytics/events",
|
||||
Metadata: meta,
|
||||
}
|
||||
|
||||
proposal := handler.buildMaintenanceProposal(info, "localhost:8888")
|
||||
|
||||
expectedDedupe := "iceberg_maintenance:my-bucket/analytics/events"
|
||||
if proposal.DedupeKey != expectedDedupe {
|
||||
t.Errorf("expected dedupe key %q, got %q", expectedDedupe, proposal.DedupeKey)
|
||||
}
|
||||
if proposal.JobType != jobType {
|
||||
t.Errorf("expected job type %q, got %q", jobType, proposal.JobType)
|
||||
}
|
||||
|
||||
if readStringConfig(proposal.Parameters, "bucket_name", "") != "my-bucket" {
|
||||
t.Error("expected bucket_name=my-bucket in parameters")
|
||||
}
|
||||
if readStringConfig(proposal.Parameters, "namespace", "") != "analytics" {
|
||||
t.Error("expected namespace=analytics in parameters")
|
||||
}
|
||||
if readStringConfig(proposal.Parameters, "table_name", "") != "events" {
|
||||
t.Error("expected table_name=events in parameters")
|
||||
}
|
||||
if readStringConfig(proposal.Parameters, "filer_address", "") != "localhost:8888" {
|
||||
t.Error("expected filer_address=localhost:8888 in parameters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestRewritePathConsistency(t *testing.T) {
|
||||
// Verify that WriteManifest returns a ManifestFile whose FilePath()
|
||||
// matches the path we pass in. This ensures the pattern used in
|
||||
// rewriteManifests (compute filename once, pass to both WriteManifest
|
||||
// and saveFilerFile) produces consistent references.
|
||||
schema := newTestSchema()
|
||||
spec := *iceberg.UnpartitionedSpec
|
||||
|
||||
snapshotID := int64(42)
|
||||
manifestFileName := fmt.Sprintf("merged-%d-%d.avro", snapshotID, int64(1700000000000))
|
||||
manifestPath := "metadata/" + manifestFileName
|
||||
|
||||
// Create a minimal manifest entry to write
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentData,
|
||||
"data/test.parquet",
|
||||
iceberg.ParquetFile,
|
||||
map[int]any{},
|
||||
nil, nil,
|
||||
1, // recordCount
|
||||
1024, // fileSizeBytes
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build data file: %v", err)
|
||||
}
|
||||
entry := iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusADDED,
|
||||
&snapshotID,
|
||||
nil, nil,
|
||||
dfBuilder.Build(),
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
mf, err := iceberg.WriteManifest(
|
||||
manifestPath,
|
||||
&buf,
|
||||
2, // version
|
||||
spec,
|
||||
schema,
|
||||
snapshotID,
|
||||
[]iceberg.ManifestEntry{entry},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteManifest failed: %v", err)
|
||||
}
|
||||
|
||||
if mf.FilePath() != manifestPath {
|
||||
t.Errorf("manifest FilePath() = %q, want %q", mf.FilePath(), manifestPath)
|
||||
}
|
||||
|
||||
// Verify the filename we'd use for saveFilerFile matches
|
||||
if path.Base(mf.FilePath()) != manifestFileName {
|
||||
t.Errorf("manifest base name = %q, want %q", path.Base(mf.FilePath()), manifestFileName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestRewriteNestedPathConsistency(t *testing.T) {
|
||||
// Verify that WriteManifest with nested paths preserves the full path
|
||||
// and that loadFileByIcebergPath (via normalizeIcebergPath) would
|
||||
// resolve them correctly.
|
||||
schema := newTestSchema()
|
||||
spec := *iceberg.UnpartitionedSpec
|
||||
snapshotID := int64(42)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
manifestPath string
|
||||
}{
|
||||
{"nested two levels", "metadata/a/b/merged-42-1700000000000.avro"},
|
||||
{"nested one level", "metadata/subdir/manifest-42.avro"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
spec,
|
||||
iceberg.EntryContentData,
|
||||
"data/test.parquet",
|
||||
iceberg.ParquetFile,
|
||||
map[int]any{},
|
||||
nil, nil,
|
||||
1, 1024,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build data file: %v", err)
|
||||
}
|
||||
entry := iceberg.NewManifestEntry(
|
||||
iceberg.EntryStatusADDED,
|
||||
&snapshotID,
|
||||
nil, nil,
|
||||
dfBuilder.Build(),
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
mf, err := iceberg.WriteManifest(
|
||||
tc.manifestPath, &buf, 2, spec, schema, snapshotID,
|
||||
[]iceberg.ManifestEntry{entry},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteManifest failed: %v", err)
|
||||
}
|
||||
|
||||
if mf.FilePath() != tc.manifestPath {
|
||||
t.Errorf("FilePath() = %q, want %q", mf.FilePath(), tc.manifestPath)
|
||||
}
|
||||
|
||||
// normalizeIcebergPath should return the path unchanged when already relative
|
||||
normalized := normalizeIcebergPath(tc.manifestPath, "bucket", "ns/table")
|
||||
if normalized != tc.manifestPath {
|
||||
t.Errorf("normalizeIcebergPath(%q) = %q, want %q", tc.manifestPath, normalized, tc.manifestPath)
|
||||
}
|
||||
|
||||
// Verify normalization strips S3 scheme prefix correctly
|
||||
s3Path := "s3://bucket/ns/table/" + tc.manifestPath
|
||||
normalized = normalizeIcebergPath(s3Path, "bucket", "ns/table")
|
||||
if normalized != tc.manifestPath {
|
||||
t.Errorf("normalizeIcebergPath(%q) = %q, want %q", s3Path, normalized, tc.manifestPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIcebergPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
icebergPath string
|
||||
bucket string
|
||||
tablePath string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"relative metadata path",
|
||||
"metadata/snap-1.avro",
|
||||
"mybucket", "ns/table",
|
||||
"metadata/snap-1.avro",
|
||||
},
|
||||
{
|
||||
"relative data path",
|
||||
"data/file.parquet",
|
||||
"mybucket", "ns/table",
|
||||
"data/file.parquet",
|
||||
},
|
||||
{
|
||||
"S3 URL",
|
||||
"s3://mybucket/ns/table/metadata/snap-1.avro",
|
||||
"mybucket", "ns/table",
|
||||
"metadata/snap-1.avro",
|
||||
},
|
||||
{
|
||||
"absolute filer path",
|
||||
"/buckets/mybucket/ns/table/data/file.parquet",
|
||||
"mybucket", "ns/table",
|
||||
"data/file.parquet",
|
||||
},
|
||||
{
|
||||
"nested data path",
|
||||
"data/region=us/city=sf/file.parquet",
|
||||
"mybucket", "ns/table",
|
||||
"data/region=us/city=sf/file.parquet",
|
||||
},
|
||||
{
|
||||
"S3 URL nested",
|
||||
"s3://mybucket/ns/table/data/region=us/file.parquet",
|
||||
"mybucket", "ns/table",
|
||||
"data/region=us/file.parquet",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := normalizeIcebergPath(tc.icebergPath, tc.bucket, tc.tablePath)
|
||||
if result != tc.expected {
|
||||
t.Errorf("normalizeIcebergPath(%q, %q, %q) = %q, want %q",
|
||||
tc.icebergPath, tc.bucket, tc.tablePath, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
partition map[int]any
|
||||
expected string
|
||||
}{
|
||||
{"empty partition", map[int]any{}, "__unpartitioned__"},
|
||||
{"nil partition", nil, "__unpartitioned__"},
|
||||
{"single field", map[int]any{1: "us-east"}, "1=\"us-east\""},
|
||||
{"multiple fields sorted", map[int]any{3: "2024", 1: "us-east"}, "1=\"us-east\"\x003=\"2024\""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := partitionKey(tc.partition)
|
||||
if result != tc.expected {
|
||||
t.Errorf("partitionKey(%v) = %q, want %q", tc.partition, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCompactionBins(t *testing.T) {
|
||||
targetSize := int64(256 * 1024 * 1024) // 256MB
|
||||
minFiles := 3
|
||||
|
||||
// Create test entries: small files in same partition
|
||||
entries := makeTestEntries(t, []testEntrySpec{
|
||||
{path: "data/f1.parquet", size: 1024, partition: map[int]any{}},
|
||||
{path: "data/f2.parquet", size: 2048, partition: map[int]any{}},
|
||||
{path: "data/f3.parquet", size: 4096, partition: map[int]any{}},
|
||||
})
|
||||
|
||||
bins := buildCompactionBins(entries, targetSize, minFiles)
|
||||
if len(bins) != 1 {
|
||||
t.Fatalf("expected 1 bin, got %d", len(bins))
|
||||
}
|
||||
if len(bins[0].Entries) != 3 {
|
||||
t.Errorf("expected 3 entries in bin, got %d", len(bins[0].Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCompactionBinsFiltersLargeFiles(t *testing.T) {
|
||||
targetSize := int64(4000)
|
||||
minFiles := 2
|
||||
|
||||
entries := makeTestEntries(t, []testEntrySpec{
|
||||
{path: "data/small1.parquet", size: 1024, partition: map[int]any{}},
|
||||
{path: "data/small2.parquet", size: 2048, partition: map[int]any{}},
|
||||
{path: "data/large.parquet", size: 5000, partition: map[int]any{}},
|
||||
})
|
||||
|
||||
bins := buildCompactionBins(entries, targetSize, minFiles)
|
||||
if len(bins) != 1 {
|
||||
t.Fatalf("expected 1 bin, got %d", len(bins))
|
||||
}
|
||||
if len(bins[0].Entries) != 2 {
|
||||
t.Errorf("expected 2 entries (large excluded), got %d", len(bins[0].Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCompactionBinsMinFilesThreshold(t *testing.T) {
|
||||
targetSize := int64(256 * 1024 * 1024)
|
||||
minFiles := 5
|
||||
|
||||
entries := makeTestEntries(t, []testEntrySpec{
|
||||
{path: "data/f1.parquet", size: 1024, partition: map[int]any{}},
|
||||
{path: "data/f2.parquet", size: 2048, partition: map[int]any{}},
|
||||
})
|
||||
|
||||
bins := buildCompactionBins(entries, targetSize, minFiles)
|
||||
if len(bins) != 0 {
|
||||
t.Errorf("expected 0 bins (below min threshold), got %d", len(bins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCompactionBinsMultiplePartitions(t *testing.T) {
|
||||
targetSize := int64(256 * 1024 * 1024)
|
||||
minFiles := 2
|
||||
|
||||
partA := map[int]any{1: "us-east"}
|
||||
partB := map[int]any{1: "eu-west"}
|
||||
|
||||
entries := makeTestEntries(t, []testEntrySpec{
|
||||
{path: "data/a1.parquet", size: 1024, partition: partA},
|
||||
{path: "data/a2.parquet", size: 2048, partition: partA},
|
||||
{path: "data/b1.parquet", size: 1024, partition: partB},
|
||||
{path: "data/b2.parquet", size: 2048, partition: partB},
|
||||
{path: "data/b3.parquet", size: 4096, partition: partB},
|
||||
})
|
||||
|
||||
bins := buildCompactionBins(entries, targetSize, minFiles)
|
||||
if len(bins) != 2 {
|
||||
t.Fatalf("expected 2 bins (one per partition), got %d", len(bins))
|
||||
}
|
||||
}
|
||||
|
||||
type testEntrySpec struct {
|
||||
path string
|
||||
size int64
|
||||
partition map[int]any
|
||||
}
|
||||
|
||||
func makeTestEntries(t *testing.T, specs []testEntrySpec) []iceberg.ManifestEntry {
|
||||
t.Helper()
|
||||
entries := make([]iceberg.ManifestEntry, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
dfBuilder, err := iceberg.NewDataFileBuilder(
|
||||
*iceberg.UnpartitionedSpec,
|
||||
iceberg.EntryContentData,
|
||||
spec.path,
|
||||
iceberg.ParquetFile,
|
||||
spec.partition,
|
||||
nil, nil,
|
||||
1, // recordCount (must be > 0)
|
||||
spec.size,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build data file %s: %v", spec.path, err)
|
||||
}
|
||||
snapID := int64(1)
|
||||
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build())
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func TestDetectNilRequest(t *testing.T) {
|
||||
handler := NewHandler(nil)
|
||||
err := handler.Detect(nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteNilRequest(t *testing.T) {
|
||||
handler := NewHandler(nil)
|
||||
err := handler.Execute(nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil request")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildTestMetadata creates a minimal Iceberg metadata for testing.
|
||||
// When snapshots is nil or empty, the metadata has no snapshots.
|
||||
func buildTestMetadata(t *testing.T, snapshots []table.Snapshot) table.Metadata {
|
||||
t.Helper()
|
||||
|
||||
schema := newTestSchema()
|
||||
meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec, table.UnsortedSortOrder, "s3://test-bucket/test-table", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test metadata: %v", err)
|
||||
}
|
||||
|
||||
if len(snapshots) == 0 {
|
||||
return meta
|
||||
}
|
||||
|
||||
builder, err := table.MetadataBuilderFromBase(meta, "s3://test-bucket/test-table")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create metadata builder: %v", err)
|
||||
}
|
||||
|
||||
var lastSnapID int64
|
||||
for _, snap := range snapshots {
|
||||
s := snap // copy
|
||||
if err := builder.AddSnapshot(&s); err != nil {
|
||||
t.Fatalf("failed to add snapshot %d: %v", snap.SnapshotID, err)
|
||||
}
|
||||
lastSnapID = snap.SnapshotID
|
||||
}
|
||||
|
||||
if err := builder.SetSnapshotRef(table.MainBranch, lastSnapID, table.BranchRef); err != nil {
|
||||
t.Fatalf("failed to set snapshot ref: %v", err)
|
||||
}
|
||||
|
||||
result, err := builder.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build metadata: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func newTestSchema() *iceberg.Schema {
|
||||
return iceberg.NewSchema(0,
|
||||
iceberg.NestedField{ID: 1, Type: iceberg.PrimitiveTypes.Int64, Name: "id", Required: true},
|
||||
iceberg.NestedField{ID: 2, Type: iceberg.PrimitiveTypes.String, Name: "name", Required: false},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// errStalePlan is returned by a commit mutation when the table head has
|
||||
// advanced since planning. The caller should not retry the same plan.
|
||||
var errStalePlan = errors.New("stale plan: table head changed since planning")
|
||||
|
||||
// errMetadataVersionConflict is returned when the xattr update detects a
|
||||
// concurrent metadata version change (compare-and-swap failure).
|
||||
var errMetadataVersionConflict = errors.New("metadata version conflict")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation: Expire Snapshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// expireSnapshots removes old snapshots from the table metadata and cleans up
|
||||
// their manifest list files.
|
||||
func (h *Handler) expireSnapshots(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, error) {
|
||||
// Load current metadata
|
||||
meta, metadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
|
||||
snapshots := meta.Snapshots()
|
||||
if len(snapshots) == 0 {
|
||||
return "no snapshots", nil
|
||||
}
|
||||
|
||||
// Determine which snapshots to expire
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
var currentSnapID int64
|
||||
if currentSnap != nil {
|
||||
currentSnapID = currentSnap.SnapshotID
|
||||
}
|
||||
|
||||
retentionMs := config.SnapshotRetentionHours * 3600 * 1000
|
||||
nowMs := time.Now().UnixMilli()
|
||||
|
||||
// Sort snapshots by timestamp descending (most recent first) so that
|
||||
// the keep-count logic always preserves the newest snapshots.
|
||||
sorted := make([]table.Snapshot, len(snapshots))
|
||||
copy(sorted, snapshots)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].TimestampMs > sorted[j].TimestampMs
|
||||
})
|
||||
|
||||
// Walk from newest to oldest. The current snapshot is always kept.
|
||||
// Among the remaining, keep up to MaxSnapshotsToKeep-1 (since current
|
||||
// counts toward the quota). Expire the rest only if they exceed the
|
||||
// retention window; snapshots within the window are kept regardless.
|
||||
var toExpire []int64
|
||||
var kept int64
|
||||
for _, snap := range sorted {
|
||||
if snap.SnapshotID == currentSnapID {
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
age := nowMs - snap.TimestampMs
|
||||
if kept < config.MaxSnapshotsToKeep {
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
if age > retentionMs {
|
||||
toExpire = append(toExpire, snap.SnapshotID)
|
||||
} else {
|
||||
kept++
|
||||
}
|
||||
}
|
||||
|
||||
if len(toExpire) == 0 {
|
||||
return "no snapshots expired", nil
|
||||
}
|
||||
|
||||
// Split snapshots into expired and kept sets
|
||||
expireSet := make(map[int64]struct{}, len(toExpire))
|
||||
for _, id := range toExpire {
|
||||
expireSet[id] = struct{}{}
|
||||
}
|
||||
var expiredSnaps, keptSnaps []table.Snapshot
|
||||
for _, snap := range sorted {
|
||||
if _, ok := expireSet[snap.SnapshotID]; ok {
|
||||
expiredSnaps = append(expiredSnaps, snap)
|
||||
} else {
|
||||
keptSnaps = append(keptSnaps, snap)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all files referenced by each set before modifying metadata.
|
||||
// This lets us determine which files become unreferenced.
|
||||
expiredFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, tablePath, expiredSnaps)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("collect expired snapshot files: %w", err)
|
||||
}
|
||||
keptFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, tablePath, keptSnaps)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("collect kept snapshot files: %w", err)
|
||||
}
|
||||
|
||||
// Normalize kept file paths for consistent comparison
|
||||
normalizedKept := make(map[string]struct{}, len(keptFiles))
|
||||
for f := range keptFiles {
|
||||
normalizedKept[normalizeIcebergPath(f, bucketName, tablePath)] = struct{}{}
|
||||
}
|
||||
|
||||
// Use MetadataBuilder to remove snapshots and create new metadata
|
||||
err = h.commitWithRetry(ctx, filerClient, bucketName, tablePath, metadataFileName, config, func(currentMeta table.Metadata, builder *table.MetadataBuilder) error {
|
||||
// Guard: verify table head hasn't changed since we planned
|
||||
cs := currentMeta.CurrentSnapshot()
|
||||
if (cs == nil) != (currentSnapID == 0) || (cs != nil && cs.SnapshotID != currentSnapID) {
|
||||
return errStalePlan
|
||||
}
|
||||
return builder.RemoveSnapshots(toExpire)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("commit snapshot expiration: %w", err)
|
||||
}
|
||||
|
||||
// Delete files exclusively referenced by expired snapshots (best-effort)
|
||||
tableBasePath := path.Join(s3tables.TablesPath, bucketName, tablePath)
|
||||
deletedCount := 0
|
||||
for filePath := range expiredFiles {
|
||||
normalized := normalizeIcebergPath(filePath, bucketName, tablePath)
|
||||
if _, stillReferenced := normalizedKept[normalized]; stillReferenced {
|
||||
continue
|
||||
}
|
||||
dir := path.Join(tableBasePath, path.Dir(normalized))
|
||||
fileName := path.Base(normalized)
|
||||
if delErr := deleteFilerFile(ctx, filerClient, dir, fileName); delErr != nil {
|
||||
glog.Warningf("iceberg maintenance: failed to delete unreferenced file %s: %v", filePath, delErr)
|
||||
} else {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("expired %d snapshot(s), deleted %d unreferenced file(s)", len(toExpire), deletedCount), nil
|
||||
}
|
||||
|
||||
// collectSnapshotFiles returns all file paths (manifest lists, manifest files,
|
||||
// data files) referenced by the given snapshots. It returns an error if any
|
||||
// manifest list or manifest cannot be read/parsed, to prevent delete decisions
|
||||
// based on incomplete reference data.
|
||||
func collectSnapshotFiles(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
snapshots []table.Snapshot,
|
||||
) (map[string]struct{}, error) {
|
||||
files := make(map[string]struct{})
|
||||
for _, snap := range snapshots {
|
||||
if snap.ManifestList == "" {
|
||||
continue
|
||||
}
|
||||
files[snap.ManifestList] = struct{}{}
|
||||
|
||||
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, snap.ManifestList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest list %s: %w", snap.ManifestList, err)
|
||||
}
|
||||
manifests, err := iceberg.ReadManifestList(bytes.NewReader(manifestListData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse manifest list %s: %w", snap.ManifestList, err)
|
||||
}
|
||||
|
||||
for _, mf := range manifests {
|
||||
files[mf.FilePath()] = struct{}{}
|
||||
|
||||
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, mf.FilePath())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
files[entry.DataFile().FilePath()] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation: Remove Orphans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// removeOrphans finds and deletes unreferenced files from the table's
|
||||
// metadata/ and data/ directories.
|
||||
func (h *Handler) removeOrphans(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, error) {
|
||||
// Load current metadata
|
||||
meta, metadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
|
||||
// Collect all referenced files from all snapshots
|
||||
referencedFiles, err := collectSnapshotFiles(ctx, filerClient, bucketName, tablePath, meta.Snapshots())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("collect referenced files: %w", err)
|
||||
}
|
||||
|
||||
// Reference the active metadata file so it is not treated as orphan
|
||||
referencedFiles[path.Join("metadata", metadataFileName)] = struct{}{}
|
||||
|
||||
// Also reference the current metadata files
|
||||
for mle := range meta.PreviousFiles() {
|
||||
referencedFiles[mle.MetadataFile] = struct{}{}
|
||||
}
|
||||
|
||||
// Precompute a normalized lookup set so orphan checks are O(1) per file.
|
||||
normalizedRefs := make(map[string]struct{}, len(referencedFiles))
|
||||
for ref := range referencedFiles {
|
||||
normalizedRefs[ref] = struct{}{}
|
||||
normalizedRefs[normalizeIcebergPath(ref, bucketName, tablePath)] = struct{}{}
|
||||
}
|
||||
|
||||
// List actual files on filer in metadata/ and data/ directories
|
||||
tableBasePath := path.Join(s3tables.TablesPath, bucketName, tablePath)
|
||||
safetyThreshold := time.Now().Add(-time.Duration(config.OrphanOlderThanHours) * time.Hour)
|
||||
orphanCount := 0
|
||||
|
||||
for _, subdir := range []string{"metadata", "data"} {
|
||||
dirPath := path.Join(tableBasePath, subdir)
|
||||
fileEntries, err := walkFilerEntries(ctx, filerClient, dirPath)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("iceberg maintenance: cannot walk %s: %v", dirPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, fe := range fileEntries {
|
||||
entry := fe.Entry
|
||||
// Build relative path from the table base (e.g. "data/region=us/file.parquet")
|
||||
fullPath := path.Join(fe.Dir, entry.Name)
|
||||
relPath := strings.TrimPrefix(fullPath, tableBasePath+"/")
|
||||
|
||||
_, isReferenced := normalizedRefs[relPath]
|
||||
|
||||
if isReferenced {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check safety window — skip entries with unknown age
|
||||
if entry.Attributes == nil {
|
||||
continue
|
||||
}
|
||||
mtime := time.Unix(entry.Attributes.Mtime, 0)
|
||||
if mtime.After(safetyThreshold) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Delete orphan
|
||||
if delErr := deleteFilerFile(ctx, filerClient, fe.Dir, entry.Name); delErr != nil {
|
||||
glog.Warningf("iceberg maintenance: failed to delete orphan %s/%s: %v", fe.Dir, entry.Name, delErr)
|
||||
} else {
|
||||
orphanCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("removed %d orphan file(s)", orphanCount), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation: Rewrite Manifests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// rewriteManifests merges small manifests into fewer, larger ones.
|
||||
func (h *Handler) rewriteManifests(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath string,
|
||||
config Config,
|
||||
) (string, error) {
|
||||
// Load current metadata
|
||||
meta, metadataFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load metadata: %w", err)
|
||||
}
|
||||
|
||||
currentSnap := meta.CurrentSnapshot()
|
||||
if currentSnap == nil || currentSnap.ManifestList == "" {
|
||||
return "no current snapshot", nil
|
||||
}
|
||||
|
||||
// Read manifest list
|
||||
manifestListData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, currentSnap.ManifestList)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read manifest list: %w", err)
|
||||
}
|
||||
|
||||
manifests, err := iceberg.ReadManifestList(bytes.NewReader(manifestListData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse manifest list: %w", err)
|
||||
}
|
||||
|
||||
if int64(len(manifests)) < config.MinInputFiles {
|
||||
return fmt.Sprintf("only %d manifests, below threshold of %d", len(manifests), config.MinInputFiles), nil
|
||||
}
|
||||
|
||||
// Collect all entries from data manifests, grouped by partition spec ID
|
||||
// so we write one merged manifest per spec (required for spec-evolved tables).
|
||||
type specEntries struct {
|
||||
specID int32
|
||||
spec iceberg.PartitionSpec
|
||||
entries []iceberg.ManifestEntry
|
||||
}
|
||||
specMap := make(map[int32]*specEntries)
|
||||
|
||||
// Build a lookup from spec ID to PartitionSpec
|
||||
specByID := make(map[int]iceberg.PartitionSpec)
|
||||
for _, ps := range meta.PartitionSpecs() {
|
||||
specByID[ps.ID()] = ps
|
||||
}
|
||||
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() != iceberg.ManifestContentData {
|
||||
continue
|
||||
}
|
||||
manifestData, err := loadFileByIcebergPath(ctx, filerClient, bucketName, tablePath, mf.FilePath())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(manifestData), true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse manifest %s: %w", mf.FilePath(), err)
|
||||
}
|
||||
|
||||
sid := mf.PartitionSpecID()
|
||||
se, ok := specMap[sid]
|
||||
if !ok {
|
||||
ps, found := specByID[int(sid)]
|
||||
if !found {
|
||||
return "", fmt.Errorf("partition spec %d not found in table metadata", sid)
|
||||
}
|
||||
se = &specEntries{specID: sid, spec: ps}
|
||||
specMap[sid] = se
|
||||
}
|
||||
se.entries = append(se.entries, entries...)
|
||||
}
|
||||
|
||||
if len(specMap) == 0 {
|
||||
return "no data entries to rewrite", nil
|
||||
}
|
||||
|
||||
schema := meta.CurrentSchema()
|
||||
version := meta.Version()
|
||||
snapshotID := currentSnap.SnapshotID
|
||||
newSnapshotID := time.Now().UnixMilli()
|
||||
newSeqNum := currentSnap.SequenceNumber + 1
|
||||
metaDir := path.Join(s3tables.TablesPath, bucketName, tablePath, "metadata")
|
||||
|
||||
// Track written artifacts so we can clean them up if the commit fails.
|
||||
type artifact struct {
|
||||
dir, fileName string
|
||||
}
|
||||
var writtenArtifacts []artifact
|
||||
committed := false
|
||||
|
||||
defer func() {
|
||||
if committed || len(writtenArtifacts) == 0 {
|
||||
return
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
for _, a := range writtenArtifacts {
|
||||
if err := deleteFilerFile(cleanupCtx, filerClient, a.dir, a.fileName); err != nil {
|
||||
glog.Warningf("iceberg rewrite-manifests: failed to clean up artifact %s/%s: %v", a.dir, a.fileName, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Write one merged manifest per partition spec
|
||||
var newManifests []iceberg.ManifestFile
|
||||
totalEntries := 0
|
||||
for _, se := range specMap {
|
||||
totalEntries += len(se.entries)
|
||||
manifestFileName := fmt.Sprintf("merged-%d-spec%d-%d.avro", newSnapshotID, se.specID, time.Now().UnixMilli())
|
||||
manifestPath := path.Join("metadata", manifestFileName)
|
||||
|
||||
var manifestBuf bytes.Buffer
|
||||
mergedManifest, err := iceberg.WriteManifest(
|
||||
manifestPath,
|
||||
&manifestBuf,
|
||||
version,
|
||||
se.spec,
|
||||
schema,
|
||||
newSnapshotID,
|
||||
se.entries,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write merged manifest for spec %d: %w", se.specID, err)
|
||||
}
|
||||
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, manifestFileName, manifestBuf.Bytes()); err != nil {
|
||||
return "", fmt.Errorf("save merged manifest for spec %d: %w", se.specID, err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestFileName})
|
||||
newManifests = append(newManifests, mergedManifest)
|
||||
}
|
||||
|
||||
// Include any delete manifests that were not rewritten
|
||||
for _, mf := range manifests {
|
||||
if mf.ManifestContent() != iceberg.ManifestContentData {
|
||||
newManifests = append(newManifests, mf)
|
||||
}
|
||||
}
|
||||
|
||||
var manifestListBuf bytes.Buffer
|
||||
err = iceberg.WriteManifestList(version, &manifestListBuf, newSnapshotID, &snapshotID, &newSeqNum, 0, newManifests)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write manifest list: %w", err)
|
||||
}
|
||||
|
||||
// Save new manifest list
|
||||
manifestListFileName := fmt.Sprintf("snap-%d-%d.avro", newSnapshotID, time.Now().UnixMilli())
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, manifestListFileName, manifestListBuf.Bytes()); err != nil {
|
||||
return "", fmt.Errorf("save manifest list: %w", err)
|
||||
}
|
||||
writtenArtifacts = append(writtenArtifacts, artifact{dir: metaDir, fileName: manifestListFileName})
|
||||
|
||||
// Create new snapshot with the rewritten manifest list
|
||||
manifestListLocation := path.Join("metadata", manifestListFileName)
|
||||
|
||||
err = h.commitWithRetry(ctx, filerClient, bucketName, tablePath, metadataFileName, config, func(currentMeta table.Metadata, builder *table.MetadataBuilder) error {
|
||||
// Guard: verify table head hasn't advanced since we planned.
|
||||
// The merged manifest and manifest list were built against snapshotID;
|
||||
// if the head moved, they reference stale state.
|
||||
cs := currentMeta.CurrentSnapshot()
|
||||
if cs == nil || cs.SnapshotID != snapshotID {
|
||||
return errStalePlan
|
||||
}
|
||||
|
||||
newSnapshot := &table.Snapshot{
|
||||
SnapshotID: newSnapshotID,
|
||||
ParentSnapshotID: &snapshotID,
|
||||
SequenceNumber: cs.SequenceNumber + 1,
|
||||
TimestampMs: time.Now().UnixMilli(),
|
||||
ManifestList: manifestListLocation,
|
||||
Summary: &table.Summary{
|
||||
Operation: table.OpReplace,
|
||||
Properties: map[string]string{"maintenance": "rewrite_manifests"},
|
||||
},
|
||||
SchemaID: func() *int {
|
||||
id := schema.ID
|
||||
return &id
|
||||
}(),
|
||||
}
|
||||
if err := builder.AddSnapshot(newSnapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
return builder.SetSnapshotRef(
|
||||
table.MainBranch,
|
||||
newSnapshotID,
|
||||
table.BranchRef,
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("commit manifest rewrite: %w", err)
|
||||
}
|
||||
|
||||
committed = true
|
||||
return fmt.Sprintf("rewrote %d manifests into %d (%d entries)", len(manifests), len(specMap), totalEntries), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commit Protocol with Retry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// commitWithRetry implements optimistic concurrency for metadata updates.
|
||||
// It reads the current metadata, applies the mutation, writes a new metadata
|
||||
// file, and updates the table entry. On version conflict, it retries.
|
||||
func (h *Handler) commitWithRetry(
|
||||
ctx context.Context,
|
||||
filerClient filer_pb.SeaweedFilerClient,
|
||||
bucketName, tablePath, currentMetadataFileName string,
|
||||
config Config,
|
||||
mutate func(currentMeta table.Metadata, builder *table.MetadataBuilder) error,
|
||||
) error {
|
||||
maxRetries := config.MaxCommitRetries
|
||||
if maxRetries <= 0 || maxRetries > 20 {
|
||||
maxRetries = defaultMaxCommitRetries
|
||||
}
|
||||
|
||||
for attempt := int64(0); attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(50*(1<<(attempt-1))) * time.Millisecond // exponential: 50ms, 100ms, 200ms, ...
|
||||
const maxBackoff = 5 * time.Second
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
jitter := time.Duration(rand.Int64N(int64(backoff) / 5)) // 0–20% of backoff
|
||||
timer := time.NewTimer(backoff + jitter)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Load current metadata
|
||||
meta, metaFileName, err := loadCurrentMetadata(ctx, filerClient, bucketName, tablePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load metadata (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
// Build new metadata — pass the current metadata file path so the
|
||||
// metadata log correctly records where the previous version lives.
|
||||
currentMetaFilePath := path.Join("metadata", metaFileName)
|
||||
builder, err := table.MetadataBuilderFromBase(meta, currentMetaFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create metadata builder (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
// Apply the mutation
|
||||
if err := mutate(meta, builder); err != nil {
|
||||
return fmt.Errorf("apply mutation (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
if !builder.HasChanges() {
|
||||
return nil // nothing to commit
|
||||
}
|
||||
|
||||
newMeta, err := builder.Build()
|
||||
if err != nil {
|
||||
return fmt.Errorf("build metadata (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
// Serialize
|
||||
metadataBytes, err := json.Marshal(newMeta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal metadata (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
// Determine new metadata file name. Include a timestamp suffix so
|
||||
// concurrent writers stage to distinct files instead of clobbering.
|
||||
currentVersion := extractMetadataVersion(metaFileName)
|
||||
newVersion := currentVersion + 1
|
||||
newMetadataFileName := fmt.Sprintf("v%d-%d.metadata.json", newVersion, time.Now().UnixNano())
|
||||
|
||||
// Save new metadata file
|
||||
metaDir := path.Join(s3tables.TablesPath, bucketName, tablePath, "metadata")
|
||||
if err := saveFilerFile(ctx, filerClient, metaDir, newMetadataFileName, metadataBytes); err != nil {
|
||||
return fmt.Errorf("save metadata file (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
// Update the table entry's xattr with new metadata (CAS on version)
|
||||
tableDir := path.Join(s3tables.TablesPath, bucketName, tablePath)
|
||||
newMetadataLocation := path.Join("metadata", newMetadataFileName)
|
||||
err = updateTableMetadataXattr(ctx, filerClient, tableDir, currentVersion, metadataBytes, newMetadataLocation)
|
||||
if err != nil {
|
||||
// Use a detached context for cleanup so staged files are removed
|
||||
// even if the original context was canceled.
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if !errors.Is(err, errMetadataVersionConflict) {
|
||||
// Non-conflict error (permissions, transport, etc.): fail immediately.
|
||||
_ = deleteFilerFile(cleanupCtx, filerClient, metaDir, newMetadataFileName)
|
||||
cleanupCancel()
|
||||
return fmt.Errorf("update table xattr (attempt %d): %w", attempt, err)
|
||||
}
|
||||
// Version conflict: clean up the new metadata file and retry
|
||||
_ = deleteFilerFile(cleanupCtx, filerClient, metaDir, newMetadataFileName)
|
||||
cleanupCancel()
|
||||
if attempt < maxRetries-1 {
|
||||
glog.V(1).Infof("iceberg maintenance: version conflict on %s/%s, retrying (attempt %d)", bucketName, tablePath, attempt)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("update table xattr (attempt %d): %w", attempt, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("exceeded max commit retries (%d)", maxRetries)
|
||||
}
|
||||
@@ -23,6 +23,17 @@ const (
|
||||
DefaultMaxExecutionConcurrency = int32(2)
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterHandler(HandlerFactory{
|
||||
JobType: "vacuum",
|
||||
Category: CategoryDefault,
|
||||
Aliases: []string{"vol.vacuum", "volume.vacuum"},
|
||||
Build: func(opts HandlerBuildOptions) (JobHandler, error) {
|
||||
return NewVacuumHandler(opts.GrpcDialOption, int32(opts.MaxExecute)), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// VacuumHandler is the plugin job handler for vacuum job type.
|
||||
type VacuumHandler struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
@@ -180,9 +191,9 @@ func (h *VacuumHandler) Detect(ctx context.Context, request *plugin_pb.RunDetect
|
||||
}
|
||||
|
||||
workerConfig := deriveVacuumConfig(request.GetWorkerConfigValues())
|
||||
if shouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), workerConfig.MinIntervalSeconds) {
|
||||
if ShouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), workerConfig.MinIntervalSeconds) {
|
||||
minInterval := time.Duration(workerConfig.MinIntervalSeconds) * time.Second
|
||||
_ = sender.SendActivity(buildDetectorActivity(
|
||||
_ = sender.SendActivity(BuildDetectorActivity(
|
||||
"skipped_by_interval",
|
||||
fmt.Sprintf("VACUUM: Detection skipped due to min interval (%s)", minInterval),
|
||||
map[string]*plugin_pb.ConfigValue{
|
||||
@@ -311,7 +322,7 @@ func emitVacuumDetectionDecisionTrace(
|
||||
)
|
||||
}
|
||||
|
||||
if err := sender.SendActivity(buildDetectorActivity(summaryStage, summaryMessage, map[string]*plugin_pb.ConfigValue{
|
||||
if err := sender.SendActivity(BuildDetectorActivity(summaryStage, summaryMessage, map[string]*plugin_pb.ConfigValue{
|
||||
"total_volumes": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(totalVolumes)},
|
||||
},
|
||||
@@ -351,7 +362,7 @@ func emitVacuumDetectionDecisionTrace(
|
||||
metric.Age.Truncate(time.Minute),
|
||||
minVolumeAge.Truncate(time.Minute),
|
||||
)
|
||||
if err := sender.SendActivity(buildDetectorActivity("decision_volume", message, map[string]*plugin_pb.ConfigValue{
|
||||
if err := sender.SendActivity(BuildDetectorActivity("decision_volume", message, map[string]*plugin_pb.ConfigValue{
|
||||
"volume_id": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(metric.VolumeID)},
|
||||
},
|
||||
@@ -429,7 +440,7 @@ func (h *VacuumHandler) Execute(ctx context.Context, request *plugin_pb.ExecuteJ
|
||||
Stage: stage,
|
||||
Message: message,
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity(stage, message),
|
||||
BuildExecutorActivity(stage, message),
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -442,7 +453,7 @@ func (h *VacuumHandler) Execute(ctx context.Context, request *plugin_pb.ExecuteJ
|
||||
Stage: "assigned",
|
||||
Message: "vacuum job accepted",
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("assigned", "vacuum job accepted"),
|
||||
BuildExecutorActivity("assigned", "vacuum job accepted"),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
@@ -457,7 +468,7 @@ func (h *VacuumHandler) Execute(ctx context.Context, request *plugin_pb.ExecuteJ
|
||||
Stage: "failed",
|
||||
Message: err.Error(),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("failed", err.Error()),
|
||||
BuildExecutorActivity("failed", err.Error()),
|
||||
},
|
||||
})
|
||||
return err
|
||||
@@ -480,7 +491,7 @@ func (h *VacuumHandler) Execute(ctx context.Context, request *plugin_pb.ExecuteJ
|
||||
},
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("completed", resultSummary),
|
||||
BuildExecutorActivity("completed", resultSummary),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -709,7 +720,9 @@ func mapTaskPriority(priority workertypes.TaskPriority) plugin_pb.JobPriority {
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipDetectionByInterval(lastSuccessfulRun *timestamppb.Timestamp, minIntervalSeconds int) bool {
|
||||
// ShouldSkipDetectionByInterval returns true when less than minIntervalSeconds
|
||||
// have elapsed since lastSuccessfulRun. Exported so sub-packages can reuse it.
|
||||
func ShouldSkipDetectionByInterval(lastSuccessfulRun *timestamppb.Timestamp, minIntervalSeconds int) bool {
|
||||
if lastSuccessfulRun == nil || minIntervalSeconds <= 0 {
|
||||
return false
|
||||
}
|
||||
@@ -720,7 +733,8 @@ func shouldSkipDetectionByInterval(lastSuccessfulRun *timestamppb.Timestamp, min
|
||||
return time.Since(lastRun) < time.Duration(minIntervalSeconds)*time.Second
|
||||
}
|
||||
|
||||
func buildExecutorActivity(stage string, message string) *plugin_pb.ActivityEvent {
|
||||
// BuildExecutorActivity creates an executor activity event. Exported for sub-packages.
|
||||
func BuildExecutorActivity(stage string, message string) *plugin_pb.ActivityEvent {
|
||||
return &plugin_pb.ActivityEvent{
|
||||
Source: plugin_pb.ActivitySource_ACTIVITY_SOURCE_EXECUTOR,
|
||||
Stage: stage,
|
||||
@@ -729,7 +743,8 @@ func buildExecutorActivity(stage string, message string) *plugin_pb.ActivityEven
|
||||
}
|
||||
}
|
||||
|
||||
func buildDetectorActivity(stage string, message string, details map[string]*plugin_pb.ConfigValue) *plugin_pb.ActivityEvent {
|
||||
// BuildDetectorActivity creates a detector activity event. Exported for sub-packages.
|
||||
func BuildDetectorActivity(stage string, message string, details map[string]*plugin_pb.ConfigValue) *plugin_pb.ActivityEvent {
|
||||
return &plugin_pb.ActivityEvent{
|
||||
Source: plugin_pb.ActivitySource_ACTIVITY_SOURCE_DETECTOR,
|
||||
Stage: stage,
|
||||
|
||||
@@ -122,20 +122,20 @@ func TestMasterAddressCandidates(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShouldSkipDetectionByInterval(t *testing.T) {
|
||||
if shouldSkipDetectionByInterval(nil, 10) {
|
||||
if ShouldSkipDetectionByInterval(nil, 10) {
|
||||
t.Fatalf("expected false when timestamp is nil")
|
||||
}
|
||||
if shouldSkipDetectionByInterval(timestamppb.Now(), 0) {
|
||||
if ShouldSkipDetectionByInterval(timestamppb.Now(), 0) {
|
||||
t.Fatalf("expected false when min interval is zero")
|
||||
}
|
||||
|
||||
recent := timestamppb.New(time.Now().Add(-5 * time.Second))
|
||||
if !shouldSkipDetectionByInterval(recent, 10) {
|
||||
if !ShouldSkipDetectionByInterval(recent, 10) {
|
||||
t.Fatalf("expected true for recent successful run")
|
||||
}
|
||||
|
||||
old := timestamppb.New(time.Now().Add(-30 * time.Second))
|
||||
if shouldSkipDetectionByInterval(old, 10) {
|
||||
if ShouldSkipDetectionByInterval(old, 10) {
|
||||
t.Fatalf("expected false for old successful run")
|
||||
}
|
||||
}
|
||||
@@ -182,7 +182,7 @@ func TestVacuumHandlerDetectSkipsByMinInterval(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildExecutorActivity(t *testing.T) {
|
||||
activity := buildExecutorActivity("running", "vacuum in progress")
|
||||
activity := BuildExecutorActivity("running", "vacuum in progress")
|
||||
if activity == nil {
|
||||
t.Fatalf("expected non-nil activity")
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ const (
|
||||
defaultBalanceTimeoutSeconds = int32(10 * 60)
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterHandler(HandlerFactory{
|
||||
JobType: "volume_balance",
|
||||
Category: CategoryDefault,
|
||||
Aliases: []string{"balance", "volume.balance", "volume-balance"},
|
||||
Build: func(opts HandlerBuildOptions) (JobHandler, error) {
|
||||
return NewVolumeBalanceHandler(opts.GrpcDialOption), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type volumeBalanceWorkerConfig struct {
|
||||
TaskConfig *balancetask.Config
|
||||
MinIntervalSeconds int
|
||||
@@ -176,9 +187,9 @@ func (h *VolumeBalanceHandler) Detect(
|
||||
}
|
||||
|
||||
workerConfig := deriveBalanceWorkerConfig(request.GetWorkerConfigValues())
|
||||
if shouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), workerConfig.MinIntervalSeconds) {
|
||||
if ShouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), workerConfig.MinIntervalSeconds) {
|
||||
minInterval := time.Duration(workerConfig.MinIntervalSeconds) * time.Second
|
||||
_ = sender.SendActivity(buildDetectorActivity(
|
||||
_ = sender.SendActivity(BuildDetectorActivity(
|
||||
"skipped_by_interval",
|
||||
fmt.Sprintf("VOLUME BALANCE: Detection skipped due to min interval (%s)", minInterval),
|
||||
map[string]*plugin_pb.ConfigValue{
|
||||
@@ -284,7 +295,7 @@ func emitVolumeBalanceDetectionDecisionTrace(
|
||||
)
|
||||
}
|
||||
|
||||
if err := sender.SendActivity(buildDetectorActivity("decision_summary", summaryMessage, map[string]*plugin_pb.ConfigValue{
|
||||
if err := sender.SendActivity(BuildDetectorActivity("decision_summary", summaryMessage, map[string]*plugin_pb.ConfigValue{
|
||||
"total_volumes": {
|
||||
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(totalVolumes)},
|
||||
},
|
||||
@@ -331,7 +342,7 @@ func emitVolumeBalanceDetectionDecisionTrace(
|
||||
volumeCount,
|
||||
minVolumeCount,
|
||||
)
|
||||
if err := sender.SendActivity(buildDetectorActivity("decision_disk_type", message, map[string]*plugin_pb.ConfigValue{
|
||||
if err := sender.SendActivity(BuildDetectorActivity("decision_disk_type", message, map[string]*plugin_pb.ConfigValue{
|
||||
"disk_type": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: diskType},
|
||||
},
|
||||
@@ -362,7 +373,7 @@ func emitVolumeBalanceDetectionDecisionTrace(
|
||||
len(serverVolumeCounts),
|
||||
taskConfig.MinServerCount,
|
||||
)
|
||||
if err := sender.SendActivity(buildDetectorActivity("decision_disk_type", message, map[string]*plugin_pb.ConfigValue{
|
||||
if err := sender.SendActivity(BuildDetectorActivity("decision_disk_type", message, map[string]*plugin_pb.ConfigValue{
|
||||
"disk_type": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: diskType},
|
||||
},
|
||||
@@ -433,7 +444,7 @@ func emitVolumeBalanceDetectionDecisionTrace(
|
||||
)
|
||||
}
|
||||
|
||||
if err := sender.SendActivity(buildDetectorActivity(stage, message, map[string]*plugin_pb.ConfigValue{
|
||||
if err := sender.SendActivity(BuildDetectorActivity(stage, message, map[string]*plugin_pb.ConfigValue{
|
||||
"disk_type": {
|
||||
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: diskType},
|
||||
},
|
||||
@@ -534,7 +545,7 @@ func (h *VolumeBalanceHandler) Execute(
|
||||
Stage: stage,
|
||||
Message: message,
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity(stage, message),
|
||||
BuildExecutorActivity(stage, message),
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -547,7 +558,7 @@ func (h *VolumeBalanceHandler) Execute(
|
||||
Stage: "assigned",
|
||||
Message: "volume balance job accepted",
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("assigned", "volume balance job accepted"),
|
||||
BuildExecutorActivity("assigned", "volume balance job accepted"),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
@@ -562,7 +573,7 @@ func (h *VolumeBalanceHandler) Execute(
|
||||
Stage: "failed",
|
||||
Message: err.Error(),
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("failed", err.Error()),
|
||||
BuildExecutorActivity("failed", err.Error()),
|
||||
},
|
||||
})
|
||||
return err
|
||||
@@ -591,7 +602,7 @@ func (h *VolumeBalanceHandler) Execute(
|
||||
},
|
||||
},
|
||||
Activities: []*plugin_pb.ActivityEvent{
|
||||
buildExecutorActivity("completed", resultSummary),
|
||||
BuildExecutorActivity("completed", resultSummary),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ func (c *BrokerClient) GetTopicSchema(ctx context.Context, namespace, topicName
|
||||
}
|
||||
|
||||
// Read the topic.conf file content
|
||||
data, err := filer.ReadInsideFiler(client, topicDir, "topic.conf")
|
||||
data, err := filer.ReadInsideFiler(ctx, client, topicDir, "topic.conf")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read topic.conf for %s.%s: %v", namespace, topicName, err)
|
||||
}
|
||||
|
||||
@@ -625,6 +625,9 @@ func (hms *HybridMessageScanner) countLiveLogFiles(partition topic.Partition) (i
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.Entry == nil {
|
||||
continue
|
||||
}
|
||||
// Count files that are not .parquet files (live log files)
|
||||
// Live log files typically have timestamps or are named like log files
|
||||
fileName := resp.Entry.Name
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
@@ -67,6 +68,10 @@ type IdentityAccessManagement struct {
|
||||
// Bucket policy engine for evaluating bucket policies
|
||||
policyEngine *BucketPolicyEngine
|
||||
|
||||
// Cached policy engine for IAM policy fallback evaluation.
|
||||
// Keyed by policy name, kept in sync by PutPolicy/DeletePolicy.
|
||||
iamPolicyEngine *policy_engine.PolicyEngine
|
||||
|
||||
// background polling
|
||||
stopChan chan struct{}
|
||||
shutdownOnce sync.Once
|
||||
@@ -658,6 +663,7 @@ func (iam *IdentityAccessManagement) ReplaceS3ApiConfiguration(config *iam_pb.S3
|
||||
iam.nameToIdentity = nameToIdentity
|
||||
iam.accessKeyIdent = accessKeyIdent
|
||||
iam.policies = policies
|
||||
iam.rebuildIAMPolicyEngineLocked()
|
||||
|
||||
// Re-add environment-based identities that were preserved
|
||||
for _, envIdent := range envIdentities {
|
||||
@@ -914,6 +920,7 @@ func (iam *IdentityAccessManagement) MergeS3ApiConfiguration(config *iam_pb.S3Ap
|
||||
iam.nameToIdentity = nameToIdentity
|
||||
iam.accessKeyIdent = accessKeyIdent
|
||||
iam.policies = policies
|
||||
iam.rebuildIAMPolicyEngineLocked()
|
||||
// Update authentication state based on whether identities exist
|
||||
// Once enabled, keep it enabled (one-way toggle)
|
||||
authJustEnabled := iam.updateAuthenticationState(len(identities))
|
||||
@@ -1546,6 +1553,165 @@ func (iam *IdentityAccessManagement) GetCredentialManager() *credential.Credenti
|
||||
return iam.credentialManager
|
||||
}
|
||||
|
||||
type managedPolicyLoader interface {
|
||||
LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error)
|
||||
}
|
||||
|
||||
type inlinePolicyLoader interface {
|
||||
LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error)
|
||||
}
|
||||
|
||||
func inlinePolicyRuntimeName(userName, policyName string) string {
|
||||
return "__inline_policy__/" + userName + "/" + policyName
|
||||
}
|
||||
|
||||
func mergePoliciesIntoConfiguration(config *iam_pb.S3ApiConfiguration, policies []*iam_pb.Policy) {
|
||||
if len(policies) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
existingPolicies := make(map[string]int, len(config.Policies))
|
||||
for idx, policy := range config.Policies {
|
||||
if policy == nil || policy.Name == "" {
|
||||
continue
|
||||
}
|
||||
existingPolicies[policy.Name] = idx
|
||||
}
|
||||
|
||||
for _, policy := range policies {
|
||||
if policy == nil || policy.Name == "" {
|
||||
continue
|
||||
}
|
||||
policyCopy := &iam_pb.Policy{Name: policy.Name, Content: policy.Content}
|
||||
if existingIdx, found := existingPolicies[policy.Name]; found {
|
||||
config.Policies[existingIdx] = policyCopy
|
||||
continue
|
||||
}
|
||||
|
||||
config.Policies = append(config.Policies, policyCopy)
|
||||
existingPolicies[policy.Name] = len(config.Policies) - 1
|
||||
}
|
||||
}
|
||||
|
||||
func appendUniquePolicyName(policyNames []string, policyName string) []string {
|
||||
for _, existingPolicyName := range policyNames {
|
||||
if existingPolicyName == policyName {
|
||||
return policyNames
|
||||
}
|
||||
}
|
||||
return append(policyNames, policyName)
|
||||
}
|
||||
|
||||
func (iam *IdentityAccessManagement) loadManagedPoliciesForRuntime(ctx context.Context) ([]*iam_pb.Policy, error) {
|
||||
store := iam.credentialManager.GetStore()
|
||||
if store == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if loader, ok := store.(managedPolicyLoader); ok {
|
||||
return loader.LoadManagedPolicies(ctx)
|
||||
}
|
||||
|
||||
policies, err := iam.credentialManager.GetPolicies(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
managedPolicies := make([]*iam_pb.Policy, 0, len(policies))
|
||||
for name, policyDocument := range policies {
|
||||
content, err := json.Marshal(policyDocument)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal policy %q: %w", name, err)
|
||||
}
|
||||
|
||||
managedPolicies = append(managedPolicies, &iam_pb.Policy{
|
||||
Name: name,
|
||||
Content: string(content),
|
||||
})
|
||||
}
|
||||
|
||||
return managedPolicies, nil
|
||||
}
|
||||
|
||||
func (iam *IdentityAccessManagement) hydrateRuntimePolicies(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
|
||||
if iam.credentialManager == nil || config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
managedPolicies, err := iam.loadManagedPoliciesForRuntime(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load managed policies for runtime: %w", err)
|
||||
}
|
||||
mergePoliciesIntoConfiguration(config, managedPolicies)
|
||||
|
||||
store := iam.credentialManager.GetStore()
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
inlineLoader, ok := store.(inlinePolicyLoader)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
inlinePoliciesByUser, err := inlineLoader.LoadInlinePolicies(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load inline policies for runtime: %w", err)
|
||||
}
|
||||
|
||||
if len(inlinePoliciesByUser) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
identityByName := make(map[string]*iam_pb.Identity, len(config.Identities))
|
||||
for _, identity := range config.Identities {
|
||||
identityByName[identity.Name] = identity
|
||||
}
|
||||
|
||||
inlinePolicies := make([]*iam_pb.Policy, 0)
|
||||
for userName, userPolicies := range inlinePoliciesByUser {
|
||||
identity, found := identityByName[userName]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
for policyName, policyDocument := range userPolicies {
|
||||
content, err := json.Marshal(policyDocument)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal inline policy %q for user %q: %w", policyName, userName, err)
|
||||
}
|
||||
|
||||
runtimePolicyName := inlinePolicyRuntimeName(userName, policyName)
|
||||
inlinePolicies = append(inlinePolicies, &iam_pb.Policy{
|
||||
Name: runtimePolicyName,
|
||||
Content: string(content),
|
||||
})
|
||||
identity.PolicyNames = appendUniquePolicyName(identity.PolicyNames, runtimePolicyName)
|
||||
}
|
||||
}
|
||||
|
||||
mergePoliciesIntoConfiguration(config, inlinePolicies)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (iam *IdentityAccessManagement) syncRuntimePoliciesToIAMManager(ctx context.Context, policies []*iam_pb.Policy) error {
|
||||
if iam == nil || iam.iamIntegration == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
provider, ok := iam.iamIntegration.(IAMManagerProvider)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
manager := provider.GetIAMManager()
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return manager.SyncRuntimePolicies(ctx, policies)
|
||||
}
|
||||
|
||||
// LoadS3ApiConfigurationFromCredentialManager loads configuration using the credential manager
|
||||
func (iam *IdentityAccessManagement) LoadS3ApiConfigurationFromCredentialManager() error {
|
||||
glog.V(1).Infof("Loading S3 API configuration from credential manager")
|
||||
@@ -1559,6 +1725,15 @@ func (iam *IdentityAccessManagement) LoadS3ApiConfigurationFromCredentialManager
|
||||
glog.V(2).Infof("Credential manager returned %d identities and %d accounts",
|
||||
len(s3ApiConfiguration.Identities), len(s3ApiConfiguration.Accounts))
|
||||
|
||||
if err := iam.hydrateRuntimePolicies(context.Background(), s3ApiConfiguration); err != nil {
|
||||
glog.Errorf("Failed to hydrate runtime IAM policies: %v", err)
|
||||
return err
|
||||
}
|
||||
if err := iam.syncRuntimePoliciesToIAMManager(context.Background(), s3ApiConfiguration.Policies); err != nil {
|
||||
glog.Errorf("Failed to sync runtime IAM policies to advanced IAM manager: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := iam.loadS3ApiConfiguration(s3ApiConfiguration); err != nil {
|
||||
glog.Errorf("Failed to load S3 API configuration: %v", err)
|
||||
return err
|
||||
@@ -1658,6 +1833,51 @@ func determineIAMAuthPath(sessionToken, principal, principalArn string) iamAuthP
|
||||
return iamAuthPathNone
|
||||
}
|
||||
|
||||
// evaluateIAMPolicies evaluates attached IAM policies for a user identity.
|
||||
// Returns true if any matching statement explicitly allows the action.
|
||||
// Uses the cached iamPolicyEngine to avoid re-parsing policy JSON on every request.
|
||||
func (iam *IdentityAccessManagement) evaluateIAMPolicies(r *http.Request, identity *Identity, action Action, bucket, object string) bool {
|
||||
if identity == nil || len(identity.PolicyNames) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
iam.m.RLock()
|
||||
engine := iam.iamPolicyEngine
|
||||
iam.m.RUnlock()
|
||||
|
||||
if engine == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resource := buildResourceARN(bucket, object)
|
||||
principal := buildPrincipalARN(identity, r)
|
||||
s3Action := ResolveS3Action(r, string(action), bucket, object)
|
||||
explicitAllow := false
|
||||
conditions := policy_engine.ExtractConditionValuesFromRequest(r)
|
||||
for k, v := range policy_engine.ExtractPrincipalVariables(principal) {
|
||||
conditions[k] = v
|
||||
}
|
||||
|
||||
for _, policyName := range identity.PolicyNames {
|
||||
result := engine.EvaluatePolicy(policyName, &policy_engine.PolicyEvaluationArgs{
|
||||
Action: s3Action,
|
||||
Resource: resource,
|
||||
Principal: principal,
|
||||
Conditions: conditions,
|
||||
Claims: identity.Claims,
|
||||
})
|
||||
|
||||
if result == policy_engine.PolicyResultDeny {
|
||||
return false
|
||||
}
|
||||
if result == policy_engine.PolicyResultAllow {
|
||||
explicitAllow = true
|
||||
}
|
||||
}
|
||||
|
||||
return explicitAllow
|
||||
}
|
||||
|
||||
// VerifyActionPermission checks if the identity is allowed to perform the action on the resource.
|
||||
// It handles both traditional identities (via Actions) and IAM/STS identities (via Policy).
|
||||
func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, identity *Identity, action Action, bucket, object string) s3err.ErrorCode {
|
||||
@@ -1674,11 +1894,24 @@ func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, ide
|
||||
hasSessionToken := r.Header.Get("X-SeaweedFS-Session-Token") != "" ||
|
||||
r.Header.Get("X-Amz-Security-Token") != "" ||
|
||||
r.URL.Query().Get("X-Amz-Security-Token") != ""
|
||||
hasAttachedPolicies := len(identity.PolicyNames) > 0
|
||||
|
||||
if (len(identity.Actions) == 0 || hasSessionToken) && iam.iamIntegration != nil {
|
||||
if (len(identity.Actions) == 0 || hasSessionToken || hasAttachedPolicies) && iam.iamIntegration != nil {
|
||||
return iam.authorizeWithIAM(r, identity, action, bucket, object)
|
||||
}
|
||||
|
||||
// Attached IAM policies are authoritative for IAM users. The legacy Actions
|
||||
// field is a lossy projection that cannot represent deny statements,
|
||||
// conditions, or fine-grained action differences such as PutObject vs
|
||||
// DeleteObject.
|
||||
if hasAttachedPolicies {
|
||||
if iam.evaluateIAMPolicies(r, identity, action, bucket, object) {
|
||||
return s3err.ErrNone
|
||||
}
|
||||
return s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Traditional actions-based authorization from static S3 config.
|
||||
if len(identity.Actions) > 0 {
|
||||
if !identity.CanDo(action, bucket, object) {
|
||||
return s3err.ErrAccessDenied
|
||||
@@ -1752,6 +1985,12 @@ func (iam *IdentityAccessManagement) PutPolicy(name string, content string) erro
|
||||
iam.policies = make(map[string]*iam_pb.Policy)
|
||||
}
|
||||
iam.policies[name] = &iam_pb.Policy{Name: name, Content: content}
|
||||
iam.ensureIAMPolicyEngine()
|
||||
// Remove old entry first so that a parse failure doesn't leave a stale allow.
|
||||
_ = iam.iamPolicyEngine.DeleteBucketPolicy(name)
|
||||
if err := iam.iamPolicyEngine.SetBucketPolicy(name, content); err != nil {
|
||||
glog.Warningf("IAM policy %q is stored but could not be compiled for cache: %v", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1770,9 +2009,36 @@ func (iam *IdentityAccessManagement) DeletePolicy(name string) error {
|
||||
iam.m.Lock()
|
||||
defer iam.m.Unlock()
|
||||
delete(iam.policies, name)
|
||||
if iam.iamPolicyEngine != nil {
|
||||
_ = iam.iamPolicyEngine.DeleteBucketPolicy(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureIAMPolicyEngine lazily initializes the shared IAM policy engine.
|
||||
// Must be called with iam.m held.
|
||||
func (iam *IdentityAccessManagement) ensureIAMPolicyEngine() {
|
||||
if iam.iamPolicyEngine == nil {
|
||||
iam.iamPolicyEngine = policy_engine.NewPolicyEngine()
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildIAMPolicyEngineLocked rebuilds the entire IAM policy engine cache
|
||||
// from the current policies map. Must be called with iam.m held.
|
||||
func (iam *IdentityAccessManagement) rebuildIAMPolicyEngineLocked() {
|
||||
if len(iam.policies) == 0 {
|
||||
iam.iamPolicyEngine = nil
|
||||
return
|
||||
}
|
||||
engine := policy_engine.NewPolicyEngine()
|
||||
for name, p := range iam.policies {
|
||||
if err := engine.SetBucketPolicy(name, p.Content); err != nil {
|
||||
glog.Warningf("IAM policy cache rebuild: skipping invalid policy %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
iam.iamPolicyEngine = engine
|
||||
}
|
||||
|
||||
// ListPolicies lists all policies
|
||||
func (iam *IdentityAccessManagement) ListPolicies() []*iam_pb.Policy {
|
||||
iam.m.RLock()
|
||||
|
||||
@@ -1,24 +1,81 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/credential"
|
||||
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
. "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
jsonpb "google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc"
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
|
||||
)
|
||||
|
||||
type loadConfigurationDropsPoliciesStore struct {
|
||||
*memory.MemoryStore
|
||||
loadManagedPoliciesCalled bool
|
||||
}
|
||||
|
||||
func (store *loadConfigurationDropsPoliciesStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
|
||||
config, err := store.MemoryStore.LoadConfiguration(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stripped := *config
|
||||
stripped.Policies = nil
|
||||
return &stripped, nil
|
||||
}
|
||||
|
||||
func (store *loadConfigurationDropsPoliciesStore) LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error) {
|
||||
store.loadManagedPoliciesCalled = true
|
||||
|
||||
config, err := store.MemoryStore.LoadConfiguration(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policies := make([]*iam_pb.Policy, 0, len(config.Policies))
|
||||
for _, policy := range config.Policies {
|
||||
policies = append(policies, &iam_pb.Policy{
|
||||
Name: policy.Name,
|
||||
Content: policy.Content,
|
||||
})
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
type inlinePolicyRuntimeStore struct {
|
||||
*memory.MemoryStore
|
||||
inlinePolicies map[string]map[string]policy_engine.PolicyDocument
|
||||
}
|
||||
|
||||
func (store *inlinePolicyRuntimeStore) LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
|
||||
_ = ctx
|
||||
return store.inlinePolicies, nil
|
||||
}
|
||||
|
||||
func newPolicyAuthRequest(t *testing.T, method string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, "http://s3.amazonaws.com/test-bucket/test-object", nil)
|
||||
require.NoError(t, err)
|
||||
return req
|
||||
}
|
||||
|
||||
func TestIdentityListFileFormat(t *testing.T) {
|
||||
|
||||
s3ApiConfiguration := &iam_pb.S3ApiConfiguration{}
|
||||
@@ -260,6 +317,453 @@ func TestMatchWildcardPattern(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyActionPermissionPolicyFallback(t *testing.T) {
|
||||
buildRequest := func(t *testing.T, method string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, "http://s3.amazonaws.com/test-bucket/test-object", nil)
|
||||
assert.NoError(t, err)
|
||||
return req
|
||||
}
|
||||
|
||||
t.Run("policy allow grants access", func(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
err := iam.PutPolicy("allowGet", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
identity := &Identity{
|
||||
Name: "policy-user",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{"allowGet"},
|
||||
}
|
||||
|
||||
errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
})
|
||||
|
||||
t.Run("explicit deny overrides allow", func(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
err := iam.PutPolicy("allowAllGet", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`)
|
||||
assert.NoError(t, err)
|
||||
err = iam.PutPolicy("denySecret", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/secret.txt"}]}`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
identity := &Identity{
|
||||
Name: "policy-user",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{"allowAllGet", "denySecret"},
|
||||
}
|
||||
|
||||
errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "secret.txt")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode)
|
||||
})
|
||||
|
||||
t.Run("implicit deny when no statement matches", func(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
err := iam.PutPolicy("allowOtherBucket", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::other-bucket/*"}]}`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
identity := &Identity{
|
||||
Name: "policy-user",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{"allowOtherBucket"},
|
||||
}
|
||||
|
||||
errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode)
|
||||
})
|
||||
|
||||
t.Run("invalid policy document does not allow", func(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
err := iam.PutPolicy("invalidPolicy", "{not-json")
|
||||
assert.NoError(t, err)
|
||||
|
||||
identity := &Identity{
|
||||
Name: "policy-user",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{"invalidPolicy"},
|
||||
}
|
||||
|
||||
errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode)
|
||||
})
|
||||
|
||||
t.Run("notresource excludes denied object", func(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
err := iam.PutPolicy("denyNotResource", `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:GetObject","NotResource":"arn:aws:s3:::test-bucket/public/*"}]}`)
|
||||
assert.NoError(t, err)
|
||||
err = iam.PutPolicy("allowAllGet", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
identity := &Identity{
|
||||
Name: "policy-user",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{"allowAllGet", "denyNotResource"},
|
||||
}
|
||||
|
||||
errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "private/secret.txt")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode)
|
||||
|
||||
errCode = iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "public/readme.txt")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
})
|
||||
|
||||
t.Run("condition securetransport enforced", func(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
err := iam.PutPolicy("allowTLSOnly", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*","Condition":{"Bool":{"aws:SecureTransport":"true"}}}]}`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
identity := &Identity{
|
||||
Name: "policy-user",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{"allowTLSOnly"},
|
||||
}
|
||||
|
||||
httpReq := buildRequest(t, http.MethodGet)
|
||||
errCode := iam.VerifyActionPermission(httpReq, identity, Action(ACTION_READ), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode)
|
||||
|
||||
httpsReq := buildRequest(t, http.MethodGet)
|
||||
httpsReq.TLS = &tls.ConnectionState{}
|
||||
errCode = iam.VerifyActionPermission(httpsReq, identity, Action(ACTION_READ), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
})
|
||||
|
||||
t.Run("attached policies override coarse legacy actions", func(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
err := iam.PutPolicy("putOnly", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
identity := &Identity{
|
||||
Name: "policy-user",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{"Write:test-bucket"},
|
||||
PolicyNames: []string{"putOnly"},
|
||||
}
|
||||
|
||||
putErrCode := iam.VerifyActionPermission(buildRequest(t, http.MethodPut), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrNone, putErrCode)
|
||||
|
||||
deleteErrCode := iam.VerifyActionPermission(buildRequest(t, http.MethodDelete), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, deleteErrCode)
|
||||
})
|
||||
|
||||
t.Run("valid policy updated to invalid denies access", func(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
err := iam.PutPolicy("myPolicy", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`)
|
||||
assert.NoError(t, err)
|
||||
|
||||
identity := &Identity{
|
||||
Name: "policy-user",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{"myPolicy"},
|
||||
}
|
||||
|
||||
errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
|
||||
// Update to invalid JSON — should revoke access.
|
||||
err = iam.PutPolicy("myPolicy", "{broken")
|
||||
assert.NoError(t, err)
|
||||
|
||||
errCode = iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode)
|
||||
})
|
||||
|
||||
t.Run("actions based path still works", func(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
identity := &Identity{
|
||||
Name: "legacy-user",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{"Read:test-bucket"},
|
||||
}
|
||||
|
||||
errCode := iam.VerifyActionPermission(buildRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "any-object")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadS3ApiConfigurationFromCredentialManagerHydratesManagedPolicies(t *testing.T) {
|
||||
baseStore := &memory.MemoryStore{}
|
||||
assert.NoError(t, baseStore.Initialize(nil, ""))
|
||||
|
||||
store := &loadConfigurationDropsPoliciesStore{MemoryStore: baseStore}
|
||||
cm := &credential.CredentialManager{Store: store}
|
||||
|
||||
config := &iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{
|
||||
{
|
||||
Name: "managed-user",
|
||||
PolicyNames: []string{"managedGet"},
|
||||
Credentials: []*iam_pb.Credential{
|
||||
{AccessKey: "AKIAMANAGED000001", SecretKey: "managed-secret"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []*iam_pb.Policy{
|
||||
{
|
||||
Name: "managedGet",
|
||||
Content: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.NoError(t, cm.SaveConfiguration(context.Background(), config))
|
||||
|
||||
iam := &IdentityAccessManagement{credentialManager: cm}
|
||||
assert.NoError(t, iam.LoadS3ApiConfigurationFromCredentialManager())
|
||||
assert.True(t, store.loadManagedPoliciesCalled)
|
||||
|
||||
identity := iam.lookupByIdentityName("managed-user")
|
||||
if !assert.NotNil(t, identity) {
|
||||
return
|
||||
}
|
||||
|
||||
errCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
}
|
||||
|
||||
func TestLoadS3ApiConfigurationFromCredentialManagerHydratesManagedPoliciesThroughPropagatingStore(t *testing.T) {
|
||||
baseStore := &memory.MemoryStore{}
|
||||
assert.NoError(t, baseStore.Initialize(nil, ""))
|
||||
|
||||
upstream := &loadConfigurationDropsPoliciesStore{MemoryStore: baseStore}
|
||||
wrappedStore := credential.NewPropagatingCredentialStore(upstream, nil, nil)
|
||||
cm := &credential.CredentialManager{Store: wrappedStore}
|
||||
|
||||
config := &iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{
|
||||
{
|
||||
Name: "managed-user",
|
||||
PolicyNames: []string{"managedGet"},
|
||||
Credentials: []*iam_pb.Credential{
|
||||
{AccessKey: "AKIAMANAGED000010", SecretKey: "managed-secret"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []*iam_pb.Policy{
|
||||
{
|
||||
Name: "managedGet",
|
||||
Content: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.NoError(t, cm.SaveConfiguration(context.Background(), config))
|
||||
|
||||
iam := &IdentityAccessManagement{credentialManager: cm}
|
||||
assert.NoError(t, iam.LoadS3ApiConfigurationFromCredentialManager())
|
||||
assert.True(t, upstream.loadManagedPoliciesCalled)
|
||||
|
||||
identity := iam.lookupByIdentityName("managed-user")
|
||||
if !assert.NotNil(t, identity) {
|
||||
return
|
||||
}
|
||||
|
||||
errCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodGet), identity, Action(ACTION_READ), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
}
|
||||
|
||||
func TestLoadS3ApiConfigurationFromCredentialManagerSyncsPoliciesToIAMManager(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baseStore := &memory.MemoryStore{}
|
||||
assert.NoError(t, baseStore.Initialize(nil, ""))
|
||||
|
||||
cm := &credential.CredentialManager{Store: baseStore}
|
||||
config := &iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{
|
||||
{
|
||||
Name: "managed-user",
|
||||
PolicyNames: []string{"managedPut"},
|
||||
Credentials: []*iam_pb.Credential{
|
||||
{AccessKey: "AKIAMANAGED000002", SecretKey: "managed-secret"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []*iam_pb.Policy{
|
||||
{
|
||||
Name: "managedPut",
|
||||
Content: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject","s3:ListBucket"],"Resource":["arn:aws:s3:::cli-allowed-bucket","arn:aws:s3:::cli-allowed-bucket/*"]}]}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.NoError(t, cm.SaveConfiguration(ctx, config))
|
||||
|
||||
iamManager, err := loadIAMManagerFromConfig("", func() string { return "localhost:8888" }, func() string {
|
||||
return "fallback-key-for-zero-config"
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
iamManager.SetUserStore(cm)
|
||||
|
||||
iam := &IdentityAccessManagement{credentialManager: cm}
|
||||
iam.SetIAMIntegration(NewS3IAMIntegration(iamManager, ""))
|
||||
|
||||
assert.NoError(t, iam.LoadS3ApiConfigurationFromCredentialManager())
|
||||
|
||||
identity := iam.lookupByIdentityName("managed-user")
|
||||
if !assert.NotNil(t, identity) {
|
||||
return
|
||||
}
|
||||
|
||||
allowedErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodPut), identity, Action(ACTION_WRITE), "cli-allowed-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrNone, allowedErrCode)
|
||||
|
||||
forbiddenErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodPut), identity, Action(ACTION_WRITE), "cli-forbidden-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, forbiddenErrCode)
|
||||
}
|
||||
|
||||
func TestLoadS3ApiConfigurationFromCredentialManagerHydratesInlinePolicies(t *testing.T) {
|
||||
baseStore := &memory.MemoryStore{}
|
||||
assert.NoError(t, baseStore.Initialize(nil, ""))
|
||||
|
||||
inlinePolicy := policy_engine.PolicyDocument{
|
||||
Version: policy_engine.PolicyVersion2012_10_17,
|
||||
Statement: []policy_engine.PolicyStatement{
|
||||
{
|
||||
Effect: policy_engine.PolicyEffectAllow,
|
||||
Action: policy_engine.NewStringOrStringSlice("s3:PutObject"),
|
||||
Resource: policy_engine.NewStringOrStringSlice("arn:aws:s3:::test-bucket/*"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
store := &inlinePolicyRuntimeStore{
|
||||
MemoryStore: baseStore,
|
||||
inlinePolicies: map[string]map[string]policy_engine.PolicyDocument{
|
||||
"inline-user": {
|
||||
"PutOnly": inlinePolicy,
|
||||
},
|
||||
},
|
||||
}
|
||||
cm := &credential.CredentialManager{Store: store}
|
||||
|
||||
config := &iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{
|
||||
{
|
||||
Name: "inline-user",
|
||||
Actions: []string{"Write:test-bucket"},
|
||||
Credentials: []*iam_pb.Credential{
|
||||
{AccessKey: "AKIAINLINE0000001", SecretKey: "inline-secret"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.NoError(t, cm.SaveConfiguration(context.Background(), config))
|
||||
|
||||
iam := &IdentityAccessManagement{credentialManager: cm}
|
||||
assert.NoError(t, iam.LoadS3ApiConfigurationFromCredentialManager())
|
||||
|
||||
identity := iam.lookupByIdentityName("inline-user")
|
||||
if !assert.NotNil(t, identity) {
|
||||
return
|
||||
}
|
||||
assert.Contains(t, identity.PolicyNames, inlinePolicyRuntimeName("inline-user", "PutOnly"))
|
||||
|
||||
putErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodPut), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrNone, putErrCode)
|
||||
|
||||
deleteErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodDelete), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, deleteErrCode)
|
||||
}
|
||||
|
||||
func TestLoadS3ApiConfigurationFromCredentialManagerHydratesInlinePoliciesThroughPropagatingStore(t *testing.T) {
|
||||
baseStore := &memory.MemoryStore{}
|
||||
assert.NoError(t, baseStore.Initialize(nil, ""))
|
||||
|
||||
inlinePolicy := policy_engine.PolicyDocument{
|
||||
Version: policy_engine.PolicyVersion2012_10_17,
|
||||
Statement: []policy_engine.PolicyStatement{
|
||||
{
|
||||
Effect: policy_engine.PolicyEffectAllow,
|
||||
Action: policy_engine.NewStringOrStringSlice("s3:PutObject"),
|
||||
Resource: policy_engine.NewStringOrStringSlice("arn:aws:s3:::test-bucket/*"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
upstream := &inlinePolicyRuntimeStore{
|
||||
MemoryStore: baseStore,
|
||||
inlinePolicies: map[string]map[string]policy_engine.PolicyDocument{
|
||||
"inline-user": {
|
||||
"PutOnly": inlinePolicy,
|
||||
},
|
||||
},
|
||||
}
|
||||
wrappedStore := credential.NewPropagatingCredentialStore(upstream, nil, nil)
|
||||
cm := &credential.CredentialManager{Store: wrappedStore}
|
||||
|
||||
config := &iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{
|
||||
{
|
||||
Name: "inline-user",
|
||||
Actions: []string{"Write:test-bucket"},
|
||||
Credentials: []*iam_pb.Credential{
|
||||
{AccessKey: "AKIAINLINE0000010", SecretKey: "inline-secret"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.NoError(t, cm.SaveConfiguration(context.Background(), config))
|
||||
|
||||
iam := &IdentityAccessManagement{credentialManager: cm}
|
||||
assert.NoError(t, iam.LoadS3ApiConfigurationFromCredentialManager())
|
||||
|
||||
identity := iam.lookupByIdentityName("inline-user")
|
||||
if !assert.NotNil(t, identity) {
|
||||
return
|
||||
}
|
||||
assert.Contains(t, identity.PolicyNames, inlinePolicyRuntimeName("inline-user", "PutOnly"))
|
||||
|
||||
putErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodPut), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrNone, putErrCode)
|
||||
|
||||
deleteErrCode := iam.VerifyActionPermission(newPolicyAuthRequest(t, http.MethodDelete), identity, Action(ACTION_WRITE), "test-bucket", "test-object")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, deleteErrCode)
|
||||
}
|
||||
|
||||
func TestLoadConfigurationDropsPoliciesStoreDoesNotMutateSourceConfig(t *testing.T) {
|
||||
baseStore := &memory.MemoryStore{}
|
||||
require.NoError(t, baseStore.Initialize(nil, ""))
|
||||
|
||||
config := &iam_pb.S3ApiConfiguration{
|
||||
Policies: []*iam_pb.Policy{
|
||||
{Name: "managedGet", Content: `{"Version":"2012-10-17","Statement":[]}`},
|
||||
},
|
||||
}
|
||||
require.NoError(t, baseStore.SaveConfiguration(context.Background(), config))
|
||||
|
||||
store := &loadConfigurationDropsPoliciesStore{MemoryStore: baseStore}
|
||||
|
||||
stripped, err := store.LoadConfiguration(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, stripped.Policies)
|
||||
|
||||
source, err := baseStore.LoadConfiguration(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, source.Policies, 1)
|
||||
assert.Equal(t, "managedGet", source.Policies[0].Name)
|
||||
}
|
||||
|
||||
func TestMergePoliciesIntoConfigurationSkipsNilPolicies(t *testing.T) {
|
||||
config := &iam_pb.S3ApiConfiguration{
|
||||
Policies: []*iam_pb.Policy{
|
||||
nil,
|
||||
{Name: "existing", Content: "old"},
|
||||
},
|
||||
}
|
||||
|
||||
mergePoliciesIntoConfiguration(config, []*iam_pb.Policy{
|
||||
nil,
|
||||
{Name: "", Content: "ignored"},
|
||||
{Name: "existing", Content: "updated"},
|
||||
{Name: "new", Content: "created"},
|
||||
})
|
||||
|
||||
require.Len(t, config.Policies, 3)
|
||||
assert.Nil(t, config.Policies[0])
|
||||
assert.Equal(t, "existing", config.Policies[1].Name)
|
||||
assert.Equal(t, "updated", config.Policies[1].Content)
|
||||
assert.Equal(t, "new", config.Policies[2].Name)
|
||||
assert.Equal(t, "created", config.Policies[2].Content)
|
||||
}
|
||||
|
||||
type LoadS3ApiConfigurationTestCase struct {
|
||||
pbAccount *iam_pb.Account
|
||||
pbIdent *iam_pb.Identity
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
func TestCopySourceWithExclamationMark(t *testing.T) {
|
||||
// Reproduce https://github.com/seaweedfs/seaweedfs/issues/8544
|
||||
testCases := []struct {
|
||||
name string
|
||||
rawCopySource string
|
||||
expectedBucket string
|
||||
expectedObject string
|
||||
expectedVersion string
|
||||
dstBucket string
|
||||
dstObject string
|
||||
shouldBeEqual bool
|
||||
}{
|
||||
{
|
||||
name: "encoded exclamation mark - different dest",
|
||||
rawCopySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/Another test!.odt",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/Hello.odt",
|
||||
shouldBeEqual: false,
|
||||
},
|
||||
{
|
||||
name: "unencoded exclamation mark - different dest",
|
||||
rawCopySource: "my-bucket/path/to/Another%20test!.odt",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/Another test!.odt",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/Hello.odt",
|
||||
shouldBeEqual: false,
|
||||
},
|
||||
{
|
||||
name: "encoded exclamation mark - same dest",
|
||||
rawCopySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/Another test!.odt",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/Another test!.odt",
|
||||
shouldBeEqual: true,
|
||||
},
|
||||
{
|
||||
name: "encoded path with versionId",
|
||||
rawCopySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt?versionId=abc123",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/Another test!.odt",
|
||||
expectedVersion: "abc123",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/Hello.odt",
|
||||
shouldBeEqual: false,
|
||||
},
|
||||
{
|
||||
name: "unencoded path with versionId",
|
||||
rawCopySource: "my-bucket/path/to/Another%20test!.odt?versionId=v2",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/Another test!.odt",
|
||||
expectedVersion: "v2",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/Another test!.odt",
|
||||
shouldBeEqual: true,
|
||||
},
|
||||
{
|
||||
name: "plus sign in key with versionId",
|
||||
rawCopySource: "my-bucket/path/to/file+name.odt?versionId=xyz",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/file+name.odt",
|
||||
expectedVersion: "xyz",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/file+name.odt",
|
||||
shouldBeEqual: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cpSrcPath, err := url.PathUnescape(tc.rawCopySource)
|
||||
if err != nil {
|
||||
cpSrcPath = tc.rawCopySource
|
||||
}
|
||||
|
||||
srcBucket, srcObject, srcVersionId := pathToBucketObjectAndVersion(tc.rawCopySource, cpSrcPath)
|
||||
|
||||
if srcBucket != tc.expectedBucket {
|
||||
t.Errorf("expected srcBucket=%q, got %q", tc.expectedBucket, srcBucket)
|
||||
}
|
||||
if srcObject != tc.expectedObject {
|
||||
t.Errorf("expected srcObject=%q, got %q", tc.expectedObject, srcObject)
|
||||
}
|
||||
if srcVersionId != tc.expectedVersion {
|
||||
t.Errorf("expected versionId=%q, got %q", tc.expectedVersion, srcVersionId)
|
||||
}
|
||||
|
||||
isEqual := srcBucket == tc.dstBucket && srcObject == tc.dstObject
|
||||
if isEqual != tc.shouldBeEqual {
|
||||
t.Errorf("expected comparison result %v, got %v (srcObject=%q, dstObject=%q)",
|
||||
tc.shouldBeEqual, isEqual, srcObject, tc.dstObject)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopySourceDecodingPlusSign verifies that + in the copy source header
|
||||
// is treated as a literal plus sign (not a space), matching url.PathUnescape
|
||||
// behavior. Using url.QueryUnescape would incorrectly convert + to space.
|
||||
func TestCopySourceDecodingPlusSign(t *testing.T) {
|
||||
// Key: "path/to/file+name.odt"
|
||||
// With url.QueryUnescape, literal "+" → " " (WRONG for paths)
|
||||
// With url.PathUnescape, literal "+" → "+" (CORRECT)
|
||||
rawCopySource := "my-bucket/path/to/file+name.odt"
|
||||
|
||||
// url.QueryUnescape would give "file name.odt" — WRONG
|
||||
queryDecoded, _ := url.QueryUnescape(rawCopySource)
|
||||
if queryDecoded == rawCopySource {
|
||||
t.Fatal("QueryUnescape should have changed + to space")
|
||||
}
|
||||
|
||||
// url.PathUnescape preserves literal "+" — CORRECT
|
||||
pathDecoded, _ := url.PathUnescape(rawCopySource)
|
||||
if pathDecoded != rawCopySource {
|
||||
t.Fatalf("PathUnescape should have preserved +, got %q", pathDecoded)
|
||||
}
|
||||
|
||||
_, srcObject, _ := pathToBucketObjectAndVersion(rawCopySource, pathDecoded)
|
||||
if srcObject != "path/to/file+name.odt" {
|
||||
t.Errorf("expected srcObject=%q, got %q", "path/to/file+name.odt", srcObject)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopySourceRoutingWithSpecialChars tests that mux variable extraction
|
||||
// correctly handles special characters like ! (%21) in both the URL path
|
||||
// and the X-Amz-Copy-Source header.
|
||||
func TestCopySourceRoutingWithSpecialChars(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
dstURL string // URL for the PUT request (destination)
|
||||
copySource string // X-Amz-Copy-Source header value
|
||||
expectSameKey bool // whether srcObject should equal dstObject
|
||||
}{
|
||||
{
|
||||
name: "different keys, source has encoded !",
|
||||
dstURL: "/my-bucket/path/to/Hello.odt",
|
||||
copySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
|
||||
expectSameKey: false,
|
||||
},
|
||||
{
|
||||
name: "same key with !, source encoded, dest unencoded",
|
||||
dstURL: "/my-bucket/path/to/Another%20test%21.odt",
|
||||
copySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
|
||||
expectSameKey: true,
|
||||
},
|
||||
{
|
||||
name: "same key with !, both percent-encoded differently",
|
||||
dstURL: "/my-bucket/path/to/Another%20test!.odt",
|
||||
copySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
|
||||
expectSameKey: true,
|
||||
},
|
||||
{
|
||||
name: "key with + sign, source has literal +",
|
||||
dstURL: "/my-bucket/path/to/file+name.odt",
|
||||
copySource: "my-bucket/path/to/file+name.odt",
|
||||
expectSameKey: true,
|
||||
},
|
||||
{
|
||||
name: "key with + sign, source has %2B",
|
||||
dstURL: "/my-bucket/path/to/file+name.odt",
|
||||
copySource: "my-bucket/path/to/file%2Bname.odt",
|
||||
expectSameKey: true,
|
||||
},
|
||||
{
|
||||
name: "lowercase %2f in copy source",
|
||||
dstURL: "/my-bucket/path/to/Hello.odt",
|
||||
copySource: "my-bucket/path%2fto%2fAnother%20test.odt",
|
||||
expectSameKey: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var capturedDstBucket, capturedDstObject string
|
||||
var capturedSrcBucket, capturedSrcObject string
|
||||
|
||||
router := mux.NewRouter().SkipClean(true)
|
||||
bucket := router.PathPrefix("/{bucket}").Subrouter()
|
||||
bucket.Methods(http.MethodPut).Path("/{object:(?s).+}").
|
||||
HeadersRegexp("X-Amz-Copy-Source", `(?i).*?(\/|%2F).*?`).
|
||||
HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedDstBucket, capturedDstObject = s3_constants.GetBucketAndObject(r)
|
||||
|
||||
rawCopySource := r.Header.Get("X-Amz-Copy-Source")
|
||||
cpSrcPath, err := url.PathUnescape(rawCopySource)
|
||||
if err != nil {
|
||||
cpSrcPath = rawCopySource
|
||||
}
|
||||
capturedSrcBucket, capturedSrcObject, _ = pathToBucketObjectAndVersion(rawCopySource, cpSrcPath)
|
||||
|
||||
if capturedSrcBucket == capturedDstBucket && capturedSrcObject == capturedDstObject {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "ErrInvalidCopyDest")
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "OK")
|
||||
}
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("PUT", tc.dstURL, nil)
|
||||
req.Header.Set("X-Amz-Copy-Source", tc.copySource)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if tc.expectSameKey {
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected same key detection (400), got %d; src=%q dst=%q",
|
||||
rr.Code, capturedSrcObject, capturedDstObject)
|
||||
}
|
||||
} else {
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected different keys (200), got %d; src=%q dst=%q",
|
||||
rr.Code, capturedSrcObject, capturedDstObject)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA
|
||||
return s3err.ErrNone // Fallback to existing authorization
|
||||
}
|
||||
|
||||
if identity.SessionToken == "" {
|
||||
if identity == nil || identity.Principal == "" {
|
||||
return s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
@@ -292,9 +292,12 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA
|
||||
|
||||
// Create action request
|
||||
actionRequest := &integration.ActionRequest{
|
||||
Principal: identity.Principal,
|
||||
Action: specificAction,
|
||||
Resource: resourceArn,
|
||||
Principal: identity.Principal,
|
||||
Action: specificAction,
|
||||
Resource: resourceArn,
|
||||
// Static SigV4 IAM users do not carry a session token. IAMManager
|
||||
// evaluates their attached policies directly and only validates STS/OIDC
|
||||
// session state when a token is actually present.
|
||||
SessionToken: identity.SessionToken,
|
||||
RequestContext: requestContext,
|
||||
PolicyNames: identity.PolicyNames,
|
||||
|
||||
@@ -13,16 +13,15 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestS3IAMMiddleware tests the basic S3 IAM middleware functionality
|
||||
func TestS3IAMMiddleware(t *testing.T) {
|
||||
// Create IAM manager
|
||||
iamManager := integration.NewIAMManager()
|
||||
func newTestS3IAMManagerWithDefaultEffect(t *testing.T, defaultEffect string) *integration.IAMManager {
|
||||
t.Helper()
|
||||
|
||||
// Initialize with test configuration
|
||||
iamManager := integration.NewIAMManager()
|
||||
config := &integration.IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
@@ -31,7 +30,7 @@ func TestS3IAMMiddleware(t *testing.T) {
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: "Deny",
|
||||
DefaultEffect: defaultEffect,
|
||||
StoreType: "memory",
|
||||
},
|
||||
Roles: &integration.RoleStoreConfig{
|
||||
@@ -40,10 +39,22 @@ func TestS3IAMMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
err := iamManager.Initialize(config, func() string {
|
||||
return "localhost:8888" // Mock filer address for testing
|
||||
return "localhost:8888"
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return iamManager
|
||||
}
|
||||
|
||||
func newTestS3IAMManager(t *testing.T) *integration.IAMManager {
|
||||
t.Helper()
|
||||
return newTestS3IAMManagerWithDefaultEffect(t, "Deny")
|
||||
}
|
||||
|
||||
// TestS3IAMMiddleware tests the basic S3 IAM middleware functionality
|
||||
func TestS3IAMMiddleware(t *testing.T) {
|
||||
iamManager := newTestS3IAMManager(t)
|
||||
|
||||
// Create S3 IAM integration
|
||||
s3IAMIntegration := NewS3IAMIntegration(iamManager, "localhost:8888")
|
||||
|
||||
@@ -52,6 +63,74 @@ func TestS3IAMMiddleware(t *testing.T) {
|
||||
assert.True(t, s3IAMIntegration.enabled)
|
||||
}
|
||||
|
||||
func TestS3IAMMiddlewareStaticV4ManagedPolicies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
iamManager := newTestS3IAMManager(t)
|
||||
|
||||
allowPolicy := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: policy.StringList{"s3:PutObject", "s3:ListBucket"},
|
||||
Resource: policy.StringList{"arn:aws:s3:::cli-allowed-bucket", "arn:aws:s3:::cli-allowed-bucket/*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, iamManager.CreatePolicy(ctx, "localhost:8888", "cli-bucket-access-policy", allowPolicy))
|
||||
|
||||
s3IAMIntegration := NewS3IAMIntegration(iamManager, "localhost:8888")
|
||||
identity := &IAMIdentity{
|
||||
Name: "cli-test-user",
|
||||
Principal: "arn:aws:iam::000000000000:user/cli-test-user",
|
||||
PolicyNames: []string{"cli-bucket-access-policy"},
|
||||
}
|
||||
|
||||
putReq := httptest.NewRequest(http.MethodPut, "http://example.com/cli-allowed-bucket/test-file.txt", http.NoBody)
|
||||
putErrCode := s3IAMIntegration.AuthorizeAction(ctx, identity, s3_constants.ACTION_WRITE, "cli-allowed-bucket", "test-file.txt", putReq)
|
||||
assert.Equal(t, s3err.ErrNone, putErrCode)
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "http://example.com/cli-allowed-bucket/", http.NoBody)
|
||||
listErrCode := s3IAMIntegration.AuthorizeAction(ctx, identity, s3_constants.ACTION_LIST, "cli-allowed-bucket", "", listReq)
|
||||
assert.Equal(t, s3err.ErrNone, listErrCode)
|
||||
}
|
||||
|
||||
func TestS3IAMMiddlewareAttachedPoliciesRestrictDefaultAllow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
iamManager := newTestS3IAMManagerWithDefaultEffect(t, "Allow")
|
||||
|
||||
allowPolicy := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: policy.StringList{"s3:PutObject", "s3:ListBucket"},
|
||||
Resource: policy.StringList{"arn:aws:s3:::cli-allowed-bucket", "arn:aws:s3:::cli-allowed-bucket/*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, iamManager.CreatePolicy(ctx, "localhost:8888", "cli-bucket-access-policy", allowPolicy))
|
||||
|
||||
s3IAMIntegration := NewS3IAMIntegration(iamManager, "localhost:8888")
|
||||
identity := &IAMIdentity{
|
||||
Name: "cli-test-user",
|
||||
Principal: "arn:aws:iam::000000000000:user/cli-test-user",
|
||||
PolicyNames: []string{"cli-bucket-access-policy"},
|
||||
}
|
||||
|
||||
allowedReq := httptest.NewRequest(http.MethodPut, "http://example.com/cli-allowed-bucket/test-file.txt", http.NoBody)
|
||||
allowedErrCode := s3IAMIntegration.AuthorizeAction(ctx, identity, s3_constants.ACTION_WRITE, "cli-allowed-bucket", "test-file.txt", allowedReq)
|
||||
assert.Equal(t, s3err.ErrNone, allowedErrCode)
|
||||
|
||||
forbiddenReq := httptest.NewRequest(http.MethodPut, "http://example.com/cli-forbidden-bucket/forbidden-file.txt", http.NoBody)
|
||||
forbiddenErrCode := s3IAMIntegration.AuthorizeAction(ctx, identity, s3_constants.ACTION_WRITE, "cli-forbidden-bucket", "forbidden-file.txt", forbiddenReq)
|
||||
assert.Equal(t, s3err.ErrAccessDenied, forbiddenErrCode)
|
||||
|
||||
forbiddenListReq := httptest.NewRequest(http.MethodGet, "http://example.com/cli-forbidden-bucket/", http.NoBody)
|
||||
forbiddenListErrCode := s3IAMIntegration.AuthorizeAction(ctx, identity, s3_constants.ACTION_LIST, "cli-forbidden-bucket", "", forbiddenListReq)
|
||||
assert.Equal(t, s3err.ErrAccessDenied, forbiddenListErrCode)
|
||||
}
|
||||
|
||||
// TestS3IAMMiddlewareJWTAuth tests JWT authentication
|
||||
func TestS3IAMMiddlewareJWTAuth(t *testing.T) {
|
||||
// Skip for now since it requires full setup
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user