mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-07-27 00:37:14 +02:00
cc-lib changed JobData, ScopedJobStats and Job.Statistics from flat maps to structs (a .Metrics map plus array-valued Groups) to represent filesystem (and future interconnect) metric groups. Migrate all construction, indexing and iteration to the new API across the archive backends, metricstore query path, metric dispatcher, archiver, importer, tagger, taskmanager, repository and API layers. Semantics: DecodeJobStats now projects JobData.Groups into ScopedJobStats.Groups, and the archiver derives per-filesystem node-scope statistics into Job.Statistics.Groups. deepCopy, resampling and the metric/scope filter are group-aware. The metricstore internal storage (buffers, selector tree, checkpoint/parquet) is unchanged; all conversion stays at the LoadData/archive-codec seam. Note: requires the corresponding cc-lib release; bump the cc-lib dependency version once tagged (a local go.mod replace was used during development and is intentionally not committed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
921 lines
25 KiB
Go
921 lines
25 KiB
Go
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
|
// All rights reserved. This file is part of cc-backend.
|
|
// Use of this source code is governed by a MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package archive
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
|
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
|
"github.com/ClusterCockpit/cc-lib/v2/util"
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
)
|
|
|
|
// S3ArchiveConfig holds the configuration for the S3 archive backend.
|
|
type S3ArchiveConfig struct {
|
|
Endpoint string `json:"endpoint"` // S3 endpoint URL (optional, for MinIO/localstack)
|
|
AccessKey string `json:"access-key"` // AWS access key ID
|
|
SecretKey string `json:"secret-key"` // AWS secret access key
|
|
Bucket string `json:"bucket"` // S3 bucket name
|
|
Region string `json:"region"` // AWS region
|
|
UsePathStyle bool `json:"use-path-style"` // Use path-style URLs (required for MinIO)
|
|
}
|
|
|
|
// S3Archive implements ArchiveBackend using AWS S3 or S3-compatible object storage.
|
|
// Jobs are stored as objects with keys mirroring the filesystem structure.
|
|
//
|
|
// Object key structure: <cluster>/<jobid/1000>/<jobid%1000>/<starttime>/meta.json
|
|
type S3Archive struct {
|
|
client *s3.Client // AWS S3 client
|
|
bucket string // S3 bucket name
|
|
clusters []string // List of discovered cluster names
|
|
}
|
|
|
|
// getS3Key generates the S3 object key for a job file
|
|
func getS3Key(job *schema.Job, file string) string {
|
|
lvl1 := fmt.Sprintf("%d", job.JobID/1000)
|
|
lvl2 := fmt.Sprintf("%03d", job.JobID%1000)
|
|
startTime := strconv.FormatInt(job.StartTime, 10)
|
|
return fmt.Sprintf("%s/%s/%s/%s/%s", job.Cluster, lvl1, lvl2, startTime, file)
|
|
}
|
|
|
|
// getS3Directory generates the S3 key prefix for a job directory
|
|
func getS3Directory(job *schema.Job) string {
|
|
lvl1 := fmt.Sprintf("%d", job.JobID/1000)
|
|
lvl2 := fmt.Sprintf("%03d", job.JobID%1000)
|
|
startTime := strconv.FormatInt(job.StartTime, 10)
|
|
return fmt.Sprintf("%s/%s/%s/%s/", job.Cluster, lvl1, lvl2, startTime)
|
|
}
|
|
|
|
func (s3a *S3Archive) Init(rawConfig json.RawMessage) (uint64, error) {
|
|
var cfg S3ArchiveConfig
|
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
|
cclog.Warnf("S3Archive Init() > Unmarshal error: %#v", err)
|
|
return 0, err
|
|
}
|
|
|
|
if cfg.Bucket == "" {
|
|
err := fmt.Errorf("S3Archive Init(): empty bucket name")
|
|
cclog.Errorf("S3Archive Init() > config error: %v", err)
|
|
return 0, err
|
|
}
|
|
|
|
if cfg.Region == "" {
|
|
cfg.Region = "us-east-1" // Default region
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create custom AWS config
|
|
var awsCfg aws.Config
|
|
var err error
|
|
|
|
if cfg.AccessKey != "" && cfg.SecretKey != "" {
|
|
// Use static credentials
|
|
awsCfg, err = awsconfig.LoadDefaultConfig(ctx,
|
|
awsconfig.WithRegion(cfg.Region),
|
|
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
|
cfg.AccessKey,
|
|
cfg.SecretKey,
|
|
"",
|
|
)),
|
|
)
|
|
} else {
|
|
// Use default credential chain
|
|
awsCfg, err = awsconfig.LoadDefaultConfig(ctx,
|
|
awsconfig.WithRegion(cfg.Region),
|
|
)
|
|
}
|
|
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Init() > failed to load AWS config: %v", err)
|
|
return 0, err
|
|
}
|
|
|
|
// Create S3 client with path-style option and custom endpoint if specified
|
|
s3a.client = s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
|
o.DisableLogOutputChecksumValidationSkipped = true
|
|
o.UsePathStyle = cfg.UsePathStyle
|
|
if cfg.Endpoint != "" {
|
|
o.BaseEndpoint = aws.String(cfg.Endpoint)
|
|
}
|
|
})
|
|
s3a.bucket = cfg.Bucket
|
|
|
|
// Check if bucket exists and is accessible
|
|
_, err = s3a.client.HeadBucket(ctx, &s3.HeadBucketInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Init() > bucket access error: %v", err)
|
|
return 0, fmt.Errorf("cannot access S3 bucket '%s': %w", s3a.bucket, err)
|
|
}
|
|
|
|
// Read version.txt from S3
|
|
versionKey := "version.txt"
|
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(versionKey),
|
|
})
|
|
if err != nil {
|
|
// If version.txt is missing, try to bootstrap (assuming new archive)
|
|
var noKey *types.NoSuchKey
|
|
// Check for different error types that indicate missing key
|
|
if errors.As(err, &noKey) || strings.Contains(err.Error(), "NoSuchKey") || strings.Contains(err.Error(), "404") {
|
|
cclog.Infof("S3Archive Init() > Bootstrapping new archive at bucket %s", s3a.bucket)
|
|
versionStr := fmt.Sprintf("%d\n", Version)
|
|
_, err = s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(versionKey),
|
|
Body: strings.NewReader(versionStr),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Init() > failed to create version.txt: %v", err)
|
|
return 0, err
|
|
}
|
|
return Version, nil
|
|
}
|
|
|
|
cclog.Warnf("S3Archive Init() > cannot read version.txt: %v", err)
|
|
return 0, err
|
|
}
|
|
defer result.Body.Close()
|
|
|
|
versionBytes, err := io.ReadAll(result.Body)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Init() > failed to read version.txt: %v", err)
|
|
return 0, err
|
|
}
|
|
|
|
version, err := strconv.ParseUint(strings.TrimSuffix(string(versionBytes), "\n"), 10, 64)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Init() > version parse error: %v", err)
|
|
return 0, err
|
|
}
|
|
|
|
if version != Version {
|
|
return version, fmt.Errorf("unsupported version %d, need %d", version, Version)
|
|
}
|
|
|
|
// Discover clusters by listing top-level prefixes
|
|
s3a.clusters = []string{}
|
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Delimiter: aws.String("/"),
|
|
})
|
|
|
|
for paginator.HasMorePages() {
|
|
page, err := paginator.NextPage(ctx)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Init() > failed to list clusters: %v", err)
|
|
return 0, err
|
|
}
|
|
|
|
for _, prefix := range page.CommonPrefixes {
|
|
if prefix.Prefix != nil {
|
|
clusterName := strings.TrimSuffix(*prefix.Prefix, "/")
|
|
// Filter out non-cluster entries
|
|
if clusterName != "" && clusterName != "version.txt" {
|
|
s3a.clusters = append(s3a.clusters, clusterName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cclog.Infof("S3Archive initialized with bucket '%s', found %d clusters", s3a.bucket, len(s3a.clusters))
|
|
return version, nil
|
|
}
|
|
|
|
func (s3a *S3Archive) Info() {
|
|
ctx := context.Background()
|
|
fmt.Printf("S3 Job archive bucket: %s\n", s3a.bucket)
|
|
|
|
ci := make(map[string]*clusterInfo)
|
|
|
|
for _, cluster := range s3a.clusters {
|
|
ci[cluster] = &clusterInfo{dateFirst: time.Now().Unix()}
|
|
|
|
// List all jobs for this cluster
|
|
prefix := cluster + "/"
|
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Prefix: aws.String(prefix),
|
|
})
|
|
|
|
for paginator.HasMorePages() {
|
|
page, err := paginator.NextPage(ctx)
|
|
if err != nil {
|
|
cclog.Fatalf("S3Archive Info() > failed to list objects: %s", err.Error())
|
|
}
|
|
|
|
for _, obj := range page.Contents {
|
|
if obj.Key != nil && strings.HasSuffix(*obj.Key, "/meta.json") {
|
|
ci[cluster].numJobs++
|
|
// Extract starttime from key: cluster/lvl1/lvl2/starttime/meta.json
|
|
parts := strings.Split(*obj.Key, "/")
|
|
if len(parts) >= 4 {
|
|
startTime, err := strconv.ParseInt(parts[3], 10, 64)
|
|
if err == nil {
|
|
ci[cluster].dateFirst = util.Min(ci[cluster].dateFirst, startTime)
|
|
ci[cluster].dateLast = util.Max(ci[cluster].dateLast, startTime)
|
|
}
|
|
}
|
|
if obj.Size != nil {
|
|
ci[cluster].diskSize += float64(*obj.Size) / (1024 * 1024) // Convert to MB
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cit := clusterInfo{dateFirst: time.Now().Unix()}
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug)
|
|
fmt.Fprintln(w, "cluster\t#jobs\tfrom\tto\tsize (MB)")
|
|
for cluster, clusterInfo := range ci {
|
|
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%.2f\n", cluster,
|
|
clusterInfo.numJobs,
|
|
time.Unix(clusterInfo.dateFirst, 0),
|
|
time.Unix(clusterInfo.dateLast, 0),
|
|
clusterInfo.diskSize)
|
|
|
|
cit.numJobs += clusterInfo.numJobs
|
|
cit.dateFirst = util.Min(cit.dateFirst, clusterInfo.dateFirst)
|
|
cit.dateLast = util.Max(cit.dateLast, clusterInfo.dateLast)
|
|
cit.diskSize += clusterInfo.diskSize
|
|
}
|
|
|
|
fmt.Fprintf(w, "TOTAL\t%d\t%s\t%s\t%.2f\n",
|
|
cit.numJobs, time.Unix(cit.dateFirst, 0), time.Unix(cit.dateLast, 0), cit.diskSize)
|
|
w.Flush()
|
|
}
|
|
|
|
func (s3a *S3Archive) Exists(job *schema.Job) bool {
|
|
ctx := context.Background()
|
|
key := getS3Key(job, "meta.json")
|
|
|
|
_, err := s3a.client.HeadObject(ctx, &s3.HeadObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(key),
|
|
})
|
|
|
|
return err == nil
|
|
}
|
|
|
|
func (s3a *S3Archive) LoadJobMeta(job *schema.Job) (*schema.Job, error) {
|
|
ctx := context.Background()
|
|
key := getS3Key(job, "meta.json")
|
|
|
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive LoadJobMeta() > GetObject error: %v", err)
|
|
return nil, err
|
|
}
|
|
defer result.Body.Close()
|
|
|
|
b, err := io.ReadAll(result.Body)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive LoadJobMeta() > read error: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
if config.Keys.Validate {
|
|
if err := schema.Validate(schema.Meta, bytes.NewReader(b)); err != nil {
|
|
return nil, fmt.Errorf("validate job meta: %v", err)
|
|
}
|
|
}
|
|
|
|
return DecodeJobMeta(bytes.NewReader(b))
|
|
}
|
|
|
|
func (s3a *S3Archive) LoadJobData(job *schema.Job) (schema.JobData, error) {
|
|
ctx := context.Background()
|
|
|
|
// Try compressed file first
|
|
keyGz := getS3Key(job, "data.json.gz")
|
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(keyGz),
|
|
})
|
|
if err != nil {
|
|
// Try uncompressed file
|
|
key := getS3Key(job, "data.json")
|
|
result, err = s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive LoadJobData() > GetObject error: %v", err)
|
|
return schema.JobData{}, err
|
|
}
|
|
defer result.Body.Close()
|
|
|
|
if config.Keys.Validate {
|
|
b, _ := io.ReadAll(result.Body)
|
|
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
|
|
return schema.JobData{}, fmt.Errorf("validate job data: %v", err)
|
|
}
|
|
return DecodeJobData(bytes.NewReader(b), key)
|
|
}
|
|
return DecodeJobData(result.Body, key)
|
|
}
|
|
defer result.Body.Close()
|
|
|
|
// Decompress
|
|
r, err := gzip.NewReader(result.Body)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive LoadJobData() > gzip error: %v", err)
|
|
return schema.JobData{}, err
|
|
}
|
|
defer r.Close()
|
|
|
|
if config.Keys.Validate {
|
|
b, _ := io.ReadAll(r)
|
|
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
|
|
return schema.JobData{}, fmt.Errorf("validate job data: %v", err)
|
|
}
|
|
return DecodeJobData(bytes.NewReader(b), keyGz)
|
|
}
|
|
return DecodeJobData(r, keyGz)
|
|
}
|
|
|
|
func (s3a *S3Archive) LoadJobStats(job *schema.Job) (schema.ScopedJobStats, error) {
|
|
ctx := context.Background()
|
|
|
|
// Try compressed file first
|
|
keyGz := getS3Key(job, "data.json.gz")
|
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(keyGz),
|
|
})
|
|
if err != nil {
|
|
// Try uncompressed file
|
|
key := getS3Key(job, "data.json")
|
|
result, err = s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive LoadJobStats() > GetObject error: %v", err)
|
|
return schema.ScopedJobStats{}, err
|
|
}
|
|
defer result.Body.Close()
|
|
|
|
if config.Keys.Validate {
|
|
b, _ := io.ReadAll(result.Body)
|
|
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
|
|
return schema.ScopedJobStats{}, fmt.Errorf("validate job data: %v", err)
|
|
}
|
|
return DecodeJobStats(bytes.NewReader(b), key)
|
|
}
|
|
return DecodeJobStats(result.Body, key)
|
|
}
|
|
defer result.Body.Close()
|
|
|
|
// Decompress
|
|
r, err := gzip.NewReader(result.Body)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive LoadJobStats() > gzip error: %v", err)
|
|
return schema.ScopedJobStats{}, err
|
|
}
|
|
defer r.Close()
|
|
|
|
if config.Keys.Validate {
|
|
b, _ := io.ReadAll(r)
|
|
if err := schema.Validate(schema.Data, bytes.NewReader(b)); err != nil {
|
|
return schema.ScopedJobStats{}, fmt.Errorf("validate job data: %v", err)
|
|
}
|
|
return DecodeJobStats(bytes.NewReader(b), keyGz)
|
|
}
|
|
return DecodeJobStats(r, keyGz)
|
|
}
|
|
|
|
func (s3a *S3Archive) LoadClusterCfg(name string) (*schema.Cluster, error) {
|
|
ctx := context.Background()
|
|
key := fmt.Sprintf("%s/cluster.json", name)
|
|
|
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive LoadClusterCfg() > GetObject error: %v", err)
|
|
return nil, err
|
|
}
|
|
defer result.Body.Close()
|
|
|
|
b, err := io.ReadAll(result.Body)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive LoadClusterCfg() > read error: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
if config.Keys.Validate {
|
|
if err := schema.Validate(schema.ClusterCfg, bytes.NewReader(b)); err != nil {
|
|
cclog.Warnf("Validate cluster config: %v\n", err)
|
|
return &schema.Cluster{}, fmt.Errorf("validate cluster config: %v", err)
|
|
}
|
|
}
|
|
|
|
return DecodeCluster(bytes.NewReader(b))
|
|
}
|
|
|
|
func (s3a *S3Archive) StoreJobMeta(job *schema.Job) error {
|
|
ctx := context.Background()
|
|
key := getS3Key(job, "meta.json")
|
|
|
|
var buf bytes.Buffer
|
|
if err := EncodeJobMeta(&buf, job); err != nil {
|
|
cclog.Error("S3Archive StoreJobMeta() > encoding error")
|
|
return err
|
|
}
|
|
|
|
_, err := s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(key),
|
|
Body: bytes.NewReader(buf.Bytes()),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive StoreJobMeta() > PutObject error: %v", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s3a *S3Archive) ImportJob(jobMeta *schema.Job, jobData *schema.JobData) error {
|
|
ctx := context.Background()
|
|
|
|
metaKey := getS3Key(jobMeta, "meta.json")
|
|
var metaBuf bytes.Buffer
|
|
if err := EncodeJobMeta(&metaBuf, jobMeta); err != nil {
|
|
cclog.Error("S3Archive ImportJob() > encoding meta error")
|
|
return err
|
|
}
|
|
|
|
_, err := s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(metaKey),
|
|
Body: bytes.NewReader(metaBuf.Bytes()),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive ImportJob() > PutObject meta error: %v", err)
|
|
return err
|
|
}
|
|
|
|
var dataBuf bytes.Buffer
|
|
if err := EncodeJobData(&dataBuf, jobData); err != nil {
|
|
cclog.Error("S3Archive ImportJob() > encoding data error")
|
|
return err
|
|
}
|
|
|
|
var dataKey string
|
|
var dataBytes []byte
|
|
|
|
if dataBuf.Len() > 2000 {
|
|
dataKey = getS3Key(jobMeta, "data.json.gz")
|
|
var compressedBuf bytes.Buffer
|
|
gzipWriter := gzip.NewWriter(&compressedBuf)
|
|
if _, err := gzipWriter.Write(dataBuf.Bytes()); err != nil {
|
|
cclog.Errorf("S3Archive ImportJob() > gzip write error: %v", err)
|
|
return err
|
|
}
|
|
if err := gzipWriter.Close(); err != nil {
|
|
cclog.Errorf("S3Archive ImportJob() > gzip close error: %v", err)
|
|
return err
|
|
}
|
|
dataBytes = compressedBuf.Bytes()
|
|
} else {
|
|
dataKey = getS3Key(jobMeta, "data.json")
|
|
dataBytes = dataBuf.Bytes()
|
|
}
|
|
|
|
_, err = s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(dataKey),
|
|
Body: bytes.NewReader(dataBytes),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive ImportJob() > PutObject data error: %v", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s3a *S3Archive) GetClusters() []string {
|
|
return s3a.clusters
|
|
}
|
|
|
|
func (s3a *S3Archive) CleanUp(jobs []*schema.Job) {
|
|
ctx := context.Background()
|
|
start := time.Now()
|
|
|
|
for _, job := range jobs {
|
|
if job == nil {
|
|
cclog.Errorf("S3Archive CleanUp() error: job is nil")
|
|
continue
|
|
}
|
|
|
|
// Delete all files in the job directory
|
|
prefix := getS3Directory(job)
|
|
|
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Prefix: aws.String(prefix),
|
|
})
|
|
|
|
for paginator.HasMorePages() {
|
|
page, err := paginator.NextPage(ctx)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive CleanUp() > list error: %v", err)
|
|
continue
|
|
}
|
|
|
|
for _, obj := range page.Contents {
|
|
if obj.Key != nil {
|
|
_, err := s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: obj.Key,
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive CleanUp() > delete error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cclog.Infof("Retention Service - Remove %d jobs from S3 in %s", len(jobs), time.Since(start))
|
|
}
|
|
|
|
func (s3a *S3Archive) Move(jobs []*schema.Job, targetPath string) {
|
|
ctx := context.Background()
|
|
|
|
for _, job := range jobs {
|
|
sourcePrefix := getS3Directory(job)
|
|
|
|
// List all objects in source
|
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Prefix: aws.String(sourcePrefix),
|
|
})
|
|
|
|
for paginator.HasMorePages() {
|
|
page, err := paginator.NextPage(ctx)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Move() > list error: %v", err)
|
|
continue
|
|
}
|
|
|
|
for _, obj := range page.Contents {
|
|
if obj.Key == nil {
|
|
continue
|
|
}
|
|
|
|
// Compute target key by replacing prefix
|
|
targetKey := strings.Replace(*obj.Key, sourcePrefix, targetPath+"/", 1)
|
|
|
|
// Copy object
|
|
_, err := s3a.client.CopyObject(ctx, &s3.CopyObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
CopySource: aws.String(fmt.Sprintf("%s/%s", s3a.bucket, *obj.Key)),
|
|
Key: aws.String(targetKey),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Move() > copy error: %v", err)
|
|
continue
|
|
}
|
|
|
|
// Delete source object
|
|
_, err = s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: obj.Key,
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Move() > delete error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s3a *S3Archive) Clean(before int64, after int64) {
|
|
ctx := context.Background()
|
|
|
|
if after == 0 {
|
|
after = math.MaxInt64
|
|
}
|
|
|
|
for _, cluster := range s3a.clusters {
|
|
prefix := cluster + "/"
|
|
|
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Prefix: aws.String(prefix),
|
|
})
|
|
|
|
for paginator.HasMorePages() {
|
|
page, err := paginator.NextPage(ctx)
|
|
if err != nil {
|
|
cclog.Fatalf("S3Archive Clean() > list error: %s", err.Error())
|
|
}
|
|
|
|
for _, obj := range page.Contents {
|
|
if obj.Key == nil || !strings.HasSuffix(*obj.Key, "/meta.json") {
|
|
continue
|
|
}
|
|
|
|
// Extract starttime from key: cluster/lvl1/lvl2/starttime/meta.json
|
|
parts := strings.Split(*obj.Key, "/")
|
|
if len(parts) < 4 {
|
|
continue
|
|
}
|
|
|
|
startTime, err := strconv.ParseInt(parts[3], 10, 64)
|
|
if err != nil {
|
|
cclog.Fatalf("S3Archive Clean() > cannot parse starttime: %s", err.Error())
|
|
}
|
|
|
|
if startTime < before || startTime > after {
|
|
// Delete entire job directory
|
|
jobPrefix := strings.Join(parts[:4], "/") + "/"
|
|
|
|
jobPaginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Prefix: aws.String(jobPrefix),
|
|
})
|
|
|
|
for jobPaginator.HasMorePages() {
|
|
jobPage, err := jobPaginator.NextPage(ctx)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Clean() > list job error: %v", err)
|
|
continue
|
|
}
|
|
|
|
for _, jobObj := range jobPage.Contents {
|
|
if jobObj.Key != nil {
|
|
_, err := s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: jobObj.Key,
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Clean() > delete error: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s3a *S3Archive) Compress(jobs []*schema.Job) {
|
|
ctx := context.Background()
|
|
var cnt int
|
|
start := time.Now()
|
|
|
|
for _, job := range jobs {
|
|
dataKey := getS3Key(job, "data.json")
|
|
|
|
// Check if uncompressed file exists and get its size
|
|
headResult, err := s3a.client.HeadObject(ctx, &s3.HeadObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(dataKey),
|
|
})
|
|
if err != nil {
|
|
continue // File doesn't exist or error
|
|
}
|
|
|
|
if headResult.ContentLength == nil || *headResult.ContentLength < 2000 {
|
|
continue // Too small to compress
|
|
}
|
|
|
|
// Download the file
|
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(dataKey),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Compress() > GetObject error: %v", err)
|
|
continue
|
|
}
|
|
|
|
data, err := io.ReadAll(result.Body)
|
|
result.Body.Close()
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Compress() > read error: %v", err)
|
|
continue
|
|
}
|
|
|
|
// Compress the data
|
|
var compressedBuf bytes.Buffer
|
|
gzipWriter := gzip.NewWriter(&compressedBuf)
|
|
if _, err := gzipWriter.Write(data); err != nil {
|
|
cclog.Errorf("S3Archive Compress() > gzip write error: %v", err)
|
|
gzipWriter.Close()
|
|
continue
|
|
}
|
|
gzipWriter.Close()
|
|
|
|
// Upload compressed file
|
|
compressedKey := getS3Key(job, "data.json.gz")
|
|
_, err = s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(compressedKey),
|
|
Body: bytes.NewReader(compressedBuf.Bytes()),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Compress() > PutObject error: %v", err)
|
|
continue
|
|
}
|
|
|
|
// Delete uncompressed file
|
|
_, err = s3a.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(dataKey),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Compress() > delete error: %v", err)
|
|
}
|
|
|
|
cnt++
|
|
}
|
|
|
|
cclog.Infof("Compression Service - %d files in S3 took %s", cnt, time.Since(start))
|
|
}
|
|
|
|
func (s3a *S3Archive) CompressLast(starttime int64) int64 {
|
|
ctx := context.Background()
|
|
compressKey := "compress.txt"
|
|
|
|
// Try to read existing compress.txt
|
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(compressKey),
|
|
})
|
|
|
|
var last int64
|
|
if err == nil {
|
|
b, _ := io.ReadAll(result.Body)
|
|
result.Body.Close()
|
|
last, err = strconv.ParseInt(strings.TrimSuffix(string(b), "\n"), 10, 64)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive CompressLast() > parse error: %v", err)
|
|
last = starttime
|
|
}
|
|
} else {
|
|
last = starttime
|
|
}
|
|
|
|
cclog.Infof("S3Archive CompressLast() - start %d last %d", starttime, last)
|
|
|
|
// Write new timestamp
|
|
newValue := fmt.Sprintf("%d", starttime)
|
|
_, err = s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(compressKey),
|
|
Body: strings.NewReader(newValue),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive CompressLast() > PutObject error: %v", err)
|
|
}
|
|
|
|
return last
|
|
}
|
|
|
|
func (s3a *S3Archive) Iter(loadMetricData bool) <-chan JobContainer {
|
|
ch := make(chan JobContainer)
|
|
|
|
go func() {
|
|
ctx := context.Background()
|
|
defer close(ch)
|
|
|
|
numWorkers := 4
|
|
metaKeys := make(chan string, numWorkers*2)
|
|
var wg sync.WaitGroup
|
|
|
|
for range numWorkers {
|
|
wg.Go(func() {
|
|
for metaKey := range metaKeys {
|
|
result, err := s3a.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(metaKey),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Iter() > GetObject meta error: %v", err)
|
|
continue
|
|
}
|
|
|
|
b, err := io.ReadAll(result.Body)
|
|
result.Body.Close()
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Iter() > read meta error: %v", err)
|
|
continue
|
|
}
|
|
|
|
job, err := DecodeJobMeta(bytes.NewReader(b))
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Iter() > decode meta error: %v", err)
|
|
continue
|
|
}
|
|
|
|
if loadMetricData {
|
|
jobData, err := s3a.LoadJobData(job)
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive Iter() > load data error: %v", err)
|
|
ch <- JobContainer{Meta: job, Data: nil}
|
|
} else {
|
|
ch <- JobContainer{Meta: job, Data: &jobData}
|
|
}
|
|
} else {
|
|
ch <- JobContainer{Meta: job, Data: nil}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
for _, cluster := range s3a.clusters {
|
|
prefix := cluster + "/"
|
|
|
|
paginator := s3.NewListObjectsV2Paginator(s3a.client, &s3.ListObjectsV2Input{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Prefix: aws.String(prefix),
|
|
})
|
|
|
|
for paginator.HasMorePages() {
|
|
page, err := paginator.NextPage(ctx)
|
|
if err != nil {
|
|
cclog.Fatalf("S3Archive Iter() > list error: %s", err.Error())
|
|
}
|
|
|
|
for _, obj := range page.Contents {
|
|
if obj.Key == nil || !strings.HasSuffix(*obj.Key, "/meta.json") {
|
|
continue
|
|
}
|
|
metaKeys <- *obj.Key
|
|
}
|
|
}
|
|
}
|
|
|
|
close(metaKeys)
|
|
wg.Wait()
|
|
}()
|
|
|
|
return ch
|
|
}
|
|
|
|
func (s3a *S3Archive) StoreClusterCfg(name string, config *schema.Cluster) error {
|
|
ctx := context.Background()
|
|
key := fmt.Sprintf("%s/cluster.json", name)
|
|
|
|
var buf bytes.Buffer
|
|
if err := EncodeCluster(&buf, config); err != nil {
|
|
cclog.Error("S3Archive StoreClusterCfg() > encoding error")
|
|
return err
|
|
}
|
|
|
|
_, err := s3a.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(s3a.bucket),
|
|
Key: aws.String(key),
|
|
Body: bytes.NewReader(buf.Bytes()),
|
|
})
|
|
if err != nil {
|
|
cclog.Errorf("S3Archive StoreClusterCfg() > PutObject error: %v", err)
|
|
return err
|
|
}
|
|
|
|
// Update clusters list if new
|
|
found := slices.Contains(s3a.clusters, name)
|
|
if !found {
|
|
s3a.clusters = append(s3a.clusters, name)
|
|
}
|
|
|
|
return nil
|
|
}
|