Skip to content

Commit 586cc7f

Browse files
committed
Implement CI/CD pipeline and core UpdateController functionality with Kubernetes integration
1 parent 3d0d677 commit 586cc7f

7 files changed

Lines changed: 932 additions & 0 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: CI/CD Pipeline
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags: ["v*"]
7+
8+
permissions:
9+
contents: read
10+
packages: write
11+
12+
jobs:
13+
build:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v2
18+
- name: Log in to GHCR
19+
uses: docker/login-action@v3
20+
with:
21+
registry: ghcr.io
22+
username: ${{ github.actor }}
23+
password: ${{ secrets.GITHUB_TOKEN }}
24+
- name: Extract metadata for Docker
25+
id: meta
26+
uses: docker/metadata-action@v5
27+
with:
28+
images: ghcr.io/udl-tf/update-controller
29+
tags: |
30+
type=ref,event=branch
31+
type=semver,pattern={{version}}
32+
type=semver,pattern={{major}}.{{minor}}
33+
type=sha
34+
type=raw,value=build-${{ github.run_number }}
35+
type=raw,value=latest,enable={{is_default_branch}}
36+
- name: Build and push
37+
uses: docker/build-push-action@v5
38+
with:
39+
file: Dockerfile
40+
context: .
41+
push: true
42+
tags: ${{ steps.meta.outputs.tags }}
43+
labels: ${{ steps.meta.outputs.labels }}

cmd/controller/main.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"os/signal"
9+
"syscall"
10+
"time"
11+
12+
"github.com/UDL-TF/UpdateController/internal/controller"
13+
"github.com/UDL-TF/UpdateController/internal/k8s"
14+
"github.com/UDL-TF/UpdateController/internal/steamcmd"
15+
"k8s.io/client-go/kubernetes"
16+
"k8s.io/client-go/rest"
17+
"k8s.io/client-go/tools/clientcmd"
18+
"k8s.io/klog/v2"
19+
)
20+
21+
func main() {
22+
klog.InitFlags(nil)
23+
24+
var kubeconfig string
25+
flag.StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig file (optional, uses in-cluster config if not provided)")
26+
flag.Parse()
27+
28+
// Load configuration from environment
29+
config := controller.LoadConfig()
30+
31+
klog.Infof("Starting UpdateController for %s (AppID: %s)", config.SteamApp, config.SteamAppID)
32+
klog.Infof("Check interval: %s", config.CheckInterval)
33+
klog.Infof("Namespace: %s", config.Namespace)
34+
klog.Infof("Pod selector: %s", config.PodSelector)
35+
36+
// Initialize Kubernetes client
37+
k8sConfig, err := buildKubeConfig(kubeconfig)
38+
if err != nil {
39+
klog.Fatalf("Failed to build Kubernetes config: %v", err)
40+
}
41+
42+
clientset, err := kubernetes.NewForConfig(k8sConfig)
43+
if err != nil {
44+
klog.Fatalf("Failed to create Kubernetes client: %v", err)
45+
}
46+
47+
k8sClient := k8s.NewClient(clientset, config.Namespace)
48+
49+
// Initialize SteamCMD client
50+
steamClient := steamcmd.NewClient(
51+
config.SteamCMDPath,
52+
config.SteamApp,
53+
config.SteamAppID,
54+
config.GameMountPath,
55+
config.UpdateScript,
56+
)
57+
58+
// Create controller
59+
ctrl := controller.NewUpdateController(config, k8sClient, steamClient)
60+
61+
// Setup signal handling for graceful shutdown
62+
ctx, cancel := context.WithCancel(context.Background())
63+
defer cancel()
64+
65+
sigChan := make(chan os.Signal, 1)
66+
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
67+
68+
// Start controller
69+
go func() {
70+
if err := ctrl.Run(ctx); err != nil {
71+
klog.Errorf("Controller error: %v", err)
72+
cancel()
73+
}
74+
}()
75+
76+
// Wait for shutdown signal
77+
sig := <-sigChan
78+
klog.Infof("Received signal %v, shutting down gracefully...", sig)
79+
cancel()
80+
81+
// Give the controller time to clean up
82+
time.Sleep(2 * time.Second)
83+
klog.Info("Shutdown complete")
84+
}
85+
86+
// buildKubeConfig builds Kubernetes configuration from kubeconfig file or in-cluster config
87+
func buildKubeConfig(kubeconfig string) (*rest.Config, error) {
88+
if kubeconfig != "" {
89+
klog.Infof("Using kubeconfig: %s", kubeconfig)
90+
return clientcmd.BuildConfigFromFlags("", kubeconfig)
91+
}
92+
93+
klog.Info("Using in-cluster configuration")
94+
config, err := rest.InClusterConfig()
95+
if err != nil {
96+
return nil, fmt.Errorf("failed to get in-cluster config: %w", err)
97+
}
98+
return config, nil
99+
}

internal/controller/config.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package controller
2+
3+
import (
4+
"os"
5+
"strconv"
6+
"time"
7+
)
8+
9+
// Config holds the configuration for the UpdateController
10+
type Config struct {
11+
CheckInterval time.Duration
12+
SteamCMDPath string
13+
SteamApp string
14+
SteamAppID string
15+
GameMountPath string
16+
UpdateScript string
17+
PodSelector string
18+
MaxRetries int
19+
RetryDelay time.Duration
20+
Namespace string
21+
}
22+
23+
// LoadConfig loads configuration from environment variables
24+
func LoadConfig() *Config {
25+
return &Config{
26+
CheckInterval: getEnvDuration("CHECK_INTERVAL", 30*time.Minute),
27+
SteamCMDPath: getEnv("STEAMCMD_PATH", "/home/steam/steamcmd"),
28+
SteamApp: getEnv("STEAMAPP", "tf"),
29+
SteamAppID: getEnv("STEAMAPPID", "232250"),
30+
GameMountPath: getEnv("GAME_MOUNT_PATH", "/tf"),
31+
UpdateScript: getEnv("UPDATE_SCRIPT", "tf_update.txt"),
32+
PodSelector: getEnv("POD_SELECTOR", "app=tf2-server"),
33+
MaxRetries: getEnvInt("MAX_RETRIES", 3),
34+
RetryDelay: getEnvDuration("RETRY_DELAY", 5*time.Minute),
35+
Namespace: getEnv("NAMESPACE", "default"),
36+
}
37+
}
38+
39+
func getEnv(key, defaultValue string) string {
40+
if value := os.Getenv(key); value != "" {
41+
return value
42+
}
43+
return defaultValue
44+
}
45+
46+
func getEnvInt(key string, defaultValue int) int {
47+
if value := os.Getenv(key); value != "" {
48+
if intValue, err := strconv.Atoi(value); err == nil {
49+
return intValue
50+
}
51+
}
52+
return defaultValue
53+
}
54+
55+
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
56+
if value := os.Getenv(key); value != "" {
57+
if duration, err := time.ParseDuration(value); err == nil {
58+
return duration
59+
}
60+
}
61+
return defaultValue
62+
}

internal/controller/restart.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package controller
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"k8s.io/klog/v2"
8+
)
9+
10+
// restartPods restarts all pods matching the configured selector
11+
func (uc *UpdateController) restartPods(ctx context.Context) error {
12+
klog.Infof("Finding pods with selector: %s", uc.config.PodSelector)
13+
14+
// Get pods matching selector
15+
pods, err := uc.k8sClient.ListPodsBySelector(ctx, uc.config.PodSelector)
16+
if err != nil {
17+
return fmt.Errorf("failed to list pods: %w", err)
18+
}
19+
20+
if len(pods) == 0 {
21+
klog.Warning("No pods found matching selector")
22+
return nil
23+
}
24+
25+
klog.Infof("Found %d pods to restart", len(pods))
26+
27+
// Track workloads to restart (to avoid duplicate restarts)
28+
workloadsRestarted := make(map[string]bool)
29+
30+
for _, pod := range pods {
31+
// Determine the owner (Deployment, StatefulSet, etc.)
32+
ownerKind, ownerName, err := uc.k8sClient.GetPodOwner(pod)
33+
if err != nil {
34+
klog.Warningf("Failed to get owner for pod %s: %v", pod.Name, err)
35+
continue
36+
}
37+
38+
workloadKey := fmt.Sprintf("%s/%s", ownerKind, ownerName)
39+
if workloadsRestarted[workloadKey] {
40+
klog.V(2).Infof("Workload %s already restarted, skipping", workloadKey)
41+
continue
42+
}
43+
44+
klog.Infof("Restarting %s: %s", ownerKind, ownerName)
45+
if err := uc.restartWorkload(ctx, ownerKind, ownerName); err != nil {
46+
klog.Errorf("Failed to restart %s/%s: %v", ownerKind, ownerName, err)
47+
continue
48+
}
49+
50+
workloadsRestarted[workloadKey] = true
51+
klog.Infof("Successfully initiated restart for %s/%s", ownerKind, ownerName)
52+
}
53+
54+
if len(workloadsRestarted) == 0 {
55+
return fmt.Errorf("failed to restart any workloads")
56+
}
57+
58+
klog.Infof("Successfully restarted %d workloads", len(workloadsRestarted))
59+
return nil
60+
}
61+
62+
// restartWorkload restarts a specific workload by kind and name
63+
func (uc *UpdateController) restartWorkload(ctx context.Context, kind, name string) error {
64+
switch kind {
65+
case "Deployment":
66+
return uc.k8sClient.RestartDeployment(ctx, name)
67+
case "StatefulSet":
68+
return uc.k8sClient.RestartStatefulSet(ctx, name)
69+
case "DaemonSet":
70+
return uc.k8sClient.RestartDaemonSet(ctx, name)
71+
case "ReplicaSet":
72+
return uc.k8sClient.RestartReplicaSet(ctx, name)
73+
default:
74+
return fmt.Errorf("unsupported workload kind: %s", kind)
75+
}
76+
}

internal/controller/update.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package controller
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/UDL-TF/UpdateController/internal/k8s"
9+
"github.com/UDL-TF/UpdateController/internal/steamcmd"
10+
"k8s.io/klog/v2"
11+
)
12+
13+
// UpdateController manages TF2 server updates and pod restarts
14+
type UpdateController struct {
15+
config *Config
16+
k8sClient *k8s.Client
17+
steamClient *steamcmd.Client
18+
retryCount int
19+
}
20+
21+
// NewUpdateController creates a new UpdateController instance
22+
func NewUpdateController(config *Config, k8sClient *k8s.Client, steamClient *steamcmd.Client) *UpdateController {
23+
return &UpdateController{
24+
config: config,
25+
k8sClient: k8sClient,
26+
steamClient: steamClient,
27+
retryCount: 0,
28+
}
29+
}
30+
31+
// Run starts the controller's main loop
32+
func (uc *UpdateController) Run(ctx context.Context) error {
33+
klog.Info("UpdateController started")
34+
35+
ticker := time.NewTicker(uc.config.CheckInterval)
36+
defer ticker.Stop()
37+
38+
// Perform initial check
39+
if err := uc.performUpdateCheck(ctx); err != nil {
40+
klog.Errorf("Initial update check failed: %v", err)
41+
}
42+
43+
for {
44+
select {
45+
case <-ctx.Done():
46+
klog.Info("UpdateController stopping")
47+
return ctx.Err()
48+
case <-ticker.C:
49+
if err := uc.performUpdateCheck(ctx); err != nil {
50+
klog.Errorf("Update check failed: %v", err)
51+
}
52+
}
53+
}
54+
}
55+
56+
// performUpdateCheck checks for updates and applies them if available
57+
func (uc *UpdateController) performUpdateCheck(ctx context.Context) error {
58+
klog.Info("Checking for TF2 updates...")
59+
60+
// Check if update is available
61+
updateAvailable, err := uc.steamClient.CheckUpdate(ctx)
62+
if err != nil {
63+
return fmt.Errorf("failed to check for updates: %w", err)
64+
}
65+
66+
if !updateAvailable {
67+
klog.Info("No updates available, continuing monitoring")
68+
return nil
69+
}
70+
71+
klog.Info("Update available! Starting update process...")
72+
return uc.applyUpdate(ctx)
73+
}
74+
75+
// applyUpdate downloads and applies the update, then restarts pods
76+
func (uc *UpdateController) applyUpdate(ctx context.Context) error {
77+
// Download and install update
78+
klog.Info("Downloading and installing update...")
79+
if err := uc.steamClient.ApplyUpdate(ctx); err != nil {
80+
return uc.handleUpdateFailure(err)
81+
}
82+
83+
// Validate update
84+
klog.Info("Validating update...")
85+
if err := uc.steamClient.ValidateUpdate(ctx); err != nil {
86+
return uc.handleUpdateFailure(fmt.Errorf("update validation failed: %w", err))
87+
}
88+
89+
// Restart affected pods
90+
klog.Info("Update successful! Restarting affected pods...")
91+
if err := uc.restartPods(ctx); err != nil {
92+
return uc.handleUpdateFailure(fmt.Errorf("failed to restart pods: %w", err))
93+
}
94+
95+
klog.Info("Update process completed successfully")
96+
uc.retryCount = 0
97+
return nil
98+
}
99+
100+
// handleUpdateFailure handles update failures with retry logic
101+
func (uc *UpdateController) handleUpdateFailure(err error) error {
102+
uc.retryCount++
103+
klog.Errorf("Update failed (attempt %d/%d): %v", uc.retryCount, uc.config.MaxRetries, err)
104+
105+
if uc.retryCount >= uc.config.MaxRetries {
106+
klog.Errorf("Max retries exceeded, giving up on this update")
107+
uc.retryCount = 0
108+
return fmt.Errorf("update failed after %d attempts: %w", uc.config.MaxRetries, err)
109+
}
110+
111+
klog.Infof("Will retry in %s", uc.config.RetryDelay)
112+
time.Sleep(uc.config.RetryDelay)
113+
114+
return err
115+
}

0 commit comments

Comments
 (0)