Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/approve_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ This is used when the application's sync policy is set to 'manual'.`,
return err
}
req.Header.Set("Content-Type", "application/json")
if apiAuthKey != "" {
req.Header.Set("X-API-Key", apiAuthKey)
}

resp, err := client.Do(req)
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions cmd/check_cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Requires 'gitopsctl start' with API reachable (--api-url must match the controll
if err != nil {
return err
}
if apiAuthKey != "" {
req.Header.Set("X-API-Key", apiAuthKey)
}

resp, err := client.Do(req)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ var dashboardCmd = &cobra.Command{
Long: `The dashboard provides a real-time view of all registered applications and clusters, allowing for easy monitoring and manual actions.`,
Run: func(cmd *cobra.Command, args []string) {
url := cmd.Flag("api-url").Value.String()
if err := tui.Run(url); err != nil {
if err := tui.Run(url, apiAuthKey); err != nil {
log.Fatalf("Error running dashboard: %v", err)
}
},
Expand Down
100 changes: 89 additions & 11 deletions cmd/register_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"os"
"strings"
"time"

Expand All @@ -15,17 +16,26 @@ import (

var (
// Flags for the register command
appName string // Name of the application
repoURL string // Git repository URL
branch string // Branch in the repository (optional, default is "main")
pathInRepo string // Path to Kubernetes manifests in the repository
clusterName string // Name of the Kubernetes cluster
interval string // Polling interval for Git repository
dryRunApp bool // Preview changes without applying them
forceApp bool // Force overwrite existing application
syncPolicy string // Synchronization policy (auto or manual)
webhookURL string // Webhook URL for notifications
webhookSecret string // Webhook secret for signing
appName string // Name of the application
repoURL string // Git repository URL
branch string // Branch in the repository (optional, default is "main")
pathInRepo string // Path to Kubernetes manifests in the repository
clusterName string // Name of the Kubernetes cluster
interval string // Polling interval for Git repository
dryRunApp bool // Preview changes without applying them
forceApp bool // Force overwrite existing application
syncPolicy string // Synchronization policy (auto or manual)
webhookURL string // Webhook URL
webhookSecret string // Webhook secret for signing
gitUsername string // Git username
gitPassword string // Git password/token
gitToken string // Git token
gitSSHKeyFile string // Path to SSH private key
maxRetries int // Maximum number of retries
retryInitial string // Initial backoff duration
retryMax string // Maximum backoff duration
createNS bool // Create target namespace
pruneResources bool // Prune removed resources
)

// registrationConfig holds validated configuration for app registration
Expand All @@ -40,6 +50,15 @@ type registrationConfig struct {
syncPolicy string
webhookURL string
webhookSecret string
gitUsername string
gitPassword string
gitToken string
gitSSHKey string
maxRetries int
retryInitial string
retryMax string
createNS bool
pruneResources bool
}

var registerCmd = &cobra.Command{
Expand Down Expand Up @@ -163,6 +182,35 @@ func validateAndNormalizeInput() (*registrationConfig, error) {
config.webhookURL = strings.TrimSpace(webhookURL)
config.webhookSecret = strings.TrimSpace(webhookSecret)

config.gitUsername = strings.TrimSpace(gitUsername)
config.gitPassword = strings.TrimSpace(gitPassword)
config.gitToken = strings.TrimSpace(gitToken)

if gitSSHKeyFile != "" {
keyContent, err := os.ReadFile(gitSSHKeyFile)
if err != nil {
return nil, fmt.Errorf("failed to read Git SSH key file: %w", err)
}
config.gitSSHKey = string(keyContent)
}

config.maxRetries = maxRetries
config.retryInitial = strings.TrimSpace(retryInitial)
config.retryMax = strings.TrimSpace(retryMax)
config.createNS = createNS
config.pruneResources = pruneResources

if config.retryInitial != "" {
if _, err := time.ParseDuration(config.retryInitial); err != nil {
return nil, fmt.Errorf("invalid initial backoff duration: %w", err)
}
}
if config.retryMax != "" {
if _, err := time.ParseDuration(config.retryMax); err != nil {
return nil, fmt.Errorf("invalid max backoff duration: %w", err)
}
}

return config, nil
}

Expand Down Expand Up @@ -226,6 +274,17 @@ func createApplication(config *registrationConfig) *app.Application {
SyncPolicy: config.syncPolicy,
WebhookURL: config.webhookURL,
WebhookSecret: config.webhookSecret,
Credentials: &app.GitCredentials{
Username: config.gitUsername,
Password: config.gitPassword,
Token: config.gitToken,
SSHKey: config.gitSSHKey,
},
MaxRetries: config.maxRetries,
InitialBackoff: config.retryInitial,
MaxBackoff: config.retryMax,
CreateNamespace: config.createNS,
Prune: config.pruneResources,
}
}

Expand Down Expand Up @@ -341,6 +400,25 @@ func init() {
registerCmd.Flags().BoolVar(&forceApp, "force", false,
"Force overwrite existing application")

registerCmd.Flags().StringVar(&gitUsername, "git-username", "",
"Username for Git authentication")
registerCmd.Flags().StringVar(&gitPassword, "git-password", "",
"Password or Personal Access Token for Git authentication")
registerCmd.Flags().StringVar(&gitToken, "git-token", "",
"Personal Access Token for Git authentication (alternative to password)")
registerCmd.Flags().StringVar(&gitSSHKeyFile, "git-ssh-key-file", "",
"Path to SSH private key file for Git authentication")
registerCmd.Flags().IntVar(&maxRetries, "max-retries", 0,
"Maximum number of retries for failed syncs (0 for infinite)")
registerCmd.Flags().StringVar(&retryInitial, "retry-initial-backoff", "30s",
"Initial backoff duration for failed syncs")
registerCmd.Flags().StringVar(&retryMax, "retry-max-backoff", "10m",
"Maximum backoff duration for failed syncs")
registerCmd.Flags().BoolVar(&createNS, "create-namespace", false,
"Automatically create the target namespace if it doesn't exist")
registerCmd.Flags().BoolVar(&pruneResources, "prune", false,
"Automatically delete Kubernetes resources that are no longer present in Git")

_ = registerCmd.MarkFlagRequired("name")
_ = registerCmd.MarkFlagRequired("repo")
_ = registerCmd.MarkFlagRequired("path")
Expand Down
22 changes: 22 additions & 0 deletions cmd/register_cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ var (
dryRunCluster bool // Preview registration without applying
testConnection bool // Test cluster connectivity during registration
allowedNamespaces []string // List of allowed namespaces
defaultNamespace string // Default namespace for the cluster
enforceNamespace bool // Enforce the default namespace
)

// clusterRegistrationConfig holds validated configuration for cluster registration
Expand All @@ -31,6 +33,8 @@ type clusterRegistrationConfig struct {
kubeconfigPath string
resolvedPath string
namespaces []string
defaultNS string
enforceNS bool
}

var registerClusterCmd = &cobra.Command{
Expand Down Expand Up @@ -136,6 +140,13 @@ func validateAndNormalizeClusterInput() (*clusterRegistrationConfig, error) {
}
config.resolvedPath = absPath
config.namespaces = allowedNamespaces
config.defaultNS = strings.TrimSpace(defaultNamespace)
config.enforceNS = enforceNamespace

if config.enforceNS && config.defaultNS == "" {
return nil, fmt.Errorf("--enforce-namespace requires --default-namespace to be set")
}

return config, nil
}

Expand Down Expand Up @@ -186,6 +197,8 @@ func createClusterConfig(config *clusterRegistrationConfig) *clustercore.Cluster
Status: status,
Message: message,
AllowedNamespaces: config.namespaces,
DefaultNamespace: config.defaultNS,
EnforceNamespace: config.enforceNS,
}
}

Expand All @@ -205,6 +218,10 @@ func displayDryRunClusterSummary(newCluster *clustercore.Cluster, isUpdate bool)
if len(newCluster.AllowedNamespaces) > 0 {
fmt.Printf(" Allowed NS: %s\n", strings.Join(newCluster.AllowedNamespaces, ", "))
}
if newCluster.DefaultNamespace != "" {
fmt.Printf(" Default NS: %s\n", newCluster.DefaultNamespace)
fmt.Printf(" Enforce NS: %v\n", newCluster.EnforceNamespace)
}
fmt.Printf("\nTo apply these changes, run the command again without --dry-run\n")

return nil
Expand Down Expand Up @@ -239,6 +256,9 @@ func saveAndConfirmCluster(newCluster *clustercore.Cluster, isUpdate bool) error
if len(newCluster.AllowedNamespaces) > 0 {
fmt.Printf(" Allowed NS: %s\n", strings.Join(newCluster.AllowedNamespaces, ", "))
}
if newCluster.DefaultNamespace != "" {
fmt.Printf(" Default NS: %s (Enforce: %v)\n", newCluster.DefaultNamespace, newCluster.EnforceNamespace)
}

fmt.Printf("\nNext steps:\n")
fmt.Printf(" • List clusters: gitopsctl list-clusters\n")
Expand Down Expand Up @@ -271,6 +291,8 @@ func init() {
registerClusterCmd.Flags().BoolVar(&dryRunCluster, "dry-run", false, "Preview registration without applying changes")
registerClusterCmd.Flags().BoolVar(&testConnection, "test", false, "Test cluster connectivity during registration")
registerClusterCmd.Flags().StringSliceVar(&allowedNamespaces, "allowed-namespaces", []string{}, "Comma-separated list of namespaces this cluster is restricted to")
registerClusterCmd.Flags().StringVar(&defaultNamespace, "default-namespace", "", "Default namespace for this cluster")
registerClusterCmd.Flags().BoolVar(&enforceNamespace, "enforce-namespace", false, "Enforce that all resources are applied to the default namespace")

_ = registerClusterCmd.MarkFlagRequired("name")
_ = registerClusterCmd.RegisterFlagCompletionFunc("kubeconfig", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
Expand Down
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ var (
eventsWebhookRetry int
eventsWebhookBackoff time.Duration
eventsWebhookTimeout time.Duration
// apiAuthKey is the API Key used for authenticating with the gitopsctl API.
apiAuthKey string
)

var rootCmd = &cobra.Command{
Expand Down Expand Up @@ -81,5 +83,6 @@ func init() {
rootCmd.PersistentFlags().IntVar(&eventsWebhookRetry, "events-webhook-retries", 2, "Number of webhook retry attempts for transient failures")
rootCmd.PersistentFlags().DurationVar(&eventsWebhookBackoff, "events-webhook-backoff", 750*time.Millisecond, "Base backoff duration between webhook retries")
rootCmd.PersistentFlags().DurationVar(&eventsWebhookTimeout, "events-webhook-timeout", 12*time.Second, "HTTP timeout per webhook request")
rootCmd.PersistentFlags().StringVar(&apiAuthKey, "api-key", "", "API Key for authenticating with the gitopsctl API (or configuring the server in 'start')")
rootCmd.AddCommand(startCmd)
}
2 changes: 1 addition & 1 deletion cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Phase 2: optionally emit integration events (JSONL file and/or HTTP webhook) for
zap.Bool("webhook_sink", eventsWebhookURL != ""))

ctrl := controller.NewController(logger, apps, clusters, ctrlOpts...)
apiServer := api.NewServer(logger, apps, clusters, ctrl, streamSink, historySink)
apiServer := api.NewServer(logger, apps, clusters, ctrl, streamSink, historySink, apiAuthKey)

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
Expand Down
3 changes: 3 additions & 0 deletions cmd/sync_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Requires 'gitopsctl start' with API reachable (--api-url must match the controll
if err != nil {
return err
}
if apiAuthKey != "" {
req.Header.Set("X-API-Key", apiAuthKey)
}

resp, err := client.Do(req)
if err != nil {
Expand Down
36 changes: 36 additions & 0 deletions internal/api/app/get_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package app

import (
appcore "aeswibon.com/github/gitopsctl/internal/core/app"
"net/http"
"testing"
)

func TestGet_ReturnsApplication(t *testing.T) {
h, e, apps, _ := newTestHandler()
apps.Add(&appcore.Application{Name: "a1"})

c, rec := newJSONContext(e, http.MethodGet, "/applications/a1", "")
c.SetParamNames("name")
c.SetParamValues("a1")

if err := h.Get(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}

func TestGet_NotFound(t *testing.T) {
h, e, _, _ := newTestHandler()

c, _ := newJSONContext(e, http.MethodGet, "/applications/missing", "")
c.SetParamNames("name")
c.SetParamValues("missing")

err := h.Get(c)
if err == nil {
t.Fatal("expected 404 error")
}
}
37 changes: 36 additions & 1 deletion internal/api/app/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,29 @@ func (h *Handler) Register(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid interval format: "+err.Error())
}
existingApp.PollingInterval = parsedInterval
// Reset status/message/failures on update, assuming it's a re-registration
existingApp.Status = "Pending"
existingApp.Message = "Application updated, awaiting next sync."
existingApp.ConsecutiveFailures = 0

if req.Credentials != nil {
existingApp.Credentials = &appcore.GitCredentials{
Username: req.Credentials.Username,
Password: req.Credentials.Password,
SSHKey: req.Credentials.SSHKey,
Token: req.Credentials.Token,
}
}

existingApp.MaxRetries = req.MaxRetries
existingApp.InitialBackoff = req.InitialBackoff
existingApp.MaxBackoff = req.MaxBackoff
existingApp.CreateNamespace = req.CreateNamespace
existingApp.DependsOn = req.DependsOn
existingApp.Prune = req.Prune
existingApp.SyncWindows = req.SyncWindows
existingApp.WebhookURL = req.WebhookURL
existingApp.WebhookSecret = req.WebhookSecret

} else {
// Create new application
parsedInterval, err := time.ParseDuration(req.Interval)
Expand All @@ -83,6 +101,23 @@ func (h *Handler) Register(c echo.Context) error {
Status: "Pending",
Message: "Application registered, awaiting first sync.",
ConsecutiveFailures: 0,
MaxRetries: req.MaxRetries,
InitialBackoff: req.InitialBackoff,
MaxBackoff: req.MaxBackoff,
CreateNamespace: req.CreateNamespace,
DependsOn: req.DependsOn,
Prune: req.Prune,
SyncWindows: req.SyncWindows,
WebhookURL: req.WebhookURL,
WebhookSecret: req.WebhookSecret,
}
if req.Credentials != nil {
newApp.Credentials = &appcore.GitCredentials{
Username: req.Credentials.Username,
Password: req.Credentials.Password,
SSHKey: req.Credentials.SSHKey,
Token: req.Credentials.Token,
}
}
h.apps.Add(newApp)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/api/app/register_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
func TestRegister_ClusterNotFound(t *testing.T) {
h, e, _, _ := newTestHandler()

body := `{"name":"a1","repo_url":"https://github.com/org/repo.git","branch":"main","path":"manifests","cluster":"missing","interval":"1m"}`
body := `{"name":"a1","repo_url":"https://github.com/org/repo.git","branch":"main","path":"manifests","cluster_name":"missing","interval":"1m"}`
c, rec := newJSONContext(e, http.MethodPost, "/applications", body)
if err := h.Register(c); err == nil {
t.Fatal("expected error when cluster missing")
Expand All @@ -37,7 +37,7 @@ func TestRegister_InvalidInterval(t *testing.T) {
h, e, apps, clusters := newTestHandler()
clusters.Add(&clustercore.Cluster{Name: "c1"})

body := `{"name":"a1","repo_url":"https://github.com/org/repo.git","branch":"main","path":"manifests","cluster":"c1","interval":"not-a-duration"}`
body := `{"name":"a1","repo_url":"https://github.com/org/repo.git","branch":"main","path":"manifests","cluster_name":"c1","interval":"not-a-duration"}`
c, rec := newJSONContext(e, http.MethodPost, "/applications", body)
err := h.Register(c)
if err == nil {
Expand All @@ -52,7 +52,7 @@ func TestRegister_UpdatesExistingApp(t *testing.T) {
clusters.Add(&clustercore.Cluster{Name: "c1"})
apps.Add(&appcore.Application{Name: "a1", RepoURL: "https://github.com/old/old.git", Branch: "main", Path: "p", ClusterName: "c1", Interval: "5m"})

body := `{"name":"a1","repo_url":"https://github.com/new/repo.git","branch":"develop","path":"deploy","cluster":"c1","interval":"10m"}`
body := `{"name":"a1","repo_url":"https://github.com/new/repo.git","branch":"develop","path":"deploy","cluster_name":"c1","interval":"10m"}`
c, rec := newJSONContext(e, http.MethodPost, "/applications", body)
if err := h.Register(c); err != nil {
t.Fatalf("Register() error = %v", err)
Expand Down
Loading
Loading