diff --git a/.gitignore b/.gitignore
index 04e7774..7a0b252 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,10 @@
-configs
+/configs
gitopsctl
.tmp_gitopsctl*
dist/
coverage.out
coverage.html
+# Ignore runtime configs in test packages
+internal/**/configs
+# But don't ignore examples
+!examples/configs
diff --git a/README.md b/README.md
index 3977b02..56ef24e 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# GitOpsCTL: A Lightweight GitOps Control Plane for Kubernetes
+# GitOpsCTL
@@ -6,292 +6,102 @@
[](https://github.com/aeswibon/gitopsctl/actions/workflows/ci.yml)
[](https://goreportcard.com/report/github.com/aeswibon/gitopsctl)
-[](https://opensource.org/licenses/MIT)
-**GitOpsCTL** (GitOps Control Tool) is a minimalistic, self-hosted, and externally managed GitOps controller written in Go. Designed to complement existing tools like ArgoCD and FluxCD, GitOpsCTL offers a simpler, more flexible alternative for Kubernetes application deployments, especially suited for smaller teams, edge environments, or scenarios requiring fine-grained external control.
+GitOpsCTL is a lightweight, external GitOps control plane for Kubernetes. It watches Git repositories, renders plain YAML, Kustomize, or Helm manifests, applies them to registered clusters, and exposes a CLI, REST API, Server-Sent Events stream, Prometheus metrics, JSONL event log, webhooks, and an interactive terminal dashboard.
-## Goals
+Unlike in-cluster GitOps controllers, GitOpsCTL can run from a laptop, CI runner, bastion host, management VM, or container while managing one or more remote Kubernetes clusters through kubeconfig files.
-The project exists to provide a **small, explicit GitOps loop**: desired state lives in Git; GitOpsCTL **watches that Git**, **applies Kubernetes manifests** to **named clusters**, and exposes a **CLI** as the main operator surface, plus **HTTP and integration hooks** so automation and **your own dashboards** can register workloads, trigger syncs, inspect status, and subscribe to events—without requiring a full in-cluster control plane such as Argo CD or Flux.
+## What It Does
-In one sentence: **GitOpsCTL keeps Kubernetes aligned with Git using a minimal external controller.**
+- Watches application Git repositories on a configurable interval.
+- Applies raw Kubernetes YAML, Kustomize overlays, or Helm charts.
+- Supports automatic sync and manual approval workflows.
+- Manages multiple clusters from one controller process.
+- Restricts cluster writes to configured namespaces when `allowedNamespaces` is set.
+- Decrypts SOPS-encrypted YAML, YML, and JSON manifests before apply.
+- Tracks app and cluster status in local JSON config files.
+- Exposes an API and TUI for operational commands and live status.
+- Emits integration events to SSE, JSONL files, and HTTP webhooks.
+- Publishes Prometheus metrics for syncs, cluster health, app health, Git pulls, and Kubernetes applies.
-Everything beyond that loop (bundled UI, heavy plugins, webhook-primary Git ingest, advanced policy) is optional evolution **after** reconciliation, observability contracts, and operations are reliable and well documented.
+## Quick Start
-## Who this is for
+Prerequisites:
-- **Platform and DevOps engineers** who want Git-as-source-of-truth deploys with a thin controller they can run beside their existing toolchain.
-- **SREs and on-call** who need logs, status, and a way to confirm what revision synced—or to kick a sync without digging through cluster internals only.
-- **Small teams, edge, or local Kubernetes setups** where a lightweight external reconciler is easier to own than a large GitOps stack in-cluster.
-- **Automation authors** integrating registration, sync, health checks, or future **event sinks** (webhooks, streams) from pipelines, agents, or custom backends—often alongside **`--output json`** on the CLI.
-
-### Who this is not for (today)
-
-- Teams that need **DR admission hooks** or **deep multi-tenant RBAC on the control plane** out of the box—those may land later; compare with mature GitOps products if that is your baseline.
-
-## Table of Contents
-
-- [🎯 Goals](#goals)
-- [👥 Who this is for](#who-this-is-for)
-- [🚀 Why GitOpsCTL?](#why-gitopsctl)
-- [✨ Features (Phase 1)](#features-phase-1)
-- [🏗️ Architecture Goals](#architecture-goals)
-- [🏁 Getting Started](#getting-started)
- - [Prerequisites](#prerequisites)
- - [Clone the Repository](#clone-the-repository)
- - [Install Dependencies & Build](#install-dependencies--build)
-- [📖 Usage](#usage)
- - [Register a cluster](#register-a-cluster)
- - [Register an application](#register-an-application)
- - [Check application status](#check-application-status)
- - [Start the controller](#start-the-controller)
- - [Example workflow](#example-workflow)
-- [⚙️ Configuration](#configuration)
-- [📂 Project structure](#project-structure)
-- [➡️ Next steps (future phases)](#next-steps-future-phases)
-- [Phase 2 roadmap (CLI-first & integrations)](docs/phase2.md)
-- [🤝 Contributing](#contributing)
-- [📄 License](#license)
-
-## Why GitOpsCTL?
-
-Traditional GitOps tools are powerful but can be resource-intensive, opinionated, or tightly coupled to the cluster they manage. GitOpsCTL addresses these concerns by being:
-
-- **Lightweight**: Built with Go for efficiency and minimal overhead.
-- **External**: Manages deployments from outside your Kubernetes cluster(s), providing a single control plane for multiple environments.
-- **GitOps-Driven**: Continuously watches Git repositories for desired state and applies changes to target clusters.
-- **Complementary**: Provides a simpler reconciliation loop, allowing you to build custom deployment logic on top of a solid GitOps foundation.
-
-## Features (Phase 1)
-
-A concrete **Phase 1 checklist** (what is done vs still recommended before calling Phase 1 “complete”) lives in [docs/phase1.md](docs/phase1.md).
-
-This phase focuses on the core reconciliation loop and operational APIs:
-
-- **CLI for apps and clusters**: Register applications (Git URL, manifest path, poll interval, target cluster) and register multiple Kubernetes clusters (kubeconfig-backed) via command-line subcommands.
-- **Git polling**: Periodically checks registered Git repositories for manifest changes.
-- **Kubernetes manifest sync**: Applies YAML manifests to target cluster(s) with client-go when Git moves ahead.
-- **REST API**: Manage applications and clusters and trigger sync or cluster checks over HTTP (`gitopsctl start` serves `/api/v1` by default on `:8080`; use `--api-address` to change the bind address).
-- **Logging and status**: Structured logs and CLI commands to inspect registration and sync status.
-
-## Enterprise Hardening & Observability (Phase 4)
-
-GitOpsCTL has been hardened for production-grade workflows:
-
-- **Strict Testing Standards**: 80% unit test coverage enforced in CI and local pre-push hooks.
-- **Native Helm & Kustomize Support**: Renders Helm charts and Kustomizations in-memory without needing external binaries.
-- **Mozilla SOPS Integration**: Automatically decrypts secrets stored in Git using AWS KMS, GCP KMS, PGP, etc.
-- **Manual Approval Workflow**: Optional `manual` sync policy to pause deployments until a human approves the commit hash.
-- **Notification Webhooks**: Per-application webhooks to notify external systems (Slack, Discord, etc.) on sync status changes.
-- **Prometheus Metrics**: High-resolution metrics exposed at `/metrics` for monitoring sync duration, failures, and cluster health.
-
-## Architecture Goals
-
-GitOpsCTL is built with a clear architectural vision:
-
-- **External Control Plane**: Operates outside the Kubernetes cluster, offering a broader view and management capabilities.
-- **Reconciler Pattern**: Continuously aligns the actual state of your applications in Kubernetes with the desired state defined in Git.
-- **Modular design**: Git operations, Kubernetes apply, reconciliation, and HTTP API are separated so each can evolve without collapsing into one blob.
-- **Go-Native**: Leverages Go's concurrency model and client-go for efficient Kubernetes interactions.
-
-## Getting Started
-
-### Prerequisites
-
-- **Go (1.24+)**: Match `go.mod`; install Go on your system.
-- **Git**: Ensure Git is installed and configured on your machine.
-- **Kubernetes Cluster**: A running Kubernetes cluster.
- - **For Mac users**: We highly recommend OrbStack for a fast and lightweight local Kubernetes environment. Enable Kubernetes in OrbStack's settings.
- - Ensure your kubectl is configured to connect to your cluster (e.g., via ~/.kube/config).
-
-### Install via Homebrew (macOS / Linux)
-
-The easiest way to install GitOpsCTL is using Homebrew:
-
-```bash
-brew install aeswibon/gitopsctl/gitopsctl
-```
-
-### Install from Source
-
-**Clone the Repository:**
-
-```bash
-git clone https://github.com/aeswibon/gitopsctl.git
-cd gitopsctl
-```
-
-### Install Dependencies & Build
+- A Kubernetes cluster reachable through `kubectl`.
+- A kubeconfig file for that cluster.
+- GitOpsCTL installed. See [Installation](docs/installation.md).
```bash
-go mod tidy
-go build -o gitopsctl .
+# 1. Register a cluster.
+gitopsctl register-cluster \
+ --name local-dev \
+ --kubeconfig ~/.kube/config \
+ --allowed-namespaces demo
+
+# 2. Register the example nginx app.
+gitopsctl register-apps \
+ --name nginx-demo \
+ --repo https://github.com/aeswibon/gitopsctl.git \
+ --branch main \
+ --path examples/manifests \
+ --cluster local-dev \
+ --interval 30s \
+ --sync-policy auto
+
+# 3. Start the controller and API server.
+gitopsctl start --api-address :8080
+
+# 4. In another terminal, open the dashboard.
+gitopsctl dashboard --api-url http://127.0.0.1:8080
```
-This will create an executable binary named gitopsctl in your current directory.
-
-## Usage
-
-### Register a cluster
-
-Applications deploy to a **named cluster** that must exist in `configs/clusters.json`. Register one first (example uses your default kubeconfig):
-
-```bash
-./gitopsctl register-cluster \
- --name production \
- --kubeconfig ~/.kube/config
+To start from checked-in sample config files instead of registering resources manually, see [Examples](examples/README.md).
+
+## Documentation
+
+- [Getting Started](docs/getting-started.md): First local sync, dashboard, status checks, and cleanup.
+- [Installation](docs/installation.md): Install from releases, Go, Docker, or source.
+- [Configuration](docs/configuration.md): Complete `applications.json` and `clusters.json` reference.
+- [CLI Reference](docs/cli-reference.md): Commands, flags, and common workflows.
+- [Architecture](docs/architecture.md): Controller, API, event bus, reconciliation, and storage model.
+- [Terminal Dashboard](docs/features/tui.md): TUI views and keyboard controls.
+- [Security](docs/features/security.md): Kubeconfig hygiene, namespace restrictions, RBAC, and SOPS.
+- [SOPS](docs/SOPS.md): Secret encryption and decryption setup.
+- [Observability](docs/features/observability.md): Metrics, events, JSONL audit logs, webhooks, and SSE.
+- [Troubleshooting](docs/troubleshooting.md): Common setup and runtime failures.
+
+## Repository Layout
+
+```text
+cmd/ Cobra CLI commands
+internal/api/ REST API, SSE stream, metrics endpoint
+internal/controller/ Reconciliation loop and command dispatch
+internal/core/app/ Application model and persistence
+internal/core/cluster/ Cluster model and persistence
+internal/core/git/ Git clone, pull, and commit helpers
+internal/core/k8s/ Kubernetes client, render, apply, health logic
+internal/events/ Event bus, history, stream, file, webhook sinks
+internal/tui/ Bubble Tea terminal dashboard
+docs/ User and architecture documentation
+examples/ Runnable sample configs and manifests
+configs/ Default local runtime config directory
```
-Short flags: `-n` for name, `-k` for kubeconfig. Optional: `--context`, `--test` to verify connectivity, `--dry-run`, `--force`.
-
-Clusters are stored in `configs/clusters.json`.
-
-### Register an application
-
-Point at a Git repo, manifest path **within that repo**, **cluster name** (must match a registered cluster), and poll interval:
+## Development
```bash
-./gitopsctl register-apps \
- --name my-nginx-app \
- --repo https://github.com/your-github-user/your-gitops-repo.git \
- --path k8s/manifests/nginx \
- --cluster production \
- --interval 30s
+go test ./...
+go test ./... -coverprofile=coverage.out
+go tool cover -func=coverage.out
```
-Short flags: `-n` name, `-r` repo, `-p` path, `-c` cluster, `-i` interval. Optional: `-b`/`--branch` (default `main`), `--dry-run`, `--force`.
-
-After registration, `configs/applications.json` is created or updated.
-
-### Check application status
-
-Inspect registered applications (status, last synced commit, messages):
-
-```bash
-./gitopsctl status-apps
-```
-
-Use flags such as `--output json`, `--details`, or `--sort-by name` for different views.
-
-### Start the controller
-
-Run the main controller to begin the GitOps reconciliation loop:
-
-```bash
-./gitopsctl start
-```
-
-The controller starts polling registered Git repositories and applying changes to your clusters. An HTTP API is started alongside it (default listen address `:8080`; override with `--api-address`, for example `--api-address 127.0.0.1:9090`). You'll see logs in your terminal indicating activity.
-
-**Phase 2 — integration events (optional):** append JSON lines to a file and/or POST to a webhook so external dashboards can react:
-
-```bash
-./gitopsctl start \
- --events-file configs/events.jsonl \
- --events-webhook https://example.com/hooks/gitops \
- --events-webhook-bearer "$TOKEN" \
- --events-webhook-secret "$HMAC_SECRET" \
- --events-webhook-retries 3 \
- --events-webhook-backoff 1s
-```
-
-Follow the file from another terminal: `./gitopsctl tail-events --file configs/events.jsonl`. Event schema: [docs/integrations.md](docs/integrations.md).
-
-**Calls into a running controller** (same as the HTTP API; set `--api-url` if the API is not at `http://127.0.0.1:8080`):
-
-- `./gitopsctl sync-app -n ` — request an immediate application sync.
-- `./gitopsctl check-cluster -n ` — request an immediate cluster connectivity check.
-- `curl -N http://127.0.0.1:8080/api/v1/events` — subscribe to live SSE events (`event` = type, `data` = envelope JSON).
-
-To stop the controller, press `Ctrl+C`. It performs a graceful shutdown (including the API server).
-
-### Example workflow
-
-1. **Register cluster**: `./gitopsctl register-cluster -n production -k ~/.kube/config` (add `--test` if you want a connectivity check).
-2. **Register application**: `./gitopsctl register-apps -n my-nginx-app -r -p k8s/manifests/nginx -c production -i 30s`.
-3. **Start**: Run `./gitopsctl start`. Observe the initial deployment of your manifests to Kubernetes. Verify with `kubectl get all -n `.
-4. **Modify**: Change a manifest in Git (for example image tag or replicas).
-5. **Commit and push**: Push to the branch your app tracks (default `main` unless you set `-b`).
-6. **Observe**: Within the poll `--interval`, GitOpsCTL detects the update, pulls, and applies. Confirm with `./gitopsctl status-apps` and `kubectl`.
-
-## Configuration
-
-Application definitions are stored in `configs/applications.json`. Cluster registrations are stored in `configs/clusters.json`. You can inspect or edit these files manually, but using the CLI (or API) keeps shape and validation consistent.
-
-```json
-[
- {
- "name": "my-nginx-app",
- "repoURL": "https://github.com/your-github-user/your-gitops-repo.git",
- "branch": "main",
- "path": "k8s/manifests/nginx",
- "clusterName": "production",
- "interval": "30s",
- "lastSyncedGitHash": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0",
- "status": "Synced",
- "message": "Successfully synced to a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
- }
-]
-```
-
-## Project structure
-
-```txt
-gitopsctl/
-├── main.go # Entry: delegates to cmd
-├── cmd/ # Cobra CLI (app/cluster register, list, status, start, …)
-├── internal/
-│ ├── api/ # Echo HTTP server and /api/v1 handlers
-│ ├── controller/ # Reconciliation loop and controller commands
-│ ├── core/ # Domains: app, cluster, git, k8s (load/save, integrations)
-│ ├── common/ # Shared types and validation helpers
-│ └── utils/ # CLI helpers (flags, list runners, …)
-└── configs/ # Created at runtime
- ├── applications.json # Registered applications
- └── clusters.json # Registered clusters
-```
-
-## Next steps (future phases)
-
-Development is phased; some items below already exist in code.
-
-### Phase 2: CLI-first operations and integrations (current direction)
-
-**GitOpsCTL stays a CLI tool.** We intend to expose **the full set of capabilities through the CLI** (including anything today reachable only via HTTP while `start` is running). The optional REST API remains a **machine interface** for automation, not a replacement for the CLI.
-
-**We do not plan to ship an official web dashboard.** Instead, Phase 2 focuses on **stable, listenable signals** (documented events, optional webhooks or streams, script-friendly JSON) so you can build **your own** dashboards and integrations on top. Details and suggested deliverables: [docs/phase2.md](docs/phase2.md).
-
-### Baseline already in the tree
-
-- REST API for apps and clusters under `/api/v1` when the controller is running (`gitopsctl start`).
-- Multiple kubeconfig-backed clusters from one controller process.
-- Git polling today; Git **push** webhooks as a sync accelerator remain a later enhancement (see Phase 2 doc).
-
-### Phase 3: Engines and advanced sync (no required UI)
-
-- Advanced sync strategies (manual approval, scheduled syncs, richer policies).
-- Deeper extensibility: Helm/OCI and templating engines where they fit the architecture.
-- Notification and integration patterns built on Phase 2 event hooks—not a bundled UI unless the community explicitly chooses otherwise later.
-
-## 🤝 Contributing
-
-We welcome contributions! To ensure a high bar for code quality, please follow these steps:
-
-1. **Local Pre-commit/Pre-push Hooks**: We use `pre-commit` to run linting and tests. Coverage checks are enforced on push.
- ```bash
- # Install pre-commit
- pip install pre-commit
- # Install the hooks (including pre-push)
- pre-commit install --hook-type pre-commit --hook-type pre-push
- ```
-2. **Quality Standards**:
- - All code must maintain a minimum of **80% unit test coverage**.
- - Ensure all tests pass (`go test ./...`) and the linter is happy (`golangci-lint run`).
-3. **PR Template**: Follow the provided Pull Request template when submitting changes.
+The project expects tests for new behavior and keeps coverage high across core packages.
+## Contributing
-Please review our [Contributing Guidelines](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md). If you discover a security vulnerability, please see our [Security Policy](SECURITY.md).
+Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md), run the test suite, and keep docs updated when changing commands, flags, config fields, or user-visible behavior.
## License
-This project is licensed under the MIT License. See the `LICENSE` file for details.
+Add a repository license file before publishing or packaging GitOpsCTL for external distribution.
diff --git a/coverage.html b/coverage.html
deleted file mode 100644
index 2200c51..0000000
--- a/coverage.html
+++ /dev/null
@@ -1,6993 +0,0 @@
-
-
-
-
-
- cmd: Go Coverage Report
-
-
-
-
package app
-
-import (
- "net/http"
-
- "github.com/labstack/echo/v4"
-)
-
-// Get retrieves the details of a specific application by name.
-// It returns a Response object containing the app's details.
-// If the app does not exist, it returns a 404 Not Found error
-func (h *Handler) Get(c echo.Context) error {
- name := c.Param("name")
-
- h.apps.RLock()
- defer h.apps.RUnlock()
-
- app, ok := h.apps.Get(name)
- if !ok {
- return echo.NewHTTPError(http.StatusNotFound, "Application not found")
- }
- return c.JSON(http.StatusOK, ConvertToResponse(app))
-}
-
-
-
package app
-
-import (
- "net/http"
-
- "aeswibon.com/github/gitopsctl/internal/events"
- "github.com/labstack/echo/v4"
- "go.uber.org/zap"
-)
-
-// Sync handles manual sync requests for an application.
-// It asks the controller to run an immediate sync for the named app and returns 202 Accepted.
-func (h *Handler) Sync(c echo.Context) error {
- name := c.Param("name")
-
- h.apps.Lock()
- defer h.apps.Unlock()
-
- app, ok := h.apps.Get(name)
- if !ok {
- h.logger.Warn("Manual sync requested for non-existent application", zap.String("name", name))
- return echo.NewHTTPError(http.StatusNotFound, "Application not found")
- }
-
- h.controller.TriggerSync(name)
- h.controller.Emit(events.TypeAppSyncRequested, map[string]any{
- "app": name,
- })
-
- app.Status = "SyncRequested"
- app.Message = "Manual sync requested."
- // No need to save to disk here, controller's next loop or signal will handle it.
- h.logger.Info("Manual sync requested for application", zap.String("name", name))
- return c.JSON(http.StatusAccepted, SyncTriggerResponse{
- Message: "Manual sync requested. The controller will process it shortly.",
- Status: "SyncRequested",
- })
-}
-
-
-
package app
-
-import (
- appcore "aeswibon.com/github/gitopsctl/internal/core/app"
-)
-
-// RegisterRequest represents the request payload for registering an application.
-// This structure is used in the API requests to register a new application with the GitOps controller.
-type RegisterRequest struct {
- // Name is the unique identifier for the application.
- Name string `json:"name" validate:"required"`
- // RepoURL is the URL of the Git repository where the application's manifests are stored.
- RepoURL string `json:"repo_url" validate:"required,url"`
- // Branch is the branch in the Git repository that contains the application's manifests.
- Branch string `json:"branch" validate:"required"`
- // Path is the directory path within the repository where the manifests are located.
- Path string `json:"path" validate:"required"`
- // ClusterName is the name of the Kubernetes cluster where the application will be deployed.
- ClusterName string `json:"cluster_name" validate:"required"`
- // Cluster is a backwards-compatible alias for cluster_name.
- Cluster string `json:"cluster"`
- // Interval is the frequency at which the application should be synced with the Git repository.
- Interval string `json:"interval" validate:"required"`
-}
-
-// Response represents the response payload for application operations.
-// This structure is used in the API responses to provide information about registered applications.
-type Response struct {
- // Name is the unique identifier for the application.
- Name string `json:"name"`
- // RepoURL is the URL of the Git repository where the application's manifests are stored.
- RepoURL string `json:"repo_url"`
- // Branch is the branch in the Git repository that contains the application's manifests.
- Branch string `json:"branch"`
- // Path is the directory path within the repository where the manifests are located.
- Path string `json:"path"`
- // ClusterName is the name of the Kubernetes cluster where the application will be deployed.
- ClusterName string `json:"cluster_name"`
- // Interval is the frequency at which the application should be synced with the Git repository.
- Interval string `json:"interval"`
- // LastSyncedGitHash is the last commit hash that was successfully synced from the Git repository.
- LastSyncedGitHash string `json:"last_synced_git_hash"`
- // LatestGitHash is the most recent commit hash discovered by the controller.
- LatestGitHash string `json:"latest_git_hash"`
- // Status indicates the current status of the application (e.g., "active", "inactive", "error").
- Status string `json:"status"`
- // Message provides additional information about the application's status, such as error messages or warnings.
- Message string `json:"message"`
- // ConsecutiveFailures counts the number of consecutive sync failures for the application.
- ConsecutiveFailures int `json:"consecutive_failures"`
- // SyncPolicy determines how changes are applied ("auto" or "manual").
- SyncPolicy string `json:"sync_policy"`
- // ApprovedGitHash is the commit hash currently approved for deployment in manual sync mode.
- ApprovedGitHash string `json:"approved_git_hash"`
- // LastUpdated is the timestamp of the last update to the application's status.
- LastUpdated string `json:"last_updated"`
-}
-
-// SyncTriggerResponse represents the response for sync trigger requests.
-type SyncTriggerResponse struct {
- Message string `json:"message"`
- Status string `json:"status"`
-}
-
-// ConvertToResponse converts an Application to a Response.
-func ConvertToResponse(app *appcore.Application) Response {
- return Response{
- Name: app.Name,
- RepoURL: app.RepoURL,
- Branch: app.Branch,
- Path: app.Path,
- ClusterName: app.ClusterName,
- Interval: app.Interval,
- LastSyncedGitHash: app.LastSyncedGitHash,
- LatestGitHash: app.LatestGitHash,
- Status: app.Status,
- Message: app.Message,
- ConsecutiveFailures: app.ConsecutiveFailures,
- SyncPolicy: app.SyncPolicy,
- ApprovedGitHash: app.ApprovedGitHash,
- }
-}
-
-
-
package app
-
-import (
- "net/http"
-
- appcore "aeswibon.com/github/gitopsctl/internal/core/app"
- "aeswibon.com/github/gitopsctl/internal/events"
- "github.com/labstack/echo/v4"
- "go.uber.org/zap"
-)
-
-// Unregister handles the removal of an application by name.
-// It deletes the application from the applications store and saves the updated configuration.
-// If the application does not exist, it returns a 404 Not Found error.
-// This is useful for cleaning up applications that are no longer needed or have been removed from the Git repository.
-func (h *Handler) Unregister(c echo.Context) error {
- name := c.Param("name")
-
- h.apps.RLock()
- _, exists := h.apps.Get(name)
- h.apps.RUnlock()
- if !exists {
- return echo.NewHTTPError(http.StatusNotFound, "Application not found")
- }
-
- // Stop the controller's goroutine for this application FIRST
- h.controller.StopApp(name)
- h.controller.Emit(events.TypeAppUnregistered, map[string]any{
- "app": name,
- })
- h.apps.Lock()
- defer h.apps.Unlock()
-
- // Remove the application from the store
- h.apps.Delete(name)
- if err := appcore.SaveApplications(h.apps, appcore.DefaultAppConfigFile); err != nil {
- h.logger.Error("Failed to save applications after unregister", zap.Error(err))
- return echo.NewHTTPError(http.StatusInternalServerError, "Failed to remove application configuration")
- }
-
- h.logger.Info("Application unregistered via API", zap.String("name", name))
- return c.JSON(http.StatusOK, map[string]string{"message": "Application unregistered successfully", "name": name})
-}
-
-
-
package cluster
-
-import (
- "net/http"
-
- "github.com/labstack/echo/v4"
-)
-
-// Get retrieves the details of a specific Kubernetes cluster by name.
-// It returns a Response object containing the cluster's details.
-// If the cluster does not exist, it returns a 404 Not Found error.
-func (h *Handler) Get(c echo.Context) error {
- name := c.Param("name")
-
- h.clusters.RLock()
- defer h.clusters.RUnlock()
-
- cl, ok := h.clusters.Get(name)
- if !ok {
- return echo.NewHTTPError(http.StatusNotFound, "Cluster not found")
- }
- return c.JSON(http.StatusOK, ConvertToResponse(cl))
-}
-
-
-
package cluster
-
-import (
- "net/http"
-
- "aeswibon.com/github/gitopsctl/internal/events"
- "github.com/labstack/echo/v4"
- "go.uber.org/zap"
-)
-
-// HealthCheck handles manual health check requests for a Kubernetes cluster.
-// It updates the cluster's status to "CheckRequested" and logs the request.
-func (h *Handler) HealthCheck(c echo.Context) error {
- name := c.Param("name")
-
- h.clusters.Lock()
- defer h.clusters.Unlock()
-
- clusterToUpdate, exists := h.clusters.Get(name)
- if !exists {
- h.logger.Warn("Attempted to trigger check for non-existent cluster", zap.String("name", name))
- return echo.NewHTTPError(http.StatusNotFound, ErrorResponse{Message: "Cluster not found"})
- }
-
- h.controller.TriggerClusterHealthCheck(name)
- h.controller.Emit(events.TypeClusterHealthCheckRequested, map[string]any{
- "cluster": name,
- })
- clusterToUpdate.Status = "CheckRequested"
- clusterToUpdate.Message = "Manual health check requested. Controller received signal."
- h.logger.Info("Manual cluster health check requested via API", zap.String("name", name))
-
- return c.JSON(http.StatusAccepted, HealthCheckTriggerResponse{
- Message: "Manual cluster health check requested. The controller will process it shortly.",
- Status: "CheckRequested",
- })
-}
-
-
-
package cluster
-
-import (
- "net/http"
-
- "github.com/labstack/echo/v4"
-)
-
-// List handles the retrieval of all registered Kubernetes clusters.
-// It returns a list of Response objects containing the details of each cluster.
-func (h *Handler) List(c echo.Context) error {
- h.clusters.RLock()
- defer h.clusters.RUnlock()
-
- responses := []Response{}
- for _, cl := range h.clusters.List() {
- responses = append(responses, ConvertToResponse(cl))
- }
-
- return c.JSON(http.StatusOK, responses)
-}
-
-
-
package cluster
-
-import (
- "net/http"
- "time"
-
- clustercore "aeswibon.com/github/gitopsctl/internal/core/cluster"
- "aeswibon.com/github/gitopsctl/internal/events"
- "github.com/labstack/echo/v4"
- "go.uber.org/zap"
-)
-
-// Register handles the registration of a new Kubernetes cluster.
-// It binds the request payload to a RegisterRequest struct, validates it,
-// and either adds a new cluster or updates an existing one.
-func (h *Handler) Register(c echo.Context) error {
- req := new(RegisterRequest)
- if err := c.Bind(req); err != nil {
- h.logger.Error("Failed to bind register cluster request", zap.Error(err))
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid request payload")
- }
- if c.Echo().Validator != nil {
- if err := c.Validate(req); err != nil {
- h.logger.Error("Failed to validate register cluster request", zap.Error(err))
- return err
- }
- }
-
- h.clusters.Lock()
- defer h.clusters.Unlock()
-
- if _, exists := h.clusters.Get(req.Name); exists {
- h.logger.Warn("Cluster with this name already exists. Updating its kubeconfig.", zap.String("name", req.Name))
- }
-
- newCluster := &clustercore.Cluster{
- Name: req.Name,
- KubeconfigPath: req.KubeconfigPath,
- RegisteredAt: time.Now(),
- Status: "Active",
- Message: "Cluster registered successfully.",
- }
- h.clusters.Add(newCluster)
-
- if err := clustercore.SaveClusters(h.clusters, clustercore.DefaultClusterConfigFile); err != nil {
- h.logger.Error("Failed to save clusters after registration", zap.Error(err))
- return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save cluster configuration")
- }
-
- h.controller.TriggerClusterHealthCheck(req.Name)
- h.controller.Emit(events.TypeClusterRegistered, map[string]any{
- "cluster": req.Name,
- "kubeconfig": req.KubeconfigPath,
- })
-
- h.logger.Info("Cluster registered/updated via API", zap.String("name", req.Name))
- return c.JSON(http.StatusOK, map[string]string{"message": "Cluster registered/updated successfully", "name": req.Name})
-}
-
package cluster
-
-import (
- "time"
-
- clustercore "aeswibon.com/github/gitopsctl/internal/core/cluster"
-)
-
-// RegisterRequest defines the payload for registering a new cluster.
-// This structure is used in the API requests to register a new Kubernetes cluster with the GitOps controller.
-type RegisterRequest struct {
- // Name is the unique identifier for the cluster.
- Name string `json:"name" validate:"required"`
- // KubeconfigPath is the file path to the kubeconfig file for accessing the Kubernetes cluster.
- KubeconfigPath string `json:"kubeconfig_path" validate:"required,kubeconfigfile"`
-}
-
-// Response defines the structure for returning cluster details via the API.
-// This structure is used in the API responses to provide information about registered clusters.
-type Response struct {
- // Name is the unique identifier for the cluster.
- Name string `json:"name"`
- // KubeconfigPath is the file path to the kubeconfig file for accessing the Kubernetes cluster.
- KubeconfigPath string `json:"kubeconfig_path"`
- // RegisteredAt is the timestamp when the cluster was registered with the GitOps controller.
- RegisteredAt time.Time `json:"registered_at"`
- // Status indicates the current status of the cluster (e.g., "active", "inactive", "error").
- Status string `json:"status"`
- // Message provides additional information about the cluster's status, such as error messages or warnings.
- Message string `json:"message"`
- // LastCheckedAt is the timestamp of the last health check performed on the cluster.
- LastCheckedAt time.Time `json:"last_checked_at"`
-}
-
-// HealthCheckTriggerResponse represents the response for health check trigger requests.
-type HealthCheckTriggerResponse struct {
- Message string `json:"message"`
- Status string `json:"status"`
-}
-
-// ErrorResponse represents an error response.
-type ErrorResponse struct {
- Message string `json:"message"`
-}
-
-// ConvertToResponse converts a Cluster to a Response.
-func ConvertToResponse(cl *clustercore.Cluster) Response {
- return Response{
- Name: cl.Name,
- KubeconfigPath: cl.KubeconfigPath,
- RegisteredAt: cl.RegisteredAt,
- Status: cl.Status,
- Message: cl.Message,
- LastCheckedAt: cl.LastCheckedAt,
- }
-}
-
-
-
package cluster
-
-import (
- "net/http"
-
- clustercore "aeswibon.com/github/gitopsctl/internal/core/cluster"
- "aeswibon.com/github/gitopsctl/internal/events"
- "github.com/labstack/echo/v4"
- "go.uber.org/zap"
-)
-
-// Unregister handles the removal of a Kubernetes cluster by name.
-// It deletes the cluster from the clusters store and saves the updated configuration.
-func (h *Handler) Unregister(c echo.Context) error {
- name := c.Param("name")
-
- h.clusters.Lock()
- defer h.clusters.Unlock()
-
- _, exists := h.clusters.Get(name)
- if !exists {
- return echo.NewHTTPError(http.StatusNotFound, "Cluster not found")
- }
-
- h.apps.RLock()
- defer h.apps.RUnlock()
- for _, app := range h.apps.List() {
- if app.ClusterName == name {
- return echo.NewHTTPError(http.StatusConflict, "Cluster '"+name+"' is in use by application '"+app.Name+"'. Please unregister or update applications first.")
- }
- }
-
- h.clusters.Delete(name)
- h.controller.Emit(events.TypeClusterUnregistered, map[string]any{
- "cluster": name,
- })
- if err := clustercore.SaveClusters(h.clusters, clustercore.DefaultClusterConfigFile); err != nil {
- h.logger.Error("Failed to save clusters after unregister", zap.Error(err))
- return echo.NewHTTPError(http.StatusInternalServerError, "Failed to remove cluster configuration")
- }
-
- h.logger.Info("Cluster unregistered via API", zap.String("name", name))
- return c.JSON(http.StatusOK, map[string]string{"message": "Cluster unregistered successfully", "name": name})
-}
-
-
-
package api
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "time"
-
- "aeswibon.com/github/gitopsctl/internal/api/app"
- "aeswibon.com/github/gitopsctl/internal/api/cluster"
- "aeswibon.com/github/gitopsctl/internal/controller"
- appcore "aeswibon.com/github/gitopsctl/internal/core/app"
- clustercore "aeswibon.com/github/gitopsctl/internal/core/cluster"
- "aeswibon.com/github/gitopsctl/internal/events"
- "github.com/labstack/echo/v4"
- "github.com/labstack/echo/v4/middleware"
- "github.com/prometheus/client_golang/prometheus/promhttp"
- "go.uber.org/zap"
-)
-
-// Server represents the API server.
-// It holds the Echo instance, logger, applications store, and controller reference.
-type Server struct {
- // e is the Echo instance used for handling HTTP requests.
- e *echo.Echo
- // logger is the zap.Logger instance used for logging.
- logger *zap.Logger
- // apps is the reference to the applications store, which holds registered applications.
- apps *appcore.Applications
- // clusters is the reference to the clusters store, which holds registered Kubernetes clusters.
- clusters *clustercore.Clusters
- // controller is the reference to the main controller that manages application synchronization.
- controller *controller.Controller
- // stream is an in-memory subscriber sink used for SSE event streaming.
- stream *events.StreamSink
- // history is an in-memory ring buffer for recent events.
- history *events.HistorySink
-}
-
-// NewServer creates a new API server instance.
-// It initializes the Echo instance, sets up middleware, and registers routes.
-func NewServer(logger *zap.Logger, apps *appcore.Applications, clusters *clustercore.Clusters, ctrl *controller.Controller, stream *events.StreamSink, history *events.HistorySink) *Server {
- e := echo.New()
- e.HideBanner = true
- e.HidePort = true
-
- e.Validator = NewCustomValidator()
- e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
- Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}",` +
- `"host":"${host}","method":"${method}","uri":"${uri}","status":${status}, "latency":"${latency_human}"` +
- `,"bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
- }))
- e.Use(middleware.Recover())
- e.Use(middleware.CORS())
-
- s := &Server{
- e: e,
- logger: logger,
- apps: apps,
- clusters: clusters,
- controller: ctrl,
- stream: stream,
- history: history,
- }
-
- s.registerRoutes()
- return s
-}
-
-// RegisterRoutes defines all API endpoints.
-// It sets up the routes for managing applications, health checks, and other API functionalities.
-func (s *Server) registerRoutes() {
- v1 := s.e.Group("/api/v1")
-
- appHandler := app.NewHandler(s.logger, s.apps, s.clusters, s.controller)
- clusterHandler := cluster.NewHandler(s.logger, s.clusters, s.apps, s.controller)
-
- app.RegisterRoutes(v1, appHandler)
- cluster.RegisterRoutes(v1, clusterHandler)
- if s.stream != nil {
- v1.GET("/events", s.StreamEvents)
- v1.GET("/events/history", s.GetEventHistory)
- }
-
- s.e.GET("/health", s.HealthCheck)
- s.e.GET("/metrics", echo.WrapHandler(promhttp.Handler()))
-
-}
-
-// Echo returns the Echo instance used by the server.
-// This is useful for accessing Echo-specific methods or configurations outside the server struct.
-func (s *Server) Echo() *echo.Echo {
- return s.e
-}
-
-// Start starts the HTTP server.
-// It binds the server to the specified address and begins listening for incoming requests.
-func (s *Server) Start(address string) error {
- s.logger.Info("Starting API server", zap.String("address", address))
- return s.e.Start(address)
-}
-
-// Stop stops the HTTP server.
-// It gracefully shuts down the server, allowing ongoing requests to complete.
-// This method can be called from the controller or directly via an API endpoint.
-func (s *Server) Stop(ctx context.Context) error {
- s.logger.Info("Shutting down API server...")
- timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
- return s.e.Shutdown(timeoutCtx)
-}
-
-// HealthCheck is a simple endpoint to check if the API server is running.
-// It responds with a 200 OK status and a simple message.
-// This is useful for monitoring and health checks in production environments.
-func (s *Server) HealthCheck(c echo.Context) error {
- // Simple health check: just respond with 200 OK
- return c.String(http.StatusOK, "OK")
-}
-
-// StreamEvents streams integration events over Server-Sent Events (SSE).
-func (s *Server) StreamEvents(c echo.Context) error {
- if s.stream == nil {
- return c.NoContent(http.StatusNotImplemented)
- }
-
- w := c.Response().Writer
- flusher, ok := w.(http.Flusher)
- if !ok {
- return echo.NewHTTPError(http.StatusInternalServerError, "streaming unsupported")
- }
-
- c.Response().Header().Set(echo.HeaderContentType, "text/event-stream")
- c.Response().Header().Set("Cache-Control", "no-cache")
- c.Response().Header().Set("Connection", "keep-alive")
- c.Response().Header().Set("X-Accel-Buffering", "no")
- c.Response().WriteHeader(http.StatusOK)
- flusher.Flush()
-
- eventsCh, unsubscribe := s.stream.Subscribe(256)
- defer unsubscribe()
-
- heartbeat := time.NewTicker(20 * time.Second)
- defer heartbeat.Stop()
-
- reqCtx := c.Request().Context()
- for {
- select {
- case <-reqCtx.Done():
- return nil
- case env, ok := <-eventsCh:
- if !ok {
- return nil
- }
- payload, err := json.Marshal(env)
- if err != nil {
- s.logger.Warn("failed to marshal SSE event", zap.Error(err))
- continue
- }
- if _, err := fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n\n", env.ID, env.Type, payload); err != nil {
- return nil
- }
- flusher.Flush()
- case <-heartbeat.C:
- // SSE comment heartbeat keeps proxies from closing idle connections.
- if _, err := fmt.Fprint(w, ": keep-alive\n\n"); err != nil {
- return nil
- }
- flusher.Flush()
- }
- }
-}
-
-// GetEventHistory returns the recent event history.
-func (s *Server) GetEventHistory(c echo.Context) error {
- if s.history == nil {
- return c.NoContent(http.StatusNotImplemented)
- }
- return c.JSON(http.StatusOK, s.history.All())
-}
-
-
-
package api
-
-import (
- "net/http"
-
- "aeswibon.com/github/gitopsctl/internal/common"
- "github.com/go-playground/validator/v10"
- "github.com/labstack/echo/v4"
-)
-
-// CustomValidator holds the go-playground validator instance.
-// It implements the echo.Validator interface to integrate with Echo's validation system.
-type CustomValidator struct {
- validator *validator.Validate
-}
-
-// NewCustomValidator creates a new CustomValidator instance with registered validations.
-func NewCustomValidator() *CustomValidator {
- v := validator.New()
-
- // Register custom validation for Git URLs
- _ = v.RegisterValidation("giturl", func(fl validator.FieldLevel) bool {
- return common.IsValidGitURL(fl.Field().String())
- })
-
- // Register custom validation for repository paths
- _ = v.RegisterValidation("path", func(fl validator.FieldLevel) bool {
- return common.IsValidRepoPath(fl.Field().String())
- })
-
- // Register custom validation for kubeconfig files
- _ = v.RegisterValidation("kubeconfigfile", func(fl validator.FieldLevel) bool {
- if err := common.ValidateKubeconfigFile(fl.Field().String()); err != nil {
- return false
- }
- return true
- })
-
- return &CustomValidator{validator: v}
-}
-
-// Validate validates the input struct.
-// It uses the go-playground validator to check the struct fields based on tags.
-// If validation fails, it returns an HTTP error with status 400 Bad Request.
-func (cv *CustomValidator) Validate(i any) error {
- if err := cv.validator.Struct(i); err != nil {
- return echo.NewHTTPError(http.StatusBadRequest, err.Error())
- }
- return nil
-}
-
-
-
package common
-
-import (
- "fmt"
- "strings"
- "time"
-)
-
-// TruncateString truncates a string to a maximum length and appends "..." if truncated.
-func TruncateString(s string, maxLen int) string {
- if len(s) <= maxLen {
- return s
- }
- if maxLen <= 3 {
- return s[:maxLen]
- }
- return s[:maxLen-3] + "..."
-}
-
-// DefaultIfEmpty returns the default value if the string is empty or just whitespace.
-func DefaultIfEmpty(s, defaultVal string) string {
- if strings.TrimSpace(s) == "" {
- return defaultVal
- }
- return s
-}
-
-// GetRelativeTime formats a time.Time object into a human-readable relative time string.
-func GetRelativeTime(t time.Time) string {
- if t.IsZero() {
- return "N/A" // or "never"
- }
-
- diff := time.Since(t)
- switch {
- case diff < time.Minute:
- return "just now"
- case diff < time.Hour:
- return fmt.Sprintf("%dm ago", int(diff.Minutes()))
- case diff < 24*time.Hour:
- return fmt.Sprintf("%dh ago", int(diff.Hours()))
- case diff < 7*24*time.Hour:
- return fmt.Sprintf("%dd ago", int(diff.Hours()/24))
- case diff < 30*24*time.Hour:
- return fmt.Sprintf("%dw ago", int(diff.Hours()/24/7))
- case diff < 365*24*time.Hour:
- return fmt.Sprintf("%dmo ago", int(diff.Hours()/24/30))
- default:
- return fmt.Sprintf("%dyr ago", int(diff.Hours()/24/30))
- }
-}
-
-func isAlphaNumeric(char byte) bool {
- return (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9')
-}
-
-// ValidateName checks if the provided name is valid in kubernetes naming conventions.
-func ValidateName(name string) error {
- if name == "" {
- return fmt.Errorf("cluster name is required (use --name or -n flag)")
- }
-
- name = strings.TrimSpace(name)
- if name == "" {
- return fmt.Errorf("cluster name cannot be empty or whitespace only")
- }
-
- if len(name) > 63 {
- return fmt.Errorf("cluster name too long (maximum 63 characters)")
- }
-
- if !isAlphaNumeric(name[0]) || !isAlphaNumeric(name[len(name)-1]) {
- return fmt.Errorf("cluster name must start and end with an alphanumeric character")
- }
-
- for _, char := range name {
- if !isAlphaNumeric(byte(char)) && char != '-' {
- return fmt.Errorf("invalid character '%c' in cluster name '%s'; only alphanumeric characters and hyphens are allowed", char, name)
- }
- }
-
- return nil
-}
-
-// ConfirmAction prompts the user for confirmation before proceeding with an action.
-func ConfirmAction(message string) bool {
- fmt.Printf("%s [y/N]: ", message)
- var response string
- if _, err := fmt.Scanln(&response); err != nil {
- return false
- }
-
- response = strings.ToLower(strings.TrimSpace(response))
- return response == "y" || response == "yes"
-}
-
-
-
package common
-
-import (
- "fmt"
- "net/url"
- "os"
- "strings"
-
- "k8s.io/client-go/tools/clientcmd"
-)
-
-// IsValidGitURL validates if a string is a basic Git URL (HTTPS or SSH format)
-// It checks for common patterns like "git@host:repo.git" for SSH and "http(s)://host/repo.git" for HTTPS.
-func IsValidGitURL(s string) bool {
- if strings.HasPrefix(s, "git@") && strings.Contains(s, ":") {
- // Basic check for SSH format: git@host:repo/path.git
- return true
- }
- if u, err := url.ParseRequestURI(s); err == nil {
- // Basic check for HTTPS format
- return u.Scheme == "http" || u.Scheme == "https"
- }
- return false
-}
-
-// IsValidRepoPath validates if a string is a valid repository path
-// It checks that the path is not empty or just slashes after trimming leading and trailing slashes.
-// This is useful to ensure that the path provided for manifests in the repository is meaningful.
-func IsValidRepoPath(s string) bool {
- trimmed := strings.Trim(s, "/")
- return trimmed != "" // Path cannot be empty or just slashes after trimming
-}
-
-// ParseURL is a helper to parse a URL. Using net/url.ParseRequestURI for stricter parsing.
-// It ensures that the URL has a scheme and host, which is important for Git URLs.
-func ParseURL(rawurl string) (*url.URL, error) {
- // Handle SCP-style Git URLs: git@github.com:user/repo.git
- if strings.HasPrefix(rawurl, "git@") && strings.Contains(rawurl, ":") {
- // Mock a URL structure for validation purposes
- return &url.URL{
- Scheme: "ssh",
- User: url.User("git"),
- Host: strings.Split(rawurl[4:], ":")[0],
- Path: strings.Split(rawurl, ":")[1],
- }, nil
- }
-
- u, err := url.Parse(rawurl)
- if err != nil {
- return nil, err
- }
- // Also ensure scheme is present for a valid URL
- if u.Scheme == "" || u.Host == "" {
- return nil, fmt.Errorf("URL missing scheme or host")
- }
- return u, nil
-}
-
-// ValidateKubeconfigFile checks if the provided kubeconfig file is valid.
-func ValidateKubeconfigFile(path string) error {
- info, err := os.Stat(path)
- if os.IsNotExist(err) {
- return fmt.Errorf("kubeconfig file not found: %s", path)
- }
- if err != nil {
- return fmt.Errorf("error accessing kubeconfig file %s: %w", path, err)
- }
-
- if info.IsDir() {
- return fmt.Errorf("kubeconfig path is a directory, not a file: %s", path)
- }
-
- file, err := os.Open(path)
- if err != nil {
- return fmt.Errorf("kubeconfig file is not readable: %s\nError: %w", path, err)
- }
- _ = file.Close()
-
- if err := validateKubeconfigStructure(path); err != nil {
- return fmt.Errorf("invalid kubeconfig file: %w", err)
- }
-
- return nil
-}
-
-func validateKubeconfigStructure(path string) error {
- kubeconfig, err := clientcmd.LoadFromFile(path)
- if err != nil {
- return fmt.Errorf("failed to parse kubeconfig: %w", err)
- }
-
- if len(kubeconfig.Clusters) == 0 {
- return fmt.Errorf("kubeconfig contains no cluster definitions")
- }
-
- return nil
-}
-
package app
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "sync"
- "time"
-
- "aeswibon.com/github/gitopsctl/internal/common"
-)
-
-const (
- // DefaultAppConfigFile is the default path to store registered applications
- DefaultAppConfigFile = "configs/applications.json"
-)
-
-// Application represents a single GitOps application managed by the controller.
-// It encapsulates all the necessary metadata and operational details required
-// to monitor and synchronize the application's state between Git and Kubernetes.
-type Application struct {
- // Name is a unique identifier for the application.
- // It must be unique across all registered applications and should follow
- // DNS subdomain naming conventions for compatibility with Kubernetes resources.
- Name string `json:"name"`
-
- // RepoURL specifies the URL of the Git repository where the application's manifests are stored.
- // This URL can be HTTPS or SSH-based, depending on the user's authentication setup.
- RepoURL string `json:"repoURL"`
-
- // Branch defines the Git branch to monitor for changes.
- // The controller will track this branch for updates and apply changes accordingly.
- Branch string `json:"branch"`
-
- // Path specifies the relative directory within the repository where Kubernetes manifests are located.
- // This allows users to organize multiple applications or environments within a single repository.
- Path string `json:"path"`
-
- // ClusterName is the name of the Kubernetes cluster where the application will be deployed.
- // This name is used for logging and status reporting purposes.
- ClusterName string `json:"clusterName"`
-
- // Interval is the polling interval as a string (e.g., "5m", "30s").
- // It defines how frequently the controller should check the Git repository for changes.
- Interval string `json:"interval"`
-
- // PollingInterval is the parsed duration of the Interval field for internal use.
- // This field is not serialized into JSON and is used for efficient time-based operations.
- PollingInterval time.Duration `json:"-"`
-
- // LastSyncedGitHash stores the Git commit hash of the last successfully synchronized state.
- // This helps the controller detect changes and avoid redundant operations.
- LastSyncedGitHash string `json:"lastSyncedGitHash,omitempty"`
-
- // Status represents the current operational state of the application.
- // Possible values include "Running", "Error", "Synced", "Pending", etc.
- Status string `json:"status,omitempty"`
-
- // Message provides additional context about the application's current state.
- // It can include error details, success messages, or other relevant information.
- Message string `json:"message,omitempty"`
-
- // ConsecutiveFailures tracks the number of consecutive synchronization failures.
- // This can be used to implement backoff logic or alerting mechanisms.
- ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
-
- // SyncPolicy defines the synchronization strategy for the application.
- // Possible values are "auto" (default) and "manual".
- SyncPolicy string `json:"syncPolicy,omitempty"`
-
- // ApprovedGitHash stores the Git commit hash that has been approved for deployment.
- // This is used when the SyncPolicy is set to "manual".
- ApprovedGitHash string `json:"approvedGitHash,omitempty"`
-
- // LatestGitHash is the most recent commit hash discovered by the controller.
- LatestGitHash string `json:"latestGitHash,omitempty"`
-
- // WebhookURL is an optional endpoint to send sync notifications to.
- WebhookURL string `json:"webhookUrl,omitempty"`
-
- // WebhookSecret is an optional secret to sign webhook payloads.
- WebhookSecret string `json:"webhookSecret,omitempty"`
-
- // AppliedResources tracks the Kubernetes resources applied for this application.
- AppliedResources []ResourceMetadata `json:"appliedResources,omitempty"`
-}
-
-// ResourceMetadata matches the one in internal/core/k8s to avoid circular imports.
-type ResourceMetadata struct {
- Group string `json:"group"`
- Version string `json:"version"`
- Kind string `json:"kind"`
- Namespace string `json:"namespace"`
- Name string `json:"name"`
-}
-
-// Applications represents a collection of Application objects.
-// It uses a mutex to ensure thread-safe access to the underlying map of applications.
-type Applications struct {
- Apps map[string]*Application
- mu sync.RWMutex
-}
-
-// NewApplications creates and initializes a new Applications collection.
-// It returns an empty collection with a properly initialized map.
-func NewApplications() *Applications {
- return &Applications{
- Apps: make(map[string]*Application),
- }
-}
-
-// Lock acquires a write lock on the Applications collection.
-// This ensures exclusive access to the collection for write operations.
-func (a *Applications) Lock() {
- a.mu.Lock()
-}
-
-// RLock acquires a read lock on the Applications collection.
-// This allows multiple readers to access the collection concurrently,
-// while preventing write operations until the read lock is released.
-func (a *Applications) RLock() {
- a.mu.RLock()
-}
-
-// RUnlock releases the read lock held on the Applications collection.
-// It should always be called after RLock, typically using a defer statement.
-func (a *Applications) RUnlock() {
- a.mu.RUnlock()
-}
-
-// Unlock releases the write lock held on the Applications collection.
-// It should always be called after Lock, typically using a defer statement.
-func (a *Applications) Unlock() {
- a.mu.Unlock()
-}
-
-// Add adds a new application to the collection.
-// The caller is responsible for acquiring the necessary write lock before calling this method.
-func (a *Applications) Add(app *Application) {
- a.Apps[app.Name] = app
-}
-
-// Get retrieves an application by its name.
-// The caller is responsible for acquiring the necessary read or write lock before calling this method.
-func (a *Applications) Get(name string) (*Application, bool) {
- app, ok := a.Apps[name]
- return app, ok
-}
-
-// List returns a slice containing all applications in the collection.
-// The caller is responsible for acquiring the necessary read or write lock before calling this method.
-func (a *Applications) List() []*Application {
- list := make([]*Application, 0, len(a.Apps))
- for _, app := range a.Apps {
- list = append(list, app)
- }
- return list
-}
-
-// Len returns the number of applications in the collection.
-func (a *Applications) Len() int {
- return len(a.Apps)
-}
-
-// Delete removes an application from the collection by its name.
-// The caller is responsible for acquiring the necessary write lock before calling this method.
-func (a *Applications) Delete(name string) {
- delete(a.Apps, name)
-}
-
-// LoadApplications loads applications from the specified JSON file.
-// It initializes the Applications collection and populates it with data from the file.
-// If the file does not exist, it returns an empty collection.
-func LoadApplications(filePath string) (*Applications, error) {
- apps := NewApplications()
- apps.mu.Lock() // Acquire lock for initial load
- defer apps.mu.Unlock()
-
- data, err := os.ReadFile(filePath)
- if err != nil {
- if os.IsNotExist(err) {
- return apps, nil // Return empty if file doesn't exist
- }
- return nil, fmt.Errorf("failed to read applications file %s: %w", filePath, err)
- }
-
- var loadedApps []*Application
- if err := json.Unmarshal(data, &loadedApps); err != nil {
- return nil, fmt.Errorf("failed to unmarshal applications data: %w", err)
- }
-
- for _, app := range loadedApps {
- // Parse interval string to time.Duration
- duration, err := time.ParseDuration(app.Interval)
- if err != nil {
- return nil, fmt.Errorf("invalid polling interval for app %s: %w", app.Name, err)
- }
- app.PollingInterval = duration
- apps.Apps[app.Name] = app // Directly add to map while lock is held
- }
-
- return apps, nil
-}
-
-// SaveApplications saves the current state of applications to the specified JSON file.
-// The caller is responsible for acquiring the necessary lock before calling this method.
-func SaveApplications(apps *Applications, filePath string) error {
- // Ensure the directory exists
- dir := filepath.Dir(filePath)
- if err := os.MkdirAll(dir, 0755); err != nil {
- return fmt.Errorf("failed to create directory %s: %w", dir, err)
- }
-
- // Convert map to slice for stable JSON output
- list := make([]*Application, 0, len(apps.Apps))
- for _, app := range apps.Apps {
- list = append(list, app)
- }
-
- data, err := json.MarshalIndent(list, "", " ")
- if err != nil {
- return fmt.Errorf("failed to marshal applications data: %w", err)
- }
-
- if err := os.WriteFile(filePath, data, 0644); err != nil {
- return fmt.Errorf("failed to write applications file %s: %w", filePath, err)
- }
- return nil
-}
-
-// ToTableHeaders implements cliutils.Renderable for table output headers.
-// It returns the headers for the table representation of the Application.
-func (a *Application) ToTableHeaders(details bool) []string {
- if details {
- return []string{"NAME", "REPO URL", "BRANCH", "PATH", "CLUSTER", "INTERVAL", "STATUS", "SYNC POLICY", "LAST SYNCED HASH", "FAILURES", "MESSAGE"}
- }
- return []string{"NAME", "REPO URL", "BRANCH", "PATH", "CLUSTER", "INTERVAL"}
-}
-
-// ToTableRow implements cliutils.Renderable for table output rows.
-// It returns a slice of strings representing the application data formatted for table display.
-func (a *Application) ToTableRow(details bool) []string {
- hash := a.LastSyncedGitHash
- if len(hash) > 7 {
- hash = hash[:7]
- }
- if details {
- return []string{
- a.Name,
- common.TruncateString(a.RepoURL, 30),
- common.DefaultIfEmpty(a.Branch, "main"),
- common.TruncateString(a.Path, 20),
- a.ClusterName,
- a.Interval,
- a.Status,
- common.DefaultIfEmpty(a.SyncPolicy, "auto"),
- hash,
- fmt.Sprintf("%d", a.ConsecutiveFailures),
- common.TruncateString(a.Message, 40),
- }
- }
- return []string{
- a.Name,
- common.TruncateString(a.RepoURL, 30),
- common.DefaultIfEmpty(a.Branch, "main"),
- common.TruncateString(a.Path, 20),
- a.ClusterName,
- a.Interval,
- }
-}
-
-// ToJSONMap implements cliutils.Renderable for JSON output.
-// It returns a map representation of the Application suitable for JSON serialization.
-func (a *Application) ToJSONMap() map[string]any {
- return map[string]any{
- "name": a.Name,
- "repo_url": a.RepoURL,
- "branch": common.DefaultIfEmpty(a.Branch, "main"),
- "path": a.Path,
- "cluster": a.ClusterName,
- "interval": a.Interval,
- "status": a.Status,
- "last_synced_hash": a.LastSyncedGitHash,
- "consecutive_failures": a.ConsecutiveFailures,
- "message": a.Message,
- }
-}
-
-// ToYAMLString implements cliutils.Renderable for YAML output.
-// It returns a YAML-formatted string representation of the Application.
-func (a *Application) ToYAMLString() string {
- // Build YAML string manually for simplicity
- return fmt.Sprintf(`name: %s
- repo_url: %s
- branch: %s
- path: %s
- cluster: %s
- interval: %s
- status: %s
- last_synced_hash: %s
- consecutive_failures: %d
- message: %s`,
- a.Name,
- a.RepoURL,
- common.DefaultIfEmpty(a.Branch, "main"),
- a.Path,
- a.ClusterName,
- a.Interval,
- a.Status,
- a.LastSyncedGitHash,
- a.ConsecutiveFailures,
- a.Message,
- )
-}
-
-
-
package cluster
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "sync"
- "time"
-
- "aeswibon.com/github/gitopsctl/internal/common"
-)
-
-const (
- // DefaultClusterHealthCheckInterval is the default interval for checking cluster health.
- DefaultClusterHealthCheckInterval = 5 * time.Minute
- // DefaultClusterConfigFile is the default path to store registered clusters
- DefaultClusterConfigFile = "configs/clusters.json"
-)
-
-// Cluster represents a registered Kubernetes cluster.
-// It contains the cluster name, path to the kubeconfig file, registration time,
-// and optional status and message fields for error handling or status reporting.
-type Cluster struct {
- // Name is the unique identifier for the cluster.
- Name string `json:"name"`
- // KubeconfigPath is the path to the kubeconfig file for this cluster.
- KubeconfigPath string `json:"kubeconfigPath"`
- // RegisteredAt is the time when the cluster was registered.
- RegisteredAt time.Time `json:"registeredAt"`
- // Status and Message are optional fields for reporting the cluster's status.
- Status string `json:"status,omitempty"`
- // Message can contain additional information about the cluster's status.
- Message string `json:"message,omitempty"`
- // LastCheckedAt is the last time the cluster was checked for status updates.
- LastCheckedAt time.Time `json:"lastCheckedAt,omitempty"`
- // AllowedNamespaces is an optional list of namespaces the controller is restricted to for this cluster.
- // If empty, no namespace restriction is enforced by the controller.
- AllowedNamespaces []string `json:"allowedNamespaces,omitempty"`
-}
-
-// Clusters represents a thread-safe collection of Cluster objects.
-// It provides methods to add, retrieve, list, and delete clusters.
-// The collection is protected by a read-write mutex to allow concurrent access.
-type Clusters struct {
- Cs map[string]*Cluster
- mu sync.RWMutex
-}
-
-// NewClusters creates a new empty Clusters collection.
-// It initializes the map to hold Cluster objects and returns a pointer to the Clusters instance.
-func NewClusters() *Clusters {
- return &Clusters{
- Cs: make(map[string]*Cluster),
- }
-}
-
-// Lock method acquires a write lock on the Clusters collection.
-// This prevents other goroutines from reading or writing to the collection
-// while the lock is held, ensuring thread safety during modifications.
-func (c *Clusters) Lock() {
- c.mu.Lock()
-}
-
-// RLock method acquires a read lock on the Clusters collection.
-// This allows multiple readers to access the collection concurrently,
-// but prevents any writes while the read lock is held.
-func (c *Clusters) RLock() {
- c.mu.RLock()
-}
-
-// Unlock method releases the write lock on the Clusters collection.
-// It should be called after any modifications to the collection.
-func (c *Clusters) Unlock() {
- c.mu.Unlock()
-}
-
-// RUnlock method releases the read lock on the Clusters collection.
-// It should be called after read operations on the collection.
-func (c *Clusters) RUnlock() {
- c.mu.RUnlock()
-}
-
-// Add adds a cluster to the collection.
-// If a cluster with the same name already exists, it will be overwritten.
-// This method is not thread-safe and should be called with the write lock held.
-func (c *Clusters) Add(cluster *Cluster) {
- c.Cs[cluster.Name] = cluster
-}
-
-// Get retrieves a cluster by name.
-// If the cluster exists, it returns the cluster and true.
-// If the cluster does not exist, it returns nil and false.
-func (c *Clusters) Get(name string) (*Cluster, bool) {
- cluster, ok := c.Cs[name]
- return cluster, ok
-}
-
-// List returns a slice of all clusters.
-// It returns a slice of pointers to Cluster objects.
-func (c *Clusters) List() []*Cluster {
- list := make([]*Cluster, 0, len(c.Cs))
- for _, cluster := range c.Cs {
- list = append(list, cluster)
- }
- return list
-}
-
-// Delete removes a cluster by name.
-// If the cluster does not exist, it does nothing.
-func (c *Clusters) Delete(name string) {
- delete(c.Cs, name)
-}
-
-// Len returns the number of clusters in the collection.
-func (c *Clusters) Len() int {
- return len(c.Cs)
-}
-
-// LoadClusters loads clusters from the specified file path.
-// It reads the JSON data from the file, unmarshals it into Cluster objects,
-// and populates the Clusters collection. If the file does not exist, it returns an empty collection.
-// This function acquires its own lock as it's typically called at startup.
-func LoadClusters(filePath string) (*Clusters, error) {
- clusters := NewClusters()
- clusters.mu.Lock()
- defer clusters.mu.Unlock()
-
- data, err := os.ReadFile(filePath)
- if err != nil {
- if os.IsNotExist(err) {
- return clusters, nil
- }
- return nil, fmt.Errorf("failed to read clusters file %s: %w", filePath, err)
- }
-
- var loadedClusters []*Cluster
- if err := json.Unmarshal(data, &loadedClusters); err != nil {
- return nil, fmt.Errorf("failed to unmarshal clusters data: %w", err)
- }
-
- for _, cluster := range loadedClusters {
- clusters.Cs[cluster.Name] = cluster
- }
-
- return clusters, nil
-}
-
-// SaveClusters saves the current state of clusters to the specified file path.
-// It serializes the Clusters collection to JSON and writes it to the file.
-// If the directory does not exist, it creates it.
-// This function does not acquire its own lock, so it should be called with the appropriate lock held.
-func SaveClusters(clusters *Clusters, filePath string) error {
- // IMPORTANT: No locking here. The caller is responsible for acquiring the appropriate lock.
-
- dir := filepath.Dir(filePath)
- if err := os.MkdirAll(dir, 0755); err != nil {
- return fmt.Errorf("failed to create directory %s: %w", dir, err)
- }
-
- list := make([]*Cluster, 0, len(clusters.Cs))
- for _, cluster := range clusters.Cs {
- list = append(list, cluster)
- }
-
- data, err := json.MarshalIndent(list, "", " ")
- if err != nil {
- return fmt.Errorf("failed to marshal clusters data: %w", err)
- }
-
- if err := os.WriteFile(filePath, data, 0644); err != nil {
- return fmt.Errorf("failed to write clusters file %s: %w", filePath, err)
- }
- return nil
-}
-
-// VerifyCluster checks if a cluster with the given name exists in the collection.
-// It loads the clusters from the default configuration file and checks if the specified cluster exists.
-func VerifyCluster(clusterName string) (*Cluster, bool, error) {
- clusters, err := LoadClusters(DefaultClusterConfigFile)
- if err != nil {
- return nil, false, fmt.Errorf("failed to load cluster configurations: %w", err)
- }
-
- clusters.RLock()
- defer clusters.RUnlock()
-
- cluster, exists := clusters.Get(clusterName)
- if !exists {
- return nil, false, fmt.Errorf("cluster '%s' not found\nUse 'gitopsctl list-clusters' to see registered clusters or 'gitopsctl register-cluster' to add one", clusterName)
- }
- return cluster, exists, nil
-}
-
-// ToTableHeaders implements cliutils.Renderable for table output headers.
-// It returns the headers for the table based on whether detailed output is requested.
-func (c *Cluster) ToTableHeaders(details bool) []string {
- if details {
- return []string{"NAME", "STATUS", "KUBECONFIG", "MESSAGE", "REGISTERED", "LAST CHECKED"}
- }
- return []string{"NAME", "STATUS", "KUBECONFIG", "REGISTERED"}
-}
-
-// ToTableRow implements cliutils.Renderable for table output rows.
-// It formats the cluster information into a slice of strings for table display.
-func (c *Cluster) ToTableRow(details bool) []string {
- lastChecked := "N/A"
- if !c.LastCheckedAt.IsZero() {
- lastChecked = c.LastCheckedAt.Format("2006-01-02 15:04:05 MST") // Consistent time format
- }
- status := formatClusterStatus(c.Status)
-
- if details {
- return []string{
- c.Name,
- status,
- common.TruncateString(c.KubeconfigPath, 30),
- common.TruncateString(c.Message, 40),
- c.RegisteredAt.Format("2006-01-02 15:04:05 MST"), // Consistent time format
- lastChecked,
- }
- }
- return []string{
- c.Name,
- status,
- common.TruncateString(c.KubeconfigPath, 40),
- c.RegisteredAt.Format("2006-01-02 15:04:05 MST"), // Consistent time format
- }
-}
-
-// ToJSONMap implements cliutils.Renderable for JSON output.
-// It formats the cluster information into a map suitable for JSON serialization.
-func (c *Cluster) ToJSONMap() map[string]any {
- lastCheckedAt := ""
- if !c.LastCheckedAt.IsZero() {
- lastCheckedAt = c.LastCheckedAt.Format(time.RFC3339)
- }
- return map[string]any{
- "name": c.Name,
- "status": c.Status,
- "kubeconfig_path": c.KubeconfigPath,
- "message": c.Message,
- "registered_at": c.RegisteredAt.Format(time.RFC3339),
- "last_checked_at": lastCheckedAt,
- }
-}
-
-// ToYAMLString implements cliutils.Renderable for YAML output.
-// It formats the cluster information into a YAML string representation.
-func (c *Cluster) ToYAMLString() string {
- lastCheckedAt := "N/A"
- if !c.LastCheckedAt.IsZero() {
- lastCheckedAt = c.LastCheckedAt.Format("2006-01-02 15:04:05 MST")
- }
- return fmt.Sprintf(`name: %s
- status: %s
- kubeconfig_path: %s
- message: %s
- registered_at: %s
- last_checked_at: %s`,
- c.Name,
- c.Status,
- c.KubeconfigPath,
- c.Message,
- c.RegisteredAt.Format("2006-01-02 15:04:05 MST"),
- lastCheckedAt,
- )
-}
-
-// formatClusterStatus provides a formatted status string with emojis.
-// This function remains in cluster package as it's specific to cluster status logic.
-func formatClusterStatus(status string) string {
- switch strings.ToLower(status) {
- case "active", "connected", "ready":
- return "✅ " + status
- case "inactive", "disconnected", "unreachable":
- return "❌ " + status
- case "pending", "connecting", "checkrequested":
- return "⏳ " + status
- case "error", "failed":
- return "❗ " + status
- default:
- return "❓ " + status
- }
-}
-
-
-
package git
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
-
- gogit "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing"
- "github.com/go-git/go-git/v5/plumbing/transport"
- "github.com/go-git/go-git/v5/plumbing/transport/ssh"
- "go.uber.org/zap"
-)
-
-// CloneOrPull performs a Git clone if the target directory doesn't contain a valid Git repository.
-// If the repository already exists, it performs a Git pull to fetch the latest changes.
-// Returns the HEAD commit hash after the operation.
-func CloneOrPull(ctx context.Context, logger *zap.Logger, repoURL, branch, targetDir string) (string, error) {
- var repo *gogit.Repository
- var err error
-
- // First, try to open the directory as an existing Git repository.
- repo, err = gogit.PlainOpen(targetDir)
- if err != nil {
- // If opening fails, check if it's because the repository does not exist at the path.
- // This can happen if the directory is empty or not a Git repo.
- if err == gogit.ErrRepositoryNotExists || strings.Contains(err.Error(), "git repository not found") {
- // Directory exists but is not a Git repo, or path does not exist. Clone it.
- logger.Info("Cloning repository",
- zap.String("repoURL", repoURL),
- zap.String("branch", branch),
- zap.String("targetDir", targetDir),
- )
- repo, err = gogit.PlainCloneContext(ctx, targetDir, false, &gogit.CloneOptions{
- URL: repoURL,
- ReferenceName: plumbing.ReferenceName("refs/heads/" + branch),
- SingleBranch: true,
- Depth: 1, // Only clone the latest commit for efficiency
- Progress: os.Stdout,
- Auth: setupAuth(repoURL), // Handles SSH agent/keys
- })
- if err != nil {
- return "", fmt.Errorf("failed to clone repository %s: %w", repoURL, err)
- }
- } else {
- // Another error occurred while trying to open the repository.
- return "", fmt.Errorf("failed to open existing repository %s: %w", targetDir, err)
- }
- } else {
- // Repository already exists and was successfully opened, perform a pull.
- logger.Debug("Pulling repository",
- zap.String("repoURL", repoURL),
- zap.String("branch", branch),
- zap.String("targetDir", targetDir),
- )
- worktree, err := repo.Worktree()
- if err != nil {
- return "", fmt.Errorf("failed to get worktree for %s: %w", targetDir, err)
- }
-
- err = worktree.PullContext(ctx, &gogit.PullOptions{
- RemoteName: "origin",
- ReferenceName: plumbing.ReferenceName("refs/heads/" + branch),
- SingleBranch: true,
- Progress: os.Stdout,
- Auth: setupAuth(repoURL), // Handles SSH agent/keys
- })
- if err != nil {
- if err == gogit.NoErrAlreadyUpToDate {
- logger.Debug("Repository already up-to-date", zap.String("repoURL", repoURL))
- } else {
- return "", fmt.Errorf("failed to pull repository %s: %w", repoURL, err)
- }
- }
- }
-
- // Get the HEAD commit hash after either clone or pull operation.
- head, err := repo.Head()
- if err != nil {
- return "", fmt.Errorf("failed to get HEAD after Git operation: %w", err)
- }
- return head.Hash().String(), nil
-}
-
-// GetLatestCommitHash retrieves the HEAD commit hash of a local Git repository.
-// This function opens the repository at the specified path and reads the current HEAD reference.
-func GetLatestCommitHash(logger *zap.Logger, repoPath string) (string, error) {
- repo, err := gogit.PlainOpen(repoPath)
- if err != nil {
- return "", fmt.Errorf("failed to open repository %s: %w", repoPath, err)
- }
- head, err := repo.Head()
- if err != nil {
- return "", fmt.Errorf("failed to get HEAD for repository %s: %w", repoPath, err)
- }
- return head.Hash().String(), nil
-}
-
-// setupAuth provides authentication for Git operations.
-// For SSH-based repositories, it attempts to use the SSH agent or default SSH keys.
-// For HTTPS-based repositories, it currently supports public repositories without authentication.
-// In production, this function could be extended to handle tokens, username/password, or specific key files.
-func setupAuth(repoURL string) transport.AuthMethod {
- if strings.HasPrefix(repoURL, "git@") || strings.HasPrefix(repoURL, "ssh://") {
- // Try to use SSH agent or default SSH keys (~/.ssh/id_rsa)
- sshAuth, err := ssh.NewSSHAgentAuth("") // Empty string uses default agent/keys
- if err != nil {
- zap.L().Warn("Could not use SSH agent for Git authentication, falling back to public repos", zap.Error(err))
- return nil // Fallback to no authentication (will work for public repos)
- }
- return sshAuth
- }
- // For HTTPS, no explicit AuthMethod for public repos.
- // For private HTTPS repos, you'd need http.BasicAuth or similar.
- return nil
-}
-
-// CleanUpRepo deletes the local repository directory.
-// This function is used to clean up temporary directories created for Git operations.
-func CleanUpRepo(logger *zap.Logger, repoDir string) error {
- logger.Info("Cleaning up local repository directory", zap.String("dir", repoDir))
- if err := os.RemoveAll(repoDir); err != nil {
- return fmt.Errorf("failed to remove directory %s: %w", repoDir, err)
- }
- return nil
-}
-
-// CreateTempRepoDir creates a temporary directory for cloning a repository.
-// The directory is created with a unique name to ensure isolation between different Git operations.
-func CreateTempRepoDir() (string, error) {
- tmpDir, err := os.MkdirTemp("", "gitopsctl-repo-*")
- if err != nil {
- return "", fmt.Errorf("failed to create temporary directory: %w", err)
- }
- return tmpDir, nil
-}
-
-
-
package k8s
-
-import (
- "context"
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "aeswibon.com/github/gitopsctl/internal/core/sops"
- "aeswibon.com/github/gitopsctl/internal/metrics"
- "go.uber.org/zap"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/chart/loader"
- "k8s.io/apimachinery/pkg/api/meta"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
- "k8s.io/apimachinery/pkg/runtime/schema"
- "k8s.io/apimachinery/pkg/runtime/serializer/yaml"
- "k8s.io/client-go/discovery"
- "k8s.io/client-go/discovery/cached/memory"
- "k8s.io/client-go/dynamic"
- "k8s.io/client-go/kubernetes"
- "k8s.io/client-go/rest"
- "k8s.io/client-go/restmapper"
- "k8s.io/client-go/tools/clientcmd"
- "k8s.io/client-go/util/homedir"
- "sigs.k8s.io/kustomize/api/krusty"
- "sigs.k8s.io/kustomize/kyaml/filesys"
-)
-
-const (
- // DefaultAPITimeout is the default timeout for Kubernetes API requests
- DefaultAPITimeout = 30 * time.Second
- // DefaultQPS is the default queries per second for client-go
- DefaultQPS = 100
- // DefaultBurst is the default burst for client-go
- DefaultBurst = 100
-)
-
-// ResourceMetadata stores the basic identity of a Kubernetes resource.
-type ResourceMetadata struct {
- Group string
- Version string
- Kind string
- Namespace string
- Name string
-}
-
-// ClientSet holds Kubernetes clients for dynamic interactions.
-// It encapsulates the dynamic client, REST mapper, and configuration required
-// for interacting with Kubernetes resources.
-type ClientSet struct {
- // logger is used for logging operations and errors.
- logger *zap.Logger
- // kubeconfigPath is the path to the kubeconfig file used for authentication.
- kubeconfigPath string
- // dynamicClient is the Kubernetes dynamic client for interacting with arbitrary resources.
- dynamicClient dynamic.Interface
- // mapper is the REST mapper for translating GroupVersionKind to REST resources.
- mapper meta.RESTMapper
- // config is the Kubernetes configuration used to initialize clients.
- config *rest.Config
- // allowedNamespaces is a list of namespaces this client is allowed to interact with.
- allowedNamespaces []string
-}
-
-// NewClientSet initializes a Kubernetes client set.
-// It attempts to use the provided kubeconfig file to build the configuration.
-// If the kubeconfig file is not provided or fails, it falls back to in-cluster configuration.
-func NewClientSet(logger *zap.Logger, kubeconfigPath string, allowedNamespaces []string) (*ClientSet, error) {
- var config *rest.Config
- var err error
-
- if kubeconfigPath == "" {
- kubeconfigPath = filepath.Join(homedir.HomeDir(), ".kube", "config")
- logger.Info("No kubeconfig path provided, attempting to use default", zap.String("path", kubeconfigPath))
- }
-
- // Use the specified kubeconfig file to build the config
- config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath)
- if err != nil {
- // Fallback to in-cluster config if kubeconfig is not found or fails
- logger.Warn("Failed to build config from kubeconfig, attempting in-cluster config", zap.Error(err))
- config, err = rest.InClusterConfig()
- if err != nil {
- return nil, fmt.Errorf("could not build Kubernetes config from kubeconfig (%s) or in-cluster: %w", kubeconfigPath, err)
- }
- logger.Info("Using in-cluster configuration")
- } else {
- logger.Info("Using kubeconfig", zap.String("path", kubeconfigPath))
- }
-
- config.Timeout = DefaultAPITimeout
- config.QPS = DefaultQPS
- config.Burst = DefaultBurst
-
- dynamicClient, err := dynamic.NewForConfig(config)
- if err != nil {
- return nil, fmt.Errorf("failed to create dynamic client: %w", err)
- }
-
- discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
- if err != nil {
- return nil, fmt.Errorf("failed to create discovery client: %w", err)
- }
-
- mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient))
- return &ClientSet{
- logger: logger,
- kubeconfigPath: kubeconfigPath,
- dynamicClient: dynamicClient,
- mapper: mapper,
- config: config,
- allowedNamespaces: allowedNamespaces,
- }, nil
-}
-
-// ApplyManifests applies Kubernetes manifests from a given directory to the cluster.
-// It checks if the directory contains a Helm chart or Kustomization file and builds it if present.
-// Otherwise, it processes all YAML files in the specified directory.
-func (cs *ClientSet) ApplyManifests(ctx context.Context, manifestsDir string, appName, clusterName string) ([]ResourceMetadata, []error) {
- cs.logger.Info("Applying manifests", zap.String("directory", manifestsDir))
- var appliedResources []ResourceMetadata
- var applyErrors []error
-
- // Pre-process: Decrypt any SOPS-encrypted files in the directory
- if err := cs.decryptDirectory(manifestsDir); err != nil {
- cs.logger.Warn("Failed to decrypt some files, proceeding anyway", zap.Error(err))
- }
-
- if hasHelmChart(manifestsDir) {
- cs.logger.Info("Detected Helm Chart, rendering with helm", zap.String("directory", manifestsDir))
-
- actionConfig := new(action.Configuration)
- actionConfig.Log = func(format string, v ...interface{}) {
- cs.logger.Debug(fmt.Sprintf(format, v...))
- }
-
- client := action.NewInstall(actionConfig)
- client.DryRun = true
- client.ClientOnly = true
- client.ReleaseName = "gitopsctl-release"
- client.Namespace = "default"
-
- chartReq, err := loader.Load(manifestsDir)
- if err != nil {
- cs.logger.Error("Failed to load Helm chart", zap.Error(err))
- return nil, []error{fmt.Errorf("failed to load Helm chart: %w", err)}
- }
-
- rel, err := client.Run(chartReq, nil)
- if err != nil {
- cs.logger.Error("Helm template rendering failed", zap.Error(err))
- return nil, []error{fmt.Errorf("helm template rendering failed: %w", err)}
- }
-
- cs.logger.Debug("Successfully rendered Helm chart")
- return cs.applyYAMLData(ctx, []byte(rel.Manifest), filepath.Join(manifestsDir, "Chart.yaml"), appName, clusterName)
- }
-
- fSys := filesys.MakeFsOnDisk()
- if hasKustomization(fSys, manifestsDir) {
- cs.logger.Info("Detected Kustomization, building with kustomize", zap.String("directory", manifestsDir))
- k := krusty.MakeKustomizer(krusty.MakeDefaultOptions())
- resMap, err := k.Run(fSys, manifestsDir)
- if err != nil {
- cs.logger.Error("Kustomize build failed", zap.Error(err))
- return nil, []error{fmt.Errorf("kustomize build failed: %w", err)}
- }
-
- yamlBytes, err := resMap.AsYaml()
- if err != nil {
- cs.logger.Error("Failed to convert kustomize output to YAML", zap.Error(err))
- return nil, []error{fmt.Errorf("failed to convert kustomize output to yaml: %w", err)}
- }
-
- cs.logger.Debug("Successfully built kustomization, applying generated YAML")
- return cs.applyYAMLData(ctx, yamlBytes, filepath.Join(manifestsDir, "kustomization"), appName, clusterName)
- }
-
- cs.logger.Info("No kustomization found, applying raw YAML files", zap.String("directory", manifestsDir))
- err := filepath.WalkDir(manifestsDir, func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- applyErrors = append(applyErrors, fmt.Errorf("filesystem error walking %s: %w", path, err))
- return nil
- }
- if d.IsDir() {
- return nil
- }
- if !strings.HasSuffix(d.Name(), ".yaml") && !strings.HasSuffix(d.Name(), ".yml") {
- return nil
- }
-
- cs.logger.Debug("Processing manifest file", zap.String("file", path))
- data, readErr := os.ReadFile(path)
- if readErr != nil {
- cs.logger.Error("Failed to read manifest file", zap.String("file", path), zap.Error(readErr))
- applyErrors = append(applyErrors, fmt.Errorf("failed to read file %s: %w", path, readErr))
- return nil
- }
-
- fileResources, fileErrors := cs.applyYAMLData(ctx, data, path, appName, clusterName)
- if len(fileErrors) > 0 {
- applyErrors = append(applyErrors, fileErrors...)
- }
- appliedResources = append(appliedResources, fileResources...)
- return nil
- })
- if err != nil {
- applyErrors = append(applyErrors, fmt.Errorf("error during manifest directory walk %s: %w", manifestsDir, err))
- }
- return appliedResources, applyErrors
-}
-
-func (cs *ClientSet) decryptDirectory(dir string) error {
- return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
- if err != nil || d.IsDir() {
- return err
- }
- if !strings.HasSuffix(d.Name(), ".yaml") && !strings.HasSuffix(d.Name(), ".yml") && !strings.HasSuffix(d.Name(), ".json") {
- return nil
- }
-
- decrypted, wasEncrypted, err := sops.Decrypt(path)
- if err != nil {
- return err
- }
-
- if wasEncrypted {
- cs.logger.Debug("Decrypted SOPS file", zap.String("file", path))
- return os.WriteFile(path, decrypted, 0644)
- }
- return nil
- })
-}
-
-func hasKustomization(fSys filesys.FileSystem, dir string) bool {
- for _, name := range []string{"kustomization.yaml", "kustomization.yml", "Kustomization"} {
- if fSys.Exists(filepath.Join(dir, name)) {
- return true
- }
- }
- return false
-}
-
-func hasHelmChart(dir string) bool {
- info, err := os.Stat(filepath.Join(dir, "Chart.yaml"))
- if err == nil && !info.IsDir() {
- return true
- }
- info, err = os.Stat(filepath.Join(dir, "Chart.yml"))
- if err == nil && !info.IsDir() {
- return true
- }
- return false
-}
-
-// applyYAMLData takes a byte slice of YAML documents (separated by ---) and applies them to the cluster.
-func (cs *ClientSet) applyYAMLData(ctx context.Context, data []byte, sourceName, appName, clusterName string) ([]ResourceMetadata, []error) {
- var appliedResources []ResourceMetadata
- var applyErrors []error
- decoder := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme)
- objects := strings.Split(string(data), "\n---")
-
- for i, objStr := range objects {
- trimmedObjStr := strings.TrimSpace(objStr)
- if trimmedObjStr == "" {
- continue
- }
-
- unstructuredObj := &unstructured.Unstructured{}
- _, gvk, decodeErr := decoder.Decode([]byte(trimmedObjStr), nil, unstructuredObj)
- if decodeErr != nil {
- cs.logger.Error("Failed to decode YAML object", zap.String("source", sourceName), zap.Int("documentIdx", i), zap.Error(decodeErr))
- applyErrors = append(applyErrors, fmt.Errorf("failed to decode YAML from %s (doc %d): %w", sourceName, i, decodeErr))
- continue
- }
-
- if unstructuredObj.GetName() == "" {
- cs.logger.Warn("Skipping unnamed resource in manifest", zap.String("source", sourceName), zap.Int("documentIdx", i), zap.String("kind", gvk.Kind))
- applyErrors = append(applyErrors, fmt.Errorf("skipping unnamed resource in %s (doc %d) of kind %s", sourceName, i, gvk.Kind))
- continue
- }
-
- mapping, mappingErr := cs.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
- if mappingErr != nil {
- cs.logger.Error("Failed to get REST mapping for GVK",
- zap.String("gvk", gvk.String()), zap.String("source", sourceName), zap.Error(mappingErr))
- applyErrors = append(applyErrors, fmt.Errorf("failed to get REST mapping for %s in %s: %w", gvk.String(), sourceName, mappingErr))
- continue
- }
-
- var dr dynamic.ResourceInterface
- if mapping.Scope.Name() == meta.RESTScopeNameNamespace {
- ns := unstructuredObj.GetNamespace()
- if ns == "" {
- ns = "default"
- unstructuredObj.SetNamespace(ns)
- cs.logger.Debug("Namespace not specified for namespaced resource, defaulting to 'default'",
- zap.String("kind", gvk.Kind),
- zap.String("name", unstructuredObj.GetName()))
- }
-
- // Security Check: Namespace Restriction
- if !cs.isNamespaceAllowed(ns) {
- cs.logger.Error("Namespace not allowed", zap.String("namespace", ns), zap.String("kind", gvk.Kind), zap.String("name", unstructuredObj.GetName()))
- applyErrors = append(applyErrors, fmt.Errorf("namespace %q is not in the allowed list for this cluster", ns))
- continue
- }
-
- dr = cs.dynamicClient.Resource(mapping.Resource).Namespace(ns)
- } else {
- // cluster-scoped resources should not specify the namespace
- dr = cs.dynamicClient.Resource(mapping.Resource)
- }
-
- // Try to get the resource
- _, getErr := dr.Get(ctx, unstructuredObj.GetName(), metav1.GetOptions{})
-
- if getErr != nil {
- // Resource does not exist, create it
- _, createErr := dr.Create(ctx, unstructuredObj, metav1.CreateOptions{})
- if createErr != nil {
- metrics.K8sApplyTotal.WithLabelValues(appName, clusterName, gvk.Kind, "failure").Inc()
- cs.logger.Error("Failed to create resource",
- zap.String("kind", gvk.Kind),
- zap.String("name", unstructuredObj.GetName()),
- zap.String("namespace", unstructuredObj.GetNamespace()),
- zap.Error(createErr))
- applyErrors = append(applyErrors, fmt.Errorf("failed to create %s %s/%s from %s: %w", gvk.Kind, unstructuredObj.GetNamespace(), unstructuredObj.GetName(), sourceName, createErr))
- continue
- }
- metrics.K8sApplyTotal.WithLabelValues(appName, clusterName, gvk.Kind, "success").Inc()
- cs.logger.Info("Created resource",
- zap.String("kind", gvk.Kind),
- zap.String("name", unstructuredObj.GetName()),
- zap.String("namespace", unstructuredObj.GetNamespace()))
- } else {
- // Resource exists, update it (using simple update for MVP)
- _, updateErr := dr.Update(ctx, unstructuredObj, metav1.UpdateOptions{})
- if updateErr != nil {
- metrics.K8sApplyTotal.WithLabelValues(appName, clusterName, gvk.Kind, "failure").Inc()
- cs.logger.Error("Failed to update resource",
- zap.String("kind", gvk.Kind),
- zap.String("name", unstructuredObj.GetName()),
- zap.String("namespace", unstructuredObj.GetNamespace()),
- zap.Error(updateErr))
- applyErrors = append(applyErrors, fmt.Errorf("failed to update %s %s/%s from %s: %w", gvk.Kind, unstructuredObj.GetNamespace(), unstructuredObj.GetName(), sourceName, updateErr))
- continue
- }
- metrics.K8sApplyTotal.WithLabelValues(appName, clusterName, gvk.Kind, "success").Inc()
- cs.logger.Info("Updated resource",
- zap.String("kind", gvk.Kind),
- zap.String("name", unstructuredObj.GetName()),
- zap.String("namespace", unstructuredObj.GetNamespace()))
- }
-
- appliedResources = append(appliedResources, ResourceMetadata{
- Group: gvk.Group,
- Version: gvk.Version,
- Kind: gvk.Kind,
- Namespace: unstructuredObj.GetNamespace(),
- Name: unstructuredObj.GetName(),
- })
- }
- return appliedResources, applyErrors
-}
-
-// CheckConnectivity verifies connectivity to the Kubernetes cluster.
-// It uses the Kubernetes clientset to fetch the server version, ensuring the cluster is reachable.
-// CheckConnectivity verifies connectivity to the Kubernetes cluster.
-// It uses the Kubernetes clientset to fetch the server version, ensuring the cluster is reachable.
-func (cs *ClientSet) CheckConnectivity(ctx context.Context) error {
- if cs.config == nil {
- return fmt.Errorf("failed to create kubernetes clientset: missing Kubernetes config")
- }
- kubeClient, err := kubernetes.NewForConfig(cs.config)
- if err != nil {
- return fmt.Errorf("failed to create kubernetes clientset: %w", err)
- }
- _, err = kubeClient.Discovery().ServerVersion()
- if err != nil {
- return fmt.Errorf("failed to get Kubernetes server version: %w", err)
- }
- return nil
-}
-
-// GetResourceHealth checks the health of a specific Kubernetes resource.
-// Returns status (Healthy, Progressing, Degraded, Unknown) and a descriptive message.
-func (cs *ClientSet) GetResourceHealth(ctx context.Context, r ResourceMetadata) (string, string, error) {
- gvk := schema.GroupVersionKind{Group: r.Group, Version: r.Version, Kind: r.Kind}
- mapping, err := cs.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
- if err != nil {
- return "Unknown", fmt.Sprintf("Failed to get mapping: %v", err), err
- }
-
- var dr dynamic.ResourceInterface
- if mapping.Scope.Name() == meta.RESTScopeNameNamespace {
- // Security Check: Namespace Restriction
- if !cs.isNamespaceAllowed(r.Namespace) {
- return "Unknown", fmt.Sprintf("Namespace %q is not allowed", r.Namespace), fmt.Errorf("namespace %q is not in the allowed list", r.Namespace)
- }
- dr = cs.dynamicClient.Resource(mapping.Resource).Namespace(r.Namespace)
- } else {
- dr = cs.dynamicClient.Resource(mapping.Resource)
- }
-
- obj, err := dr.Get(ctx, r.Name, metav1.GetOptions{})
- if err != nil {
- return "Unknown", fmt.Sprintf("Failed to get resource: %v", err), err
- }
-
- // Basic health assessment logic based on resource kind
- switch r.Kind {
- case "Deployment":
- status, ok, _ := unstructured.NestedMap(obj.Object, "status")
- if !ok {
- return "Progressing", "Waiting for status", nil
- }
- replicas, _, _ := unstructured.NestedInt64(status, "replicas")
- readyReplicas, _, _ := unstructured.NestedInt64(status, "readyReplicas")
- updatedReplicas, _, _ := unstructured.NestedInt64(status, "updatedReplicas")
- availableReplicas, _, _ := unstructured.NestedInt64(status, "availableReplicas")
-
- if availableReplicas >= replicas && readyReplicas >= replicas && updatedReplicas >= replicas {
- return "Healthy", fmt.Sprintf("%d/%d replicas available", availableReplicas, replicas), nil
- }
- return "Progressing", fmt.Sprintf("%d/%d replicas available", availableReplicas, replicas), nil
-
- case "Service":
- return "Healthy", "Service created", nil
-
- case "Pod":
- status, ok, _ := unstructured.NestedMap(obj.Object, "status")
- if !ok {
- return "Progressing", "Waiting for status", nil
- }
- phase, _, _ := unstructured.NestedString(status, "phase")
- if phase == "Running" || phase == "Succeeded" {
- return "Healthy", "Pod is " + phase, nil
- }
- if phase == "Failed" {
- return "Degraded", "Pod failed", nil
- }
- return "Progressing", "Pod is " + phase, nil
-
- default:
- return "Healthy", "Resource applied", nil
- }
-}
-
-func (cs *ClientSet) isNamespaceAllowed(ns string) bool {
- if len(cs.allowedNamespaces) == 0 {
- return true
- }
- for _, allowed := range cs.allowedNamespaces {
- if allowed == ns {
- return true
- }
- }
- return false
-}
-