nats-upload: add Gitea integration for cleaning feature branches
Conflicts: go.mod go.sum
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -14,21 +15,24 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
NatsURL string `mapstructure:"nats"`
|
||||
BucketName string `mapstructure:"bucket"`
|
||||
Directory string `mapstructure:"dir"`
|
||||
Prefix string `mapstructure:"prefix"`
|
||||
BinaryName string `mapstructure:"binary"`
|
||||
NotifyTopic string `mapstructure:"notify"`
|
||||
SkipNotify bool `mapstructure:"skip-notify"`
|
||||
Cleanup int `mapstructure:"cleanup"`
|
||||
CleanupAll bool `mapstructure:"cleanup-all"`
|
||||
NatsURL string `mapstructure:"nats"`
|
||||
BucketName string `mapstructure:"bucket"`
|
||||
Directory string `mapstructure:"dir"`
|
||||
Prefix string `mapstructure:"prefix"`
|
||||
BinaryName string `mapstructure:"binary"`
|
||||
NotifyTopic string `mapstructure:"notify"`
|
||||
SkipNotify bool `mapstructure:"skip-notify"`
|
||||
Cleanup int `mapstructure:"cleanup"`
|
||||
CleanupAll bool `mapstructure:"cleanup-all"`
|
||||
GiteaURL string `mapstructure:"gitea-url"`
|
||||
GiteaToken string `mapstructure:"gitea-token"`
|
||||
CleanupFeatures bool `mapstructure:"cleanup-features"`
|
||||
Repository string `mapstructure:"repository"`
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
@@ -39,8 +43,8 @@ var rootCmd = &cobra.Command{
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
if cfg.Directory == "" && cfg.Cleanup == 0 {
|
||||
return errors.New("directory path is required or cleanup must be enabled")
|
||||
if cfg.Directory == "" && cfg.Cleanup == 0 && !cfg.CleanupFeatures {
|
||||
return errors.New("directory path is required, or cleanup/cleanup-features must be enabled")
|
||||
}
|
||||
return runUploadAndCleanup(cmd.Context(), &cfg)
|
||||
},
|
||||
@@ -61,6 +65,18 @@ var cleanCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var cleanFeaturesCmd = &cobra.Command{
|
||||
Use: "clean-features",
|
||||
Short: "Delete feature-branch binaries whose branches no longer exist in Gitea",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var cfg Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
return runCleanupFeatures(cmd.Context(), &cfg)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
@@ -70,35 +86,31 @@ func init() {
|
||||
rootCmd.PersistentFlags().Int("cleanup", 2, "Keep only N most recent versions (0 disables cleanup)")
|
||||
rootCmd.PersistentFlags().Bool("cleanup-all", false, "Cleanup all binaries, not just current one")
|
||||
rootCmd.PersistentFlags().Bool("clean-all", false, "Alias for --cleanup-all")
|
||||
rootCmd.PersistentFlags().String("gitea-url", "", "Gitea server URL (for cleaning feature branches)")
|
||||
rootCmd.PersistentFlags().String("gitea-token", "", "Gitea API token (for cleaning feature branches)")
|
||||
rootCmd.PersistentFlags().Bool("cleanup-features", false, "Cleanup stale feature-branch binaries")
|
||||
rootCmd.PersistentFlags().String("repository", "", "Repository path (owner/repo) for Gitea branch API")
|
||||
|
||||
rootCmd.Flags().String("dir", "upload", "Directory containing binaries to upload")
|
||||
rootCmd.Flags().String("prefix", "", "Prefix to strip from paths (like 'upload/')")
|
||||
rootCmd.Flags().String("notify", "binaries.update", "NATS topic to publish update notification")
|
||||
rootCmd.Flags().Bool("skip-notify", false, "Skip publishing update notification")
|
||||
}
|
||||
|
||||
func bindPFlag(fs *pflag.FlagSet, key string, flagNames ...string) {
|
||||
name := key
|
||||
if len(flagNames) > 0 {
|
||||
name = flagNames[0]
|
||||
}
|
||||
if err := viper.BindPFlag(key, fs.Lookup(name)); err != nil {
|
||||
log.Fatalf("error binding %s flag: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootPersistentFlags := rootCmd.PersistentFlags()
|
||||
for _, name := range []string{"nats", "bucket", "binary", "cleanup", "cleanup-all"} {
|
||||
bindPFlag(rootPersistentFlags, name)
|
||||
for _, name := range []string{"nats", "bucket", "binary", "cleanup", "cleanup-all", "gitea-url", "gitea-token", "cleanup-features", "repository"} {
|
||||
if err := viper.BindPFlag(name, rootPersistentFlags.Lookup(name)); err != nil {
|
||||
log.Fatalf("error binding %s flag: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
rootFlags := rootCmd.Flags()
|
||||
for _, name := range []string{"dir", "prefix", "notify", "skip-notify"} {
|
||||
bindPFlag(rootFlags, name)
|
||||
if err := viper.BindPFlag(name, rootFlags.Lookup(name)); err != nil {
|
||||
log.Fatalf("error binding %s flag: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cleanCmd)
|
||||
rootCmd.AddCommand(cleanCmd, cleanFeaturesCmd)
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
@@ -111,12 +123,20 @@ func initConfig() {
|
||||
viper.RegisterAlias("strip_prefix", "prefix")
|
||||
viper.RegisterAlias("notify_topic", "notify")
|
||||
viper.RegisterAlias("clean_all", "cleanup-all")
|
||||
viper.RegisterAlias("gitea_url", "gitea-url")
|
||||
viper.RegisterAlias("gitea_token", "gitea-token")
|
||||
viper.RegisterAlias("cleanup_features", "cleanup-features")
|
||||
viper.RegisterAlias("repository", "repository")
|
||||
|
||||
_ = viper.BindEnv("nats", "INPUT_NATS", "INPUT_NATS_URL")
|
||||
_ = viper.BindEnv("dir", "INPUT_DIR", "INPUT_SOURCE")
|
||||
_ = viper.BindEnv("prefix", "INPUT_PREFIX", "INPUT_STRIP_PREFIX")
|
||||
_ = viper.BindEnv("notify", "INPUT_NOTIFY", "INPUT_NOTIFY_TOPIC")
|
||||
_ = viper.BindEnv("cleanup-all", "INPUT_CLEANUP_ALL", "INPUT_CLEAN_ALL")
|
||||
_ = viper.BindEnv("gitea-url", "INPUT_GITEA_URL", "INPUT_GITEA_URL")
|
||||
_ = viper.BindEnv("gitea-token", "INPUT_GITEA_TOKEN", "INPUT_GITEA_TOKEN")
|
||||
_ = viper.BindEnv("cleanup-features", "INPUT_CLEANUP_FEATURES", "INPUT_CLEANUP_FEATURES")
|
||||
_ = viper.BindEnv("repository", "INPUT_REPOSITORY", "INPUT_REPOSITORY", "GITHUB_REPOSITORY")
|
||||
}
|
||||
|
||||
type NATSClient struct {
|
||||
@@ -224,6 +244,13 @@ func runUploadAndCleanup(ctx context.Context, cfg *Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.CleanupFeatures {
|
||||
log.Printf("Cleaning up stale feature branch binaries...")
|
||||
if err := runCleanupFeatures(ctx, cfg); err != nil {
|
||||
return fmt.Errorf("failed to cleanup feature branches: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg.SkipNotify && cfg.NotifyTopic != "" {
|
||||
log.Printf("Publishing update notification to topic: %s", cfg.NotifyTopic)
|
||||
|
||||
@@ -233,7 +260,6 @@ func runUploadAndCleanup(ctx context.Context, cfg *Config) error {
|
||||
return fmt.Errorf("failed to publish notification: %w", err)
|
||||
}
|
||||
|
||||
// Flush to ensure message is sent
|
||||
err = client.Conn.Flush()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to flush notification: %w", err)
|
||||
@@ -259,20 +285,108 @@ func runCleanupOnly(ctx context.Context, cfg *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCleanupFeatures(ctx context.Context, cfg *Config) error {
|
||||
client, err := getNATSConnection(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Conn.Close()
|
||||
|
||||
log.Printf("Cleaning up stale feature branch binaries from bucket %s", cfg.BucketName)
|
||||
|
||||
objects, err := client.Store.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list objects: %w", err)
|
||||
}
|
||||
|
||||
type featureTag struct {
|
||||
binary string
|
||||
objectKey string
|
||||
version string
|
||||
}
|
||||
var featureObjects []featureTag
|
||||
|
||||
for _, obj := range objects {
|
||||
parts := strings.Split(obj.Name, "/")
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
version := parts[len(parts)-1]
|
||||
if !strings.HasPrefix(version, "feature-") {
|
||||
continue
|
||||
}
|
||||
|
||||
featureObjects = append(featureObjects, featureTag{
|
||||
binary: parts[0],
|
||||
objectKey: obj.Name,
|
||||
version: version,
|
||||
})
|
||||
}
|
||||
|
||||
if len(featureObjects) == 0 {
|
||||
log.Printf("No feature branch objects found")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("Found %d feature branch objects, checking active feature branches via git...", len(featureObjects))
|
||||
|
||||
// Use git ls-remote to list remote branches (no token needed)
|
||||
cmd := exec.CommandContext(ctx, "git", "ls-remote", "--heads", "origin")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list remote branches via git: %w", err)
|
||||
}
|
||||
|
||||
activeBranches := make(map[string]bool)
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
// Format: "<sha>\trefs/heads/<branch>"
|
||||
parts := strings.Split(line, "\t")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
ref := parts[len(parts)-1]
|
||||
branchName := strings.TrimPrefix(ref, "refs/heads/")
|
||||
if strings.HasPrefix(branchName, "feature/") {
|
||||
activeBranches[branchName] = true
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Active feature branches: %d", len(activeBranches))
|
||||
for branch := range activeBranches {
|
||||
log.Printf(" - %s", branch)
|
||||
}
|
||||
|
||||
var deleted int
|
||||
for _, fo := range featureObjects {
|
||||
branchPart := strings.TrimPrefix(fo.version, "feature-")
|
||||
branchName := "feature/" + strings.ReplaceAll(branchPart, "--", "/")
|
||||
|
||||
if !activeBranches[branchName] {
|
||||
log.Printf("Deleting stale: %s (branch %s no longer exists)", fo.objectKey, branchName)
|
||||
err := client.Store.Delete(ctx, fo.objectKey)
|
||||
if err != nil {
|
||||
log.Printf("Failed to delete %s: %v", fo.objectKey, err)
|
||||
} else {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Cleanup complete: deleted %d stale feature branch objects", deleted)
|
||||
return nil
|
||||
}
|
||||
func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, currentBinary string, keepCount int, cleanAll bool) error {
|
||||
objects, err := store.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list objects: %w", err)
|
||||
}
|
||||
|
||||
// Group objects by binary/architecture path
|
||||
// Expected structure: binary/arch/version
|
||||
versionsByPath := make(map[string][]*jetstream.ObjectInfo)
|
||||
|
||||
for _, obj := range objects {
|
||||
parts := strings.Split(obj.Name, "/")
|
||||
if len(parts) < 3 {
|
||||
// Not a version path, skip
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -280,7 +394,6 @@ func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, curren
|
||||
arch := parts[1]
|
||||
pathKey := binName + "/" + arch
|
||||
|
||||
// If not cleaning all and this isn't the current binary, skip
|
||||
if !cleanAll && currentBinary != "" && binName != currentBinary {
|
||||
continue
|
||||
}
|
||||
@@ -288,20 +401,16 @@ func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, curren
|
||||
versionsByPath[pathKey] = append(versionsByPath[pathKey], obj)
|
||||
}
|
||||
|
||||
// For each binary/arch combination, keep only the most recent N versions
|
||||
for pathKey, versions := range versionsByPath {
|
||||
if len(versions) <= keepCount {
|
||||
log.Printf("Path %s has %d versions, keeping all", pathKey, len(versions))
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort by semantic version (newest first)
|
||||
sort.Slice(versions, func(i, j int) bool {
|
||||
// Extract version from path: binary/arch/version
|
||||
versionI := filepath.Base(versions[i].Name)
|
||||
versionJ := filepath.Base(versions[j].Name)
|
||||
|
||||
// Ensure versions start with 'v' for semver.Compare
|
||||
if !strings.HasPrefix(versionI, "v") {
|
||||
versionI = "v" + versionI
|
||||
}
|
||||
@@ -309,18 +418,14 @@ func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, curren
|
||||
versionJ = "v" + versionJ
|
||||
}
|
||||
|
||||
// semver.Compare returns -1, 0, or 1
|
||||
// We want newest first, so reverse the comparison
|
||||
return semver.Compare(versionI, versionJ) > 0
|
||||
})
|
||||
|
||||
// Delete old versions (everything after keepCount)
|
||||
toDelete := versions[keepCount:]
|
||||
log.Printf("Path %s has %d versions, deleting %d old versions", pathKey, len(versions), len(toDelete))
|
||||
|
||||
for _, obj := range toDelete {
|
||||
version := filepath.Base(obj.Name)
|
||||
log.Printf("Deleting old version: %s (version: %s)", obj.Name, version)
|
||||
log.Printf("Deleting old version: %s", obj.Name)
|
||||
err := store.Delete(ctx, obj.Name)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrObjectNotFound) {
|
||||
return fmt.Errorf("failed to delete %s: %w", obj.Name, err)
|
||||
@@ -331,7 +436,6 @@ func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, curren
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
Reference in New Issue
Block a user