From f340e60bcf56ce3707518ac40d193d1e4411662c Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Thu, 16 Jul 2026 18:14:00 +0530 Subject: [PATCH 01/11] gitops-agent features added along with gitops_applications --- pkg/spec/gitops.spec.yaml | 210 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 pkg/spec/gitops.spec.yaml diff --git a/pkg/spec/gitops.spec.yaml b/pkg/spec/gitops.spec.yaml new file mode 100644 index 0000000..725f7cd --- /dev/null +++ b/pkg/spec/gitops.spec.yaml @@ -0,0 +1,210 @@ +spec_version: 1 +module_type: builtin +module_desc: Harness GitOps — ArgoCD-backed agents, applications, clusters, and repositories +help_text: | + ## GitOps (gitops) + + The GitOps module manages Harness GitOps resources — the ArgoCD-backed + agents that run in your clusters and reconcile desired state from Git. + + ### Domain Model + + A **gitops-agent** is the component Harness installs into a Kubernetes + cluster to run GitOps reconciliation. Every agent has a scope (account, org, + or project), a type (MANAGED_ARGO_PROVIDER, HOSTED_ARGO_PROVIDER, or the + deprecated CONNECTED_ARGO_PROVIDER), a health status, and a version. Agents + with `upgrade_available: true` are running behind the latest released build. + + Use `list gitops_agent` to see agents in the current scope and + `get gitops_agent ` for the full detail of a single agent. + + Agents at account or org scope are reachable with `--level account` / + `--level org`. + + A **gitops-application** is a deployed unit of desired state — an Argo CD + Application that reconciles manifests from a Git source into a target cluster + and namespace. Each application belongs to an agent, so its id is compound: + `/`. Use `list gitops_application` to see all applications in the + current scope and `get gitops_application /` for full detail. + + ### Nouns + + {{nouns}} + +nouns: + - noun: gitops_agent + short_desc: "Harness GitOps agents — ArgoCD reconcilers running in your clusters." + noun_aliases: [gitops_agents] + multi_level: true + fields: + - id: identifier + expr: it.identifier + - id: name + expr: it.name + - id: type + expr: it.type + - id: scope + expr: it.scope + - id: health + label: Health + expr: (it.health.harnessGitopsAgent ?? {})["status"] + - id: connected + label: Connected + expr: it.health.connectionStatus + - id: namespace + expr: it.metadata.namespace + - id: apps + label: Apps + expr: it.metadata.deployedApplicationCount + align: right + - id: version + expr: 'it.version == nil ? "" : it.version.major + "." + it.version.minor + "." + it.version.patch' + - id: upgrade_available + label: Upgrade + expr: it.upgradeAvailable + - id: description + expr: it.description + width_max: 60 + - id: prefixed_identifier + label: Prefixed ID + expr: it.prefixedIdentifier + - id: last_heartbeat + expr: it.health.lastHeartbeat + - id: org_id + expr: it.orgIdentifier + - id: project_id + expr: it.projectIdentifier + - id: created + expr: epochMs(it.createdAt) + - id: updated + expr: epochMs(it.lastModifiedAt) + + - noun: gitops_application + short_desc: "Harness GitOps applications — Argo CD apps reconciling Git state into a cluster." + noun_aliases: [gitops_applications] + multi_level: true + fields: + - id: name + expr: it.name + - id: agent + expr: it.agentIdentifier + - id: cluster + expr: it.clusterIdentifier + - id: repo + expr: it.repoIdentifier + - id: project + expr: it.app.spec.project + - id: namespace + expr: it.app.spec.destination.namespace + - id: sync_status + label: Sync + expr: it.app.status.sync.status + - id: health + label: Health + expr: it.app.status.health.status + - id: revision + expr: it.app.status.sync.revision + - id: repo_url + label: Repo URL + expr: it.app.spec.source.repoURL + width_max: 60 + - id: path + expr: it.app.spec.source.path + - id: target_revision + expr: it.app.spec.source.targetRevision + - id: created + expr: it.createdAt + - id: updated + expr: it.lastModifiedAt + +commands: + - command: list gitops_agent + verb: list + noun: gitops_agent + short: "List GitOps agents in the current scope: harness list gitops_agent [--type ...] [--health-status ...]" + handler_type: endpoint + flags: + - name: search + description: Filter agents by name or identifier keyword + - name: type + description: "Filter by agent type" + completion_values: [MANAGED_ARGO_PROVIDER, HOSTED_ARGO_PROVIDER, CONNECTED_ARGO_PROVIDER] + - name: scope + description: "Filter by agent scope" + completion_values: [ACCOUNT, ORG, PROJECT] + - name: health-status + description: "Filter by health status" + completion_values: [HEALTHY, UNHEALTHY] + - name: connected-status + description: "Filter by connectivity status" + completion_values: [CONNECTED, DISCONNECTED] + endpoint: + path: /gitops/api/v1/agents + items_expr: it.content + get_id_expr: it.identifier + completion: + id_expr: it.identifier + name_expr: it.name + query_params: + searchTerm: flags.search + type: flags.type + scope: flags.scope + healthStatus: flags.health-status + connectedStatus: flags.connected-status + paging: + paging_strategy: page_index + countable: true + page_index_param: pageIndex + page_size_param: pageSize + page_size_default: 100 + page_size_max: 100 + total_expr: it.totalItems ?? 0 + columns: [identifier, name, type, health, namespace, apps, version, upgrade_available] + + - command: get gitops_agent + verb: get + noun: gitops_agent + short: "Get a single GitOps agent by identifier: harness get gitops_agent " + handler_type: endpoint + endpoint: + path: /gitops/api/v1/agents/{{ctx.id}} + item_expr: it + fields_subset: [identifier, name, type, scope, health, connected, namespace, apps, version, upgrade_available, description, prefixed_identifier, last_heartbeat, org_id, project_id, created, updated] + + - command: list gitops_application + verb: list + noun: gitops_application + short: "List GitOps applications in the current scope: harness list gitops_application [--search ...]" + handler_type: endpoint + flags: + - name: search + description: Filter applications by name or keyword + endpoint: + method: POST + path: /gitops/api/v1/applications + items_expr: it.content ?? [] + get_id_expr: it.agentIdentifier + "/" + it.name + completion: + id_expr: it.agentIdentifier + "/" + it.name + name_expr: it.name + body_params: + accountIdentifier: auth.account + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'auth.project != "" ? auth.project : nil' + pageSize: "1000" + searchTerm: 'flags.search != "" ? flags.search : nil' + paging: + paging_strategy: flat_list + columns: [name, agent, sync_status, health, namespace, project] + + - command: get gitops_application + verb: get + noun: gitops_application + short: "Get a single GitOps application: harness get gitops_application " + handler_type: endpoint + id_parts: 2 + id_label: "" + endpoint: + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/applications/{{ctx.idParts[1]}} + item_expr: it + fields_subset: [name, agent, cluster, repo, project, namespace, sync_status, health, revision, repo_url, path, target_revision, created, updated] From 34e85a890b11e25386edb770971b25ae36aed2aa Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Fri, 17 Jul 2026 19:17:30 +0530 Subject: [PATCH 02/11] Feat: Create features are implemented for the gitops_agent --- pkg/spec/gitops.spec.yaml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/pkg/spec/gitops.spec.yaml b/pkg/spec/gitops.spec.yaml index 725f7cd..7640203 100644 --- a/pkg/spec/gitops.spec.yaml +++ b/pkg/spec/gitops.spec.yaml @@ -21,6 +21,12 @@ help_text: | Agents at account or org scope are reachable with `--level account` / `--level org`. + `create gitops_agent` only registers the agent record in Harness (via + `--set` flags or `-f agent.yaml`) — it does not install anything into a + cluster, and `type` is always MANAGED_ARGO_PROVIDER. Installing the agent + into a cluster is a separate future step (`execute gitops_agent:install`, + not yet implemented). + A **gitops-application** is a deployed unit of desired state — an Argo CD Application that reconciles manifests from a Git source into a target cluster and namespace. Each application belongs to an agent, so its id is compound: @@ -41,6 +47,7 @@ nouns: expr: it.identifier - id: name expr: it.name + mutable_path: name - id: type expr: it.type - id: scope @@ -53,6 +60,7 @@ nouns: expr: it.health.connectionStatus - id: namespace expr: it.metadata.namespace + mutable_path: metadata.namespace - id: apps label: Apps expr: it.metadata.deployedApplicationCount @@ -65,6 +73,7 @@ nouns: - id: description expr: it.description width_max: 60 + mutable_path: description - id: prefixed_identifier label: Prefixed ID expr: it.prefixedIdentifier @@ -171,6 +180,31 @@ commands: item_expr: it fields_subset: [identifier, name, type, scope, health, connected, namespace, apps, version, upgrade_available, description, prefixed_identifier, last_heartbeat, org_id, project_id, created, updated] + - command: create gitops_agent + verb: create + noun: gitops_agent + requires_id: true + short: "Create a GitOps agent record: harness create gitops_agent --set name= namespace=, or -f agent.yaml" + flags_builtin: + set: true + handler_type: endpoint + endpoint: + method: POST + path: /gitops/api/v1/agents + file_body: optional + create_strategy: set-fields + create_body_init: + identifier: ctx.id + name: coalesce(ctx.setArgs.name, ctx.id) + type: '"MANAGED_ARGO_PROVIDER"' + accountIdentifier: auth.account + orgIdentifier: 'ctx.level == "account" ? nil : (auth.org != "" ? auth.org : nil)' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + create_body_wrap: "" + item_expr: it + text_header: "\nCreated gitops agent {{it.identifier}} (not yet installed in a cluster)\n" + text_footer: "Install it later with: harness execute gitops_agent:install {{it.identifier}} (coming soon)\n" + - command: list gitops_application verb: list noun: gitops_application From 9c2d56cc9ed67edd31b45f23b5d1cfc7229bae68 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Sun, 19 Jul 2026 20:08:29 +0530 Subject: [PATCH 03/11] Feat: Install agent command is added --- cmd/harness/main-harness.go | 2 + modules/gitops/gitops.go | 162 ++++++++++++++++++++++++++++++++++++ pkg/spec/gitops.spec.yaml | 38 ++++++++- 3 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 modules/gitops/gitops.go diff --git a/cmd/harness/main-harness.go b/cmd/harness/main-harness.go index bc29c38..e7e112f 100644 --- a/cmd/harness/main-harness.go +++ b/cmd/harness/main-harness.go @@ -14,6 +14,7 @@ import ( "github.com/harness/cli/modules/code" "github.com/harness/cli/modules/core" + "github.com/harness/cli/modules/gitops" "github.com/harness/cli/modules/iacm" "github.com/harness/cli/modules/pipeline" "github.com/harness/cli/pkg/console" @@ -44,6 +45,7 @@ func main() { } code.ModuleInit(reg.Module("code")) core.ModuleInit(reg.Module("core")) + gitops.ModuleInit(reg.Module("gitops")) pipeline.ModuleInit(reg.Module("pipeline")) // har is an external module (external_binary: harness-har) — ModuleInit is not loaded here. iacm.ModuleInit(reg.Module("iacm")) diff --git a/modules/gitops/gitops.go b/modules/gitops/gitops.go new file mode 100644 index 0000000..120e49d --- /dev/null +++ b/modules/gitops/gitops.go @@ -0,0 +1,162 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gitops + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + + "go.yaml.in/yaml/v3" + + "github.com/harness/cli/pkg/client" + "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/registry" +) + +const installWorkflowID = "gitops_agent_install" + +// ModuleInit registers gitops workflows. Commands are declared in gitops.spec.yaml. +func ModuleInit(reg registry.ModuleRegistrar) { + reg.RegisterWorkflow(installWorkflowID, executeAgentInstall) +} + +// installInput mirrors the subset of v1AgentYamlQuery exposed via -f install.yaml. +type installInput struct { + Namespace string `yaml:"namespace"` + DisasterRecoveryIdentifier string `yaml:"disasterRecoveryIdentifier"` + SkipCrds bool `yaml:"skipCrds"` + CaData string `yaml:"caData"` + PrivateKey string `yaml:"privateKey"` + Proxy map[string]any `yaml:"proxy"` + ArgocdSettings map[string]any `yaml:"argocdSettings"` +} + +// executeAgentInstall implements "execute gitops_agent:install". It fetches the +// Helm override.yaml (or, with --method yaml, the plain k8s manifest) for an +// existing agent and prints the commands to install it. It never touches a +// cluster itself. This is a workflow (not a spec-only endpoint) because these +// endpoints respond with a literal YAML string body, which client.DoRequest's +// unconditional json.Unmarshal cannot decode — client.DoRaw is required instead. +func executeAgentInstall(ctx *cmdctx.Ctx) error { + agentID := ctx.Id + if agentID == "" { + return errors.New("execute gitops_agent:install requires a positional argument") + } + method := cmdctx.GetString(ctx.FlagValues, "method") + if method == "" { + method = "helm" + } + if method != "helm" && method != "yaml" { + return fmt.Errorf("invalid --method %q: must be \"helm\" or \"yaml\"", method) + } + + a := *ctx.Auth + switch ctx.Level { + case "org": + a.ProjectID = "" + case "account": + a.OrgID, a.ProjectID = "", "" + } + qp := map[string]string{"accountIdentifier": a.AccountID, "orgIdentifier": a.OrgID, "projectIdentifier": a.ProjectID} + + c := client.New(ctx) + agentResp, _, err := c.Get(fmt.Sprintf("/gitops/api/v1/agents/%s", agentID), qp) + if err != nil { + return fmt.Errorf("fetching agent %q: %w (has it been created with 'harness create gitops_agent'?)", agentID, err) + } + namespace := "" + if m, ok := agentResp.(map[string]any); ok { + if md, ok := m["metadata"].(map[string]any); ok { + namespace, _ = md["namespace"].(string) + } + } + + var input installInput + if filePath := cmdctx.GetString(ctx.FlagValues, "file"); filePath != "" { + body, err := cmdctx.SlurpInputFile(ctx.FlagValues) + if err != nil { + return err + } + if err := yaml.Unmarshal([]byte(body), &input); err != nil { + return fmt.Errorf("parsing -f install file: %w", err) + } + if input.Namespace != "" { + namespace = input.Namespace + } + } + if namespace == "" { + return fmt.Errorf("namespace is required: set it via -f install.yaml, or it must already be set on the agent (see 'harness get gitops_agent %s')", agentID) + } + + reqBody := map[string]any{ + "accountIdentifier": a.AccountID, + "orgIdentifier": a.OrgID, + "projectIdentifier": a.ProjectID, + "agentIdentifier": agentID, + "namespace": namespace, + } + if input.SkipCrds { + reqBody["skipCrds"] = true + } + for k, v := range map[string]string{"disasterRecoveryIdentifier": input.DisasterRecoveryIdentifier, "caData": input.CaData, "privateKey": input.PrivateKey} { + if v != "" { + reqBody[k] = v + } + } + if input.Proxy != nil { + reqBody["proxy"] = input.Proxy + } + if input.ArgocdSettings != nil { + reqBody["argocdSettings"] = input.ArgocdSettings + } + outPath := cmdctx.GetString(ctx.FlagValues, "output_file") + if outPath == "" { + return fmt.Errorf("output file is required: set it via --output_file") + } + if !strings.HasSuffix(outPath, ".yaml") && !strings.HasSuffix(outPath, ".yaml.txt") { + return fmt.Errorf("output file must end with .yaml or .yaml.txt") + } + + path := fmt.Sprintf("/gitops/api/v1/agents/%s/helm-overrides", agentID) + + resp, err := c.DoRaw(client.Request{Method: "POST", Path: path, Body: reqBody}) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading API response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("API error %d: %s", resp.StatusCode, client.APIErrorMessage(resp.StatusCode, respBody)) + } + // Despite the OpenAPI doc annotating these routes as application/yaml, the + // gRPC-gateway actually wraps the string response as JSON: {"value": "..."}. + var wrapped struct { + Value string `json:"value"` + } + content := respBody + if json.Unmarshal(respBody, &wrapped) == nil && wrapped.Value != "" { + content = []byte(wrapped.Value) + } + if err := os.WriteFile(outPath, content, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", outPath, err) + } + + fmt.Printf("\nWrote %s install artifact to %s\n\nInstall the agent with:\n", method, outPath) + fmt.Println(" Connect to your Kubernetes cluster and install the agent with:") + if method == "helm" { + fmt.Println(" helm repo add gitops-agent https://harness.github.io/gitops-helm/") + fmt.Println(" helm repo update gitops-agent") + fmt.Printf(" helm install argocd gitops-agent/gitops-helm --values %s --namespace %s\n", outPath, namespace) + } else { + fmt.Printf(" kubectl apply -f %s -n %s\n", outPath, namespace) + } + fmt.Printf("\nAfter installing, check status with: harness get gitops_agent %s\n", agentID) + return nil +} diff --git a/pkg/spec/gitops.spec.yaml b/pkg/spec/gitops.spec.yaml index 7640203..4503ccf 100644 --- a/pkg/spec/gitops.spec.yaml +++ b/pkg/spec/gitops.spec.yaml @@ -23,9 +23,11 @@ help_text: | `create gitops_agent` only registers the agent record in Harness (via `--set` flags or `-f agent.yaml`) — it does not install anything into a - cluster, and `type` is always MANAGED_ARGO_PROVIDER. Installing the agent - into a cluster is a separate future step (`execute gitops_agent:install`, - not yet implemented). + cluster, and `type` is always MANAGED_ARGO_PROVIDER. Use + `execute gitops_agent:install ` afterward to fetch the Helm + `override.yaml` (or, with `--method yaml`, the plain Kubernetes manifest) + and print the `helm`/`kubectl` commands to actually install it — the CLI + never runs those commands or touches your cluster itself. A **gitops-application** is a deployed unit of desired state — an Argo CD Application that reconciles manifests from a Git source into a target cluster @@ -203,7 +205,35 @@ commands: create_body_wrap: "" item_expr: it text_header: "\nCreated gitops agent {{it.identifier}} (not yet installed in a cluster)\n" - text_footer: "Install it later with: harness execute gitops_agent:install {{it.identifier}} (coming soon)\n" + text_footer: "Install it later with: harness execute gitops_agent:install {{it.identifier}}\n" + + - command: execute gitops_agent:install + verb: execute + noun: gitops_agent + noun_variant: install + requires_id: true + short: "Fetch install artifacts for a GitOps agent: harness execute gitops_agent:install [-f install.yaml] [--method helm|yaml]" + long: | + Fetches the Helm chart override values (or, with --method yaml, the plain + Kubernetes manifest) needed to install a previously created GitOps agent + into a cluster, writes them to a local file, and prints the commands to + run. Does not touch your cluster or run helm/kubectl for you. + + Advanced settings (namespace override, skipCrds, proxy, mTLS certs, + ArgoCD settings, disaster-recovery id) can be supplied via -f install.yaml. + If omitted, the namespace already registered on the agent is used. + handler_type: workflow + workflow_id: gitops_agent_install + flags: + - name: file + short: f + description: "Optional YAML file with install settings (namespace, skipCrds, proxy, privateKey, argocdSettings, disasterRecoveryIdentifier)" + - name: method + description: "Install method: helm (override.yaml, default) or yaml (kubectl manifest)" + completion_values: [helm, yaml] + - name: output_file + description: "Output file name" + required: true - command: list gitops_application verb: list From 279d7c65e8e960b196558f44d224d5fb8c68b3ce Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Mon, 20 Jul 2026 13:09:38 +0530 Subject: [PATCH 04/11] Feat: Implemented the get, list features for gitops instances. --- modules/gitops/gitops.go | 1 + pkg/spec/gitops.spec.yaml | 223 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) diff --git a/modules/gitops/gitops.go b/modules/gitops/gitops.go index 120e49d..1ce89bd 100644 --- a/modules/gitops/gitops.go +++ b/modules/gitops/gitops.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + "strings" "go.yaml.in/yaml/v3" diff --git a/pkg/spec/gitops.spec.yaml b/pkg/spec/gitops.spec.yaml index 4503ccf..56bb661 100644 --- a/pkg/spec/gitops.spec.yaml +++ b/pkg/spec/gitops.spec.yaml @@ -35,6 +35,18 @@ help_text: | `/`. Use `list gitops_application` to see all applications in the current scope and `get gitops_application /` for full detail. + A **gitops-cluster** is a Kubernetes cluster registered with a GitOps agent. + List with `list gitops_cluster`; get with `get gitops_cluster /`. + + A **gitops-repository** is a Git repo configured on a GitOps agent for + manifest sources. List with `list gitops_repository`; get with + `get gitops_repository /`. + + A **gitops-application-set** is an Argo CD ApplicationSet that generates + multiple applications from a template. List with `list gitops_application_set`; + get with `get gitops_application_set /` — use the UUID from list + output, not the display name. + ### Nouns {{nouns}} @@ -128,6 +140,94 @@ nouns: - id: updated expr: it.lastModifiedAt + - noun: gitops_cluster + short_desc: "Harness GitOps clusters — Kubernetes clusters registered with GitOps agents." + noun_aliases: [gitops_clusters] + multi_level: true + fields: + - id: identifier + expr: it.identifier + - id: name + expr: it.cluster.name + - id: agent + expr: it.agentIdentifier + - id: server + expr: it.cluster.server + width_max: 60 + - id: connection + label: Connection + expr: it.cluster.connectionState.status + - id: org_id + expr: it.orgIdentifier + - id: project_id + expr: it.projectIdentifier + - id: created + expr: it.createdAt + - id: updated + expr: it.lastModifiedAt + + - noun: gitops_repository + short_desc: "Harness GitOps repositories — Git repos configured on GitOps agents." + noun_aliases: [gitops_repositories] + multi_level: true + fields: + - id: identifier + expr: it.identifier + - id: name + expr: 'it.repository.name != "" ? it.repository.name : it.identifier' + - id: agent + expr: it.agentIdentifier + - id: repo_url + label: Repo URL + expr: it.repository.repo + width_max: 60 + - id: type + expr: it.repository.type + - id: connection + label: Connection + expr: it.repository.connectionState.status + - id: message + label: Message + expr: it.repository.connectionState.message ?? "" + - id: connection_type + label: Connection Type + expr: it.repository.connectionType ?? "" + - id: org_id + expr: it.orgIdentifier + - id: project_id + expr: it.projectIdentifier + - id: created + expr: it.createdAt + - id: updated + expr: it.lastModifiedAt + + - noun: gitops_application_set + short_desc: "Harness GitOps ApplicationSets — templates that auto-generate Argo CD applications." + noun_aliases: [gitops_application_sets] + multi_level: true + fields: + - id: identifier + label: UUID + expr: it.identifier + - id: name + expr: 'it.name ?? (it.applicationset ?? {}).metadata.name' + - id: agent + expr: it.agentIdentifier + - id: project + expr: '(it.applicationset ?? {}).spec.template.spec.project' + - id: generators + label: Generators + expr: 'len((it.applicationset ?? {}).spec.generators ?? [])' + align: right + - id: org_id + expr: it.orgIdentifier + - id: project_id + expr: it.projectIdentifier + - id: created + expr: it.createdAt + - id: updated + expr: it.lastModifiedAt + commands: - command: list gitops_agent verb: list @@ -272,3 +372,126 @@ commands: path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/applications/{{ctx.idParts[1]}} item_expr: it fields_subset: [name, agent, cluster, repo, project, namespace, sync_status, health, revision, repo_url, path, target_revision, created, updated] + + - command: list gitops_cluster + verb: list + noun: gitops_cluster + short: "List GitOps clusters in the current scope: harness list gitops_cluster [--search ...] [--agent ...]" + handler_type: endpoint + flags: + - name: search + description: Filter clusters by name or keyword + - name: agent + description: Filter by scope-prefixed GitOps agent identifier + endpoint: + method: POST + path: /gitops/api/v1/clusters + items_expr: it.content ?? [] + get_id_expr: it.agentIdentifier + "/" + it.identifier + completion: + id_expr: it.agentIdentifier + "/" + it.identifier + name_expr: it.cluster.name + body_params: + accountIdentifier: auth.account + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'auth.project != "" ? auth.project : nil' + pageSize: "1000" + searchTerm: 'flags.search != "" ? flags.search : nil' + agentIdentifier: 'flags.agent != "" ? flags.agent : nil' + paging: + paging_strategy: flat_list + columns: [identifier, name, agent, server, connection] + + - command: get gitops_cluster + verb: get + noun: gitops_cluster + short: "Get a single GitOps cluster: harness get gitops_cluster " + handler_type: endpoint + id_parts: 2 + id_label: "" + endpoint: + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/clusters/{{ctx.idParts[1]}} + item_expr: it + fields_subset: [identifier, name, agent, server, connection, org_id, project_id, created, updated] + + - command: list gitops_repository + verb: list + noun: gitops_repository + short: "List GitOps repositories in the current scope: harness list gitops_repository [--search ...] [--agent ...]" + handler_type: endpoint + flags: + - name: search + description: Filter repositories by name or URL keyword + - name: agent + description: Filter by scope-prefixed GitOps agent identifier + endpoint: + method: POST + path: /gitops/api/v1/repositories + items_expr: it.content ?? [] + get_id_expr: it.agentIdentifier + "/" + it.identifier + completion: + id_expr: it.agentIdentifier + "/" + it.identifier + name_expr: 'it.repository.name != "" ? it.repository.name : it.identifier' + body_params: + accountIdentifier: auth.account + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'auth.project != "" ? auth.project : nil' + pageSize: "1000" + searchTerm: 'flags.search != "" ? flags.search : nil' + agentIdentifier: 'flags.agent != "" ? flags.agent : nil' + paging: + paging_strategy: flat_list + columns: [identifier, name, agent, repo_url, type, connection] + + - command: get gitops_repository + verb: get + noun: gitops_repository + short: "Get a single GitOps repository: harness get gitops_repository " + handler_type: endpoint + id_parts: 2 + id_label: "" + endpoint: + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/repositories/{{ctx.idParts[1]}} + item_expr: it + fields_subset: [identifier, name, agent, repo_url, type, connection, org_id, project_id, created, updated] + + - command: list gitops_application_set + verb: list + noun: gitops_application_set + short: "List GitOps ApplicationSets: harness list gitops_application_set [--search ...] [--agent ...]" + handler_type: endpoint + flags: + - name: search + description: Filter ApplicationSets by name or keyword + - name: agent + description: Filter by scope-prefixed GitOps agent identifier + endpoint: + method: POST + path: /gitops/api/v1/applicationsets + items_expr: it.content ?? [] + get_id_expr: it.agentIdentifier + "/" + it.identifier + completion: + id_expr: it.agentIdentifier + "/" + it.identifier + name_expr: 'it.name ?? (it.applicationset ?? {}).metadata.name' + body_params: + accountIdentifier: auth.account + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'auth.project != "" ? auth.project : nil' + pageSize: "1000" + searchTerm: 'flags.search != "" ? flags.search : nil' + agentIdentifier: 'flags.agent != "" ? flags.agent : nil' + paging: + paging_strategy: flat_list + columns: [name, agent, identifier, project, generators] + + - command: get gitops_application_set + verb: get + noun: gitops_application_set + short: "Get a GitOps ApplicationSet by UUID: harness get gitops_application_set " + handler_type: endpoint + id_parts: 2 + id_label: "" + endpoint: + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/applicationsets/{{ctx.idParts[1]}} + item_expr: it + fields_subset: [identifier, name, agent, project, generators, org_id, project_id, created, updated] From 5bc425463f5320f12351c5bd5f365d317f31559f Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Mon, 20 Jul 2026 17:59:45 +0530 Subject: [PATCH 05/11] Feat: Add spec-only create gitops_application and delete gitops_agent. --- modules/gitops/gitops.go | 24 +++++++++-------- pkg/spec/gitops.spec.yaml | 56 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/modules/gitops/gitops.go b/modules/gitops/gitops.go index 1ce89bd..8f75d29 100644 --- a/modules/gitops/gitops.go +++ b/modules/gitops/gitops.go @@ -55,17 +55,11 @@ func executeAgentInstall(ctx *cmdctx.Ctx) error { return fmt.Errorf("invalid --method %q: must be \"helm\" or \"yaml\"", method) } - a := *ctx.Auth - switch ctx.Level { - case "org": - a.ProjectID = "" - case "account": - a.OrgID, a.ProjectID = "", "" + cs := ctx.Resolver.GetSpec("get", "gitops_agent") + if cs == nil || cs.Endpoint == nil { + return errors.New("get gitops_agent command spec not found") } - qp := map[string]string{"accountIdentifier": a.AccountID, "orgIdentifier": a.OrgID, "projectIdentifier": a.ProjectID} - - c := client.New(ctx) - agentResp, _, err := c.Get(fmt.Sprintf("/gitops/api/v1/agents/%s", agentID), qp) + agentResp, err := registry.CallEndpoint(ctx, cs.Endpoint) if err != nil { return fmt.Errorf("fetching agent %q: %w (has it been created with 'harness create gitops_agent'?)", agentID, err) } @@ -93,6 +87,13 @@ func executeAgentInstall(ctx *cmdctx.Ctx) error { return fmt.Errorf("namespace is required: set it via -f install.yaml, or it must already be set on the agent (see 'harness get gitops_agent %s')", agentID) } + a := *ctx.Auth + switch ctx.Level { + case "org": + a.ProjectID = "" + case "account": + a.OrgID, a.ProjectID = "", "" + } reqBody := map[string]any{ "accountIdentifier": a.AccountID, "orgIdentifier": a.OrgID, @@ -121,9 +122,10 @@ func executeAgentInstall(ctx *cmdctx.Ctx) error { if !strings.HasSuffix(outPath, ".yaml") && !strings.HasSuffix(outPath, ".yaml.txt") { return fmt.Errorf("output file must end with .yaml or .yaml.txt") } - + path := fmt.Sprintf("/gitops/api/v1/agents/%s/helm-overrides", agentID) + c := client.New(ctx) resp, err := c.DoRaw(client.Request{Method: "POST", Path: path, Body: reqBody}) if err != nil { return err diff --git a/pkg/spec/gitops.spec.yaml b/pkg/spec/gitops.spec.yaml index 56bb661..cb541c8 100644 --- a/pkg/spec/gitops.spec.yaml +++ b/pkg/spec/gitops.spec.yaml @@ -29,11 +29,18 @@ help_text: | and print the `helm`/`kubectl` commands to actually install it — the CLI never runs those commands or touches your cluster itself. + `delete gitops_agent ` removes the Harness agent record only — it does + not uninstall cluster resources. Use `confirm_id` (type the agent id to + confirm) or `--force` to skip the prompt. + A **gitops-application** is a deployed unit of desired state — an Argo CD Application that reconciles manifests from a Git source into a target cluster and namespace. Each application belongs to an agent, so its id is compound: `/`. Use `list gitops_application` to see all applications in the current scope and `get gitops_application /` for full detail. + Create with `create gitops_application -f app.yaml --cluster + [--repo ]` — `-f` is the Argo CD Application manifest (metadata + spec); + cluster and repo are passed as flags. A **gitops-cluster** is a Kubernetes cluster registered with a GitOps agent. List with `list gitops_cluster`; get with `get gitops_cluster /`. @@ -98,9 +105,9 @@ nouns: - id: project_id expr: it.projectIdentifier - id: created - expr: epochMs(it.createdAt) + expr: it.createdAt - id: updated - expr: epochMs(it.lastModifiedAt) + expr: it.lastModifiedAt - noun: gitops_application short_desc: "Harness GitOps applications — Argo CD apps reconciling Git state into a cluster." @@ -136,9 +143,9 @@ nouns: - id: target_revision expr: it.app.spec.source.targetRevision - id: created - expr: it.createdAt + expr: it.createdAt ?? it.app.creationTimestampTs - id: updated - expr: it.lastModifiedAt + expr: it.lastModifiedAt ?? it.app.status.operationState.finishedAtTs - noun: gitops_cluster short_desc: "Harness GitOps clusters — Kubernetes clusters registered with GitOps agents." @@ -307,6 +314,18 @@ commands: text_header: "\nCreated gitops agent {{it.identifier}} (not yet installed in a cluster)\n" text_footer: "Install it later with: harness execute gitops_agent:install {{it.identifier}}\n" + - command: delete gitops_agent + verb: delete + noun: gitops_agent + confirm_mode: prompt + short: "Delete a GitOps agent: harness delete gitops_agent " + handler_type: endpoint + endpoint: + method: DELETE + path: /gitops/api/v1/agents/{{ctx.id}} + item_expr: it + text_header: "\nDeleted gitops agent {{ctx.id}}\n" + - command: execute gitops_agent:install verb: execute noun: gitops_agent @@ -373,6 +392,35 @@ commands: item_expr: it fields_subset: [name, agent, cluster, repo, project, namespace, sync_status, health, revision, repo_url, path, target_revision, created, updated] + - command: create gitops_application + verb: create + noun: gitops_application + requires_id: true + short: "Create a GitOps application: harness create gitops_application -f app.yaml --cluster [--repo ]" + handler_type: endpoint + flags: + - name: cluster + description: "GitOps cluster identifier (required)" + required: true + - name: repo + description: "GitOps repository identifier on the agent" + - name: skip-repo-validation + description: "Skip repository validation (use instead of --repo when repo is not registered)" + is_bool: true + endpoint: + method: POST + path: /gitops/api/v1/agents/{{ctx.id}}/applications + file_body: required + create_body_wrap: application + query_params: + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + clusterIdentifier: flags.cluster + repoIdentifier: 'flags.repo != "" ? flags.repo : nil' + skipRepoValidation: 'flags["skip-repo-validation"] ? "true" : nil' + item_expr: it + text_header: "\nCreated gitops application {{it.name}}\n\nGet it with: harness get gitops_application {{ctx.id}}/{{it.name}}\n" + - command: list gitops_cluster verb: list noun: gitops_cluster From b8d527121a92e341e60d2892a10e6f0e49dc7580 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Mon, 20 Jul 2026 19:24:37 +0530 Subject: [PATCH 06/11] Feat: Add gitops_application update, sync, and refresh commands. --- pkg/spec/gitops.spec.yaml | 89 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/pkg/spec/gitops.spec.yaml b/pkg/spec/gitops.spec.yaml index cb541c8..5d459f9 100644 --- a/pkg/spec/gitops.spec.yaml +++ b/pkg/spec/gitops.spec.yaml @@ -40,7 +40,13 @@ help_text: | current scope and `get gitops_application /` for full detail. Create with `create gitops_application -f app.yaml --cluster [--repo ]` — `-f` is the Argo CD Application manifest (metadata + spec); - cluster and repo are passed as flags. + cluster and repo are passed as flags. Sync with + `execute gitops_application:sync / [--revision ] [--prune] + [--dry-run]`. Refresh with + `execute gitops_application:refresh / [--hard]`. Update with + `update gitops_application / -f app.yaml` — export first with + `get gitops_application / --yaml -o app.yaml`, edit, then apply. + Pass `--cluster` / `--repo` only when changing Harness linkage validation. A **gitops-cluster** is a Kubernetes cluster registered with a GitOps agent. List with `list gitops_cluster`; get with `get gitops_cluster /`. @@ -390,6 +396,8 @@ commands: endpoint: path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/applications/{{ctx.idParts[1]}} item_expr: it + yaml_pick_expr: it.app + yaml_exclude: [status, operation] fields_subset: [name, agent, cluster, repo, project, namespace, sync_status, health, revision, repo_url, path, target_revision, created, updated] - command: create gitops_application @@ -421,6 +429,85 @@ commands: item_expr: it text_header: "\nCreated gitops application {{it.name}}\n\nGet it with: harness get gitops_application {{ctx.id}}/{{it.name}}\n" + - command: update gitops_application + verb: update + noun: gitops_application + id_parts: 2 + id_label: "" + short: "Update a GitOps application: harness update gitops_application -f app.yaml [--cluster ] [--repo ]" + handler_type: endpoint + flags: + - name: cluster + description: "GitOps cluster identifier (when changing cluster/destination binding)" + - name: repo + description: "GitOps repository identifier on the agent" + - name: skip-repo-validation + description: "Skip repository validation (use when repo is not registered on the agent)" + is_bool: true + endpoint: + method: PUT + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/applications/{{ctx.idParts[1]}} + file_body: required + update_body_wrap: application + query_params: + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + clusterIdentifier: 'flags.cluster != "" ? flags.cluster : nil' + repoIdentifier: 'flags.repo != "" ? flags.repo : nil' + skipRepoValidation: 'flags["skip-repo-validation"] ? "true" : nil' + item_expr: it + fields_subset: [name, agent, cluster, repo, namespace, sync_status, health, revision, repo_url, path, target_revision] + text_header: "\nUpdated gitops application {{ctx.id}}\n" + + - command: execute gitops_application:sync + verb: execute + noun: gitops_application + noun_variant: sync + id_parts: 2 + id_label: "" + confirm_mode: prompt + short: "Sync a GitOps application: harness execute gitops_application:sync [--revision ] [--prune] [--dry-run]" + handler_type: endpoint + flags: + - name: revision + description: Target revision to sync to (commit, branch, or tag) + - name: prune + description: Delete resources from the cluster that are not in git + is_bool: true + - name: dry-run + description: Simulate sync without applying changes + is_bool: true + endpoint: + method: POST + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/applications/{{ctx.idParts[1]}}/sync + body_params: + revision: 'flags.revision != "" ? flags.revision : nil' + prune: 'flags.prune ? true : nil' + dryRun: 'flags["dry-run"] ? true : nil' + item_expr: it + fields_subset: [name, agent, sync_status, health, revision] + text_header: "\nSync triggered for {{ctx.id}}\n" + + - command: execute gitops_application:refresh + verb: execute + noun: gitops_application + noun_variant: refresh + id_parts: 2 + id_label: "" + short: "Refresh a GitOps application: harness execute gitops_application:refresh [--hard]" + handler_type: endpoint + flags: + - name: hard + description: Hard refresh — invalidate manifest caches and force regeneration + is_bool: true + endpoint: + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/applications/{{ctx.idParts[1]}} + query_params: + query.refresh: 'flags.hard ? "hard" : "normal"' + item_expr: it + fields_subset: [name, agent, sync_status, health, revision] + text_header: "\nRefreshed {{ctx.id}}\n" + - command: list gitops_cluster verb: list noun: gitops_cluster From 3ee899b589a1a5cde85186017160ada2076cc23a Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Tue, 21 Jul 2026 14:44:58 +0530 Subject: [PATCH 07/11] Feat: Added the CRUD for gitop_cluster and delete for applications --- pkg/spec/gitops.spec.yaml | 128 +++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/pkg/spec/gitops.spec.yaml b/pkg/spec/gitops.spec.yaml index 5d459f9..bb030dc 100644 --- a/pkg/spec/gitops.spec.yaml +++ b/pkg/spec/gitops.spec.yaml @@ -47,9 +47,14 @@ help_text: | `update gitops_application / -f app.yaml` — export first with `get gitops_application / --yaml -o app.yaml`, edit, then apply. Pass `--cluster` / `--repo` only when changing Harness linkage validation. - + Delete with `delete gitops_application /` — use + `--propagation-policy foreground|background`, `--no-cascade`, or + `--remove-finalizers` for stuck apps. A **gitops-cluster** is a Kubernetes cluster registered with a GitOps agent. List with `list gitops_cluster`; get with `get gitops_cluster /`. + Register with `create gitops_cluster / -f cluster.yaml`. + Update with `update gitops_cluster / -f cluster.yaml`. + Delete with `delete gitops_cluster /`. A **gitops-repository** is a Git repo configured on a GitOps agent for manifest sources. List with `list gitops_repository`; get with @@ -459,6 +464,50 @@ commands: fields_subset: [name, agent, cluster, repo, namespace, sync_status, health, revision, repo_url, path, target_revision] text_header: "\nUpdated gitops application {{ctx.id}}\n" + - command: delete gitops_application + verb: delete + noun: gitops_application + id_parts: 2 + id_label: "" + confirm_mode: prompt + short: "Delete a GitOps application: harness delete gitops_application [--propagation-policy foreground|background] [--no-cascade] [--remove-finalizers] [--app-namespace ]" + long: | + Deletes an Argo CD Application on the agent. You will be prompted to confirm + unless --force is passed. + Deletion modes: + (default) Cascade delete using API defaults + --propagation-policy foreground Wait for all K8s child resources to be removed first + --propagation-policy background Remove the Application immediately; K8s cleanup is async + --no-cascade Remove only the Argo CD Application record; K8s resources stay running + Use --remove-finalizers only when the app is stuck and cannot be deleted. + handler_type: endpoint + flags: + - name: propagation-policy + description: "Cascade mode: foreground (wait for K8s cleanup) or background (async cleanup)" + completion_values: [foreground, background] + - name: no-cascade + description: Non-cascading delete — remove Argo app record only, leave K8s resources running + is_bool: true + - name: remove-finalizers + description: Strip existing finalizers before deletion (unblocks stuck apps) + is_bool: true + - name: app-namespace + description: Argo CD application namespace override (non-default namespace only) + endpoint: + method: DELETE + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/applications/{{ctx.idParts[1]}} + query_params: + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + request.cascade: 'flags["no-cascade"] ? "false" : (flags["propagation-policy"] != "" ? "true" : nil)' + request.propagationPolicy: 'flags["propagation-policy"] != "" ? flags["propagation-policy"] : nil' + request.appNamespace: 'flags["app-namespace"] != "" ? flags["app-namespace"] : nil' + options.removeExistingFinalizers: 'flags["remove-finalizers"] ? "true" : nil' + item_expr: it + fields_subset: [name, agent, namespace, sync_status, health] + text_header: "\nDeleted gitops application {{ctx.id}}\n" + + - command: execute gitops_application:sync verb: execute noun: gitops_application @@ -547,8 +596,85 @@ commands: endpoint: path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/clusters/{{ctx.idParts[1]}} item_expr: it + yaml_pick_expr: it.cluster + yaml_exclude: [connectionState, info, serverVersion, refreshRequestedAt] fields_subset: [identifier, name, agent, server, connection, org_id, project_id, created, updated] + - command: create gitops_cluster + verb: create + noun: gitops_cluster + id_parts: 2 + id_label: "" + short: "Register a cluster on a GitOps agent: harness create gitops_cluster -f cluster.yaml [--upsert]" + handler_type: endpoint + flags: + - name: upsert + description: Update existing cluster if identifier already exists + is_bool: true + endpoint: + method: POST + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/clusters + file_body: required + create_body_wrap: cluster + query_params: + identifier: ctx.idParts[1] + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + body_params: + upsert: 'flags.upsert ? true : nil' + item_expr: it + fields_subset: [identifier, name, agent, server, connection] + text_header: "\nCreated gitops cluster {{it.agentIdentifier}}/{{it.identifier}}\n\nGet it with: harness get gitops_cluster {{it.agentIdentifier}}/{{it.identifier}}\n" + + - command: update gitops_cluster + verb: update + noun: gitops_cluster + id_parts: 2 + id_label: "" + short: "Update a GitOps cluster: harness update gitops_cluster -f cluster.yaml [--force-update]" + handler_type: endpoint + flags: + - name: force-update + description: Force update even when server-side validation would reject changes + is_bool: true + endpoint: + method: PUT + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/clusters/{{ctx.idParts[1]}} + file_body: required + update_body_wrap: cluster + query_params: + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + forceUpdate: 'flags["force-update"] ? "true" : nil' + item_expr: it + fields_subset: [identifier, name, agent, server, connection] + text_header: "\nUpdated gitops cluster {{ctx.id}}\n" + + - command: delete gitops_cluster + verb: delete + noun: gitops_cluster + id_parts: 2 + id_label: "" + confirm_mode: prompt + short: "Delete a GitOps cluster: harness delete gitops_cluster [--force-delete] [--query-name ]" + handler_type: endpoint + flags: + - name: force-delete + description: Delete even if applications are still deployed on the cluster + is_bool: true + - name: query-name + description: Argo CD cluster display name (required if cluster was renamed from its Harness identifier) + endpoint: + method: DELETE + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/clusters/{{ctx.idParts[1]}} + query_params: + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + forceDelete: 'flags["force-delete"] ? "true" : nil' + query.name: 'flags["query-name"] != "" ? flags["query-name"] : nil' + item_expr: it + text_header: "\nDeleted gitops cluster {{ctx.id}}\n" + - command: list gitops_repository verb: list noun: gitops_repository From 0176d51ef139fec6c279f0d74a81ba032f6c188d Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Tue, 21 Jul 2026 15:42:32 +0530 Subject: [PATCH 08/11] Feat: Added CRUD for gitops repository, with credentials support --- pkg/spec/gitops.spec.yaml | 80 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/pkg/spec/gitops.spec.yaml b/pkg/spec/gitops.spec.yaml index bb030dc..e85949f 100644 --- a/pkg/spec/gitops.spec.yaml +++ b/pkg/spec/gitops.spec.yaml @@ -59,6 +59,11 @@ help_text: | A **gitops-repository** is a Git repo configured on a GitOps agent for manifest sources. List with `list gitops_repository`; get with `get gitops_repository /`. + Register with `create gitops_repository / -f repo.yaml`. + Update with `update gitops_repository / -f repo.yaml`. + Delete with `delete gitops_repository /`. + The `-f` file may contain credentials — protect file permissions and never + commit secrets. A **gitops-application-set** is an Argo CD ApplicationSet that generates multiple applications from a template. List with `list gitops_application_set`; @@ -714,8 +719,83 @@ commands: endpoint: path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/repositories/{{ctx.idParts[1]}} item_expr: it + yaml_pick_expr: it.repository + yaml_exclude: [connectionState, info, refreshRequestedAt] fields_subset: [identifier, name, agent, repo_url, type, connection, org_id, project_id, created, updated] + - command: create gitops_repository + verb: create + noun: gitops_repository + id_parts: 2 + id_label: "" + short: "Register a Git repo on a GitOps agent: harness create gitops_repository -f repo.yaml [--upsert] [--repo-creds-id ]" + handler_type: endpoint + flags: + - name: upsert + description: Update existing repository if identifier already exists + is_bool: true + - name: repo-creds-id + description: Link to an existing repository credential set instead of inline credentials in -f + endpoint: + method: POST + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/repositories + file_body: required + create_body_wrap: repo + query_params: + identifier: ctx.idParts[1] + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + repoCredsId: 'flags["repo-creds-id"] != "" ? flags["repo-creds-id"] : nil' + body_params: + upsert: 'flags.upsert ? true : nil' + item_expr: it + fields_subset: [identifier, name, agent, repo_url, type, connection] + text_header: "\nCreated gitops repository {{it.agentIdentifier}}/{{it.identifier}}\n\nGet it with: harness get gitops_repository {{it.agentIdentifier}}/{{it.identifier}}\n" + + - command: update gitops_repository + verb: update + noun: gitops_repository + id_parts: 2 + id_label: "" + short: "Update a GitOps repository: harness update gitops_repository -f repo.yaml" + handler_type: endpoint + endpoint: + method: PUT + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/repositories/{{ctx.idParts[1]}} + file_body: required + update_body_wrap: repo + query_params: + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + item_expr: it + fields_subset: [identifier, name, agent, repo_url, type, connection] + text_header: "\nUpdated gitops repository {{ctx.id}}\n" + + - command: delete gitops_repository + verb: delete + noun: gitops_repository + id_parts: 2 + id_label: "" + confirm_mode: prompt + short: "Delete a GitOps repository: harness delete gitops_repository [--force-delete] [--query-repo ]" + handler_type: endpoint + flags: + - name: force-delete + description: Force delete even if applications reference this repository + is_bool: true + - name: query-repo + description: Repo URL (when identifier alone is insufficient) + endpoint: + method: DELETE + path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/repositories/{{ctx.idParts[1]}} + query_params: + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + forceDelete: 'flags["force-delete"] ? "true" : nil' + query.repo: 'flags["query-repo"] != "" ? flags["query-repo"] : nil' + item_expr: it + text_header: "\nDeleted gitops repository {{ctx.id}}\n" + - command: list gitops_application_set verb: list noun: gitops_application_set From 5d380194e2e188a17eaa246a6e9ab5fd964c0d20 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Tue, 21 Jul 2026 16:50:48 +0530 Subject: [PATCH 09/11] Feat: CRUD functionality for application_set with testing --- pkg/spec/gitops.spec.yaml | 106 +++++++++++++++++++++++++++++++++++--- 1 file changed, 100 insertions(+), 6 deletions(-) diff --git a/pkg/spec/gitops.spec.yaml b/pkg/spec/gitops.spec.yaml index e85949f..730e4af 100644 --- a/pkg/spec/gitops.spec.yaml +++ b/pkg/spec/gitops.spec.yaml @@ -68,7 +68,12 @@ help_text: | A **gitops-application-set** is an Argo CD ApplicationSet that generates multiple applications from a template. List with `list gitops_application_set`; get with `get gitops_application_set /` — use the UUID from list - output, not the display name. + output, not the display name. Create with + `create gitops_application_set -f appset.yaml` (the server assigns + the UUID). Update with `update gitops_application_set / -f + appset.yaml` — export first with `get gitops_application_set / + --yaml -o appset.yaml`, edit, then apply. Delete with + `delete gitops_application_set /`. ### Nouns @@ -233,14 +238,14 @@ nouns: label: UUID expr: it.identifier - id: name - expr: 'it.name ?? (it.applicationset ?? {}).metadata.name' + expr: 'it.name ?? (it.appset ?? it.applicationset ?? {}).metadata.name' - id: agent expr: it.agentIdentifier - id: project - expr: '(it.applicationset ?? {}).spec.template.spec.project' + expr: '(it.appset ?? it.applicationset ?? {}).spec.template.spec.project' - id: generators label: Generators - expr: 'len((it.applicationset ?? {}).spec.generators ?? [])' + expr: 'len((it.appset ?? it.applicationset ?? {}).spec.generators ?? [])' align: right - id: org_id expr: it.orgIdentifier @@ -813,7 +818,7 @@ commands: get_id_expr: it.agentIdentifier + "/" + it.identifier completion: id_expr: it.agentIdentifier + "/" + it.identifier - name_expr: 'it.name ?? (it.applicationset ?? {}).metadata.name' + name_expr: 'it.name ?? (it.appset ?? it.applicationset ?? {}).metadata.name' body_params: accountIdentifier: auth.account orgIdentifier: 'auth.org != "" ? auth.org : nil' @@ -833,6 +838,95 @@ commands: id_parts: 2 id_label: "" endpoint: - path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/applicationsets/{{ctx.idParts[1]}} + path: /gitops/api/v1/applicationset/{{ctx.idParts[1]}} + query_params: + agentIdentifier: ctx.idParts[0] + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' item_expr: it + yaml_pick_expr: it.appset ?? it.applicationset + yaml_exclude: [status] fields_subset: [identifier, name, agent, project, generators, org_id, project_id, created, updated] + + - command: create gitops_application_set + verb: create + noun: gitops_application_set + requires_id: true + short: "Create a GitOps ApplicationSet: harness create gitops_application_set -f appset.yaml [--upsert] [--dry-run]" + handler_type: endpoint + flags: + - name: upsert + description: Update existing ApplicationSet if one with the same name already exists + is_bool: true + - name: dry-run + description: Validate the manifest without persisting changes + is_bool: true + endpoint: + method: POST + path: /gitops/api/v1/applicationset + file_body: required + create_body_wrap: applicationset + query_params: + agentIdentifier: ctx.id + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + body_params: + upsert: 'flags.upsert ? true : nil' + dryRun: 'flags["dry-run"] ? true : nil' + item_expr: it + fields_subset: [identifier, name, agent, project, generators] + text_header: "\nCreated gitops application set {{it.name}}\n\nGet it with: harness get gitops_application_set {{ctx.id}}/{{it.identifier}}\n" + + - command: update gitops_application_set + verb: update + noun: gitops_application_set + id_parts: 2 + id_label: "" + short: "Update a GitOps ApplicationSet: harness update gitops_application_set -f appset.yaml [--upsert] [--dry-run]" + long: | + Updates an ApplicationSet from an Argo CD manifest file. Export the current + manifest first with `get gitops_application_set / --yaml -o + appset.yaml`, edit the file, then apply. Edit spec/generators/template only; + do not strip metadata fields the API may require on PUT. + handler_type: endpoint + flags: + - name: upsert + description: Create the ApplicationSet if it does not exist + is_bool: true + - name: dry-run + description: Validate the manifest without persisting changes + is_bool: true + endpoint: + method: PUT + path: /gitops/api/v1/applicationset + file_body: required + update_body_wrap: applicationset + query_params: + agentIdentifier: ctx.idParts[0] + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + body_params: + upsert: 'flags.upsert ? true : nil' + dryRun: 'flags["dry-run"] ? true : nil' + item_expr: it + fields_subset: [identifier, name, agent, project, generators] + text_header: "\nUpdated gitops application set {{ctx.id}}\n" + + - command: delete gitops_application_set + verb: delete + noun: gitops_application_set + id_parts: 2 + id_label: "" + confirm_mode: prompt + short: "Delete a GitOps ApplicationSet: harness delete gitops_application_set " + handler_type: endpoint + endpoint: + method: DELETE + path: /gitops/api/v1/applicationset/{{ctx.idParts[1]}} + query_params: + agentIdentifier: ctx.idParts[0] + orgIdentifier: 'auth.org != "" ? auth.org : nil' + projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' + item_expr: it + fields_subset: [identifier, name, agent, project, generators] + text_header: "\nDeleted gitops application set {{ctx.id}}\n" From c56b0ebd08cb53ba05de3d08ca55ef0e15af74d9 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Tue, 21 Jul 2026 17:24:08 +0530 Subject: [PATCH 10/11] Fix: get-then-put strategy is used for most of the updates --- pkg/registry/endpoint.go | 4 +++ pkg/spec/gitops.spec.yaml | 68 +++++++++++++++++++++++++++++++++------ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/pkg/registry/endpoint.go b/pkg/registry/endpoint.go index d8ed012..c95fa14 100644 --- a/pkg/registry/endpoint.go +++ b/pkg/registry/endpoint.go @@ -750,6 +750,10 @@ func runGetThenUpdate(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, c *client.Client, return nil, err } + for _, key := range ep.YamlExclude { + delete(mutable, key) + } + var updateBody any = mutable if ep.UpdateBodyWrap != "" { updateBody = map[string]any{ep.UpdateBodyWrap: mutable} diff --git a/pkg/spec/gitops.spec.yaml b/pkg/spec/gitops.spec.yaml index 730e4af..f3533a8 100644 --- a/pkg/spec/gitops.spec.yaml +++ b/pkg/spec/gitops.spec.yaml @@ -147,6 +147,7 @@ nouns: expr: it.app.spec.project - id: namespace expr: it.app.spec.destination.namespace + mutable_path: spec.destination.namespace - id: sync_status label: Sync expr: it.app.status.sync.status @@ -159,10 +160,13 @@ nouns: label: Repo URL expr: it.app.spec.source.repoURL width_max: 60 + mutable_path: spec.source.repoURL - id: path expr: it.app.spec.source.path + mutable_path: spec.source.path - id: target_revision expr: it.app.spec.source.targetRevision + mutable_path: spec.source.targetRevision - id: created expr: it.createdAt ?? it.app.creationTimestampTs - id: updated @@ -203,14 +207,17 @@ nouns: expr: it.identifier - id: name expr: 'it.repository.name != "" ? it.repository.name : it.identifier' + mutable_path: name - id: agent expr: it.agentIdentifier - id: repo_url label: Repo URL expr: it.repository.repo width_max: 60 + mutable_path: repo - id: type expr: it.repository.type + mutable_path: type - id: connection label: Connection expr: it.repository.connectionState.status @@ -239,6 +246,7 @@ nouns: expr: it.identifier - id: name expr: 'it.name ?? (it.appset ?? it.applicationset ?? {}).metadata.name' + mutable_path: metadata.name - id: agent expr: it.agentIdentifier - id: project @@ -449,8 +457,20 @@ commands: noun: gitops_application id_parts: 2 id_label: "" - short: "Update a GitOps application: harness update gitops_application -f app.yaml [--cluster ] [--repo ]" + short: "Update a GitOps application: harness update gitops_application [-f app.yaml] [--set target_revision=...] [--cluster ] [--repo ]" + long: | + Two update modes: + --set Quick scalar edits (target_revision, path, namespace, repo_url). + Uses get-then-put — no file needed. + -f Full Argo CD Application manifest. Use for syncPolicy, labels, + multi-source, or structural changes. + + --cluster / --repo / --skip-repo-validation only affect Harness linkage + validation on PUT; omit them when not changing cluster/repo binding. handler_type: endpoint + flags_builtin: + set: true + del: true flags: - name: cluster description: "GitOps cluster identifier (when changing cluster/destination binding)" @@ -462,8 +482,11 @@ commands: endpoint: method: PUT path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/applications/{{ctx.idParts[1]}} - file_body: required + file_body: optional + update_strategy: get-then-put + update_body_pick: it.app update_body_wrap: application + yaml_exclude: [status, operation, creationTimestampTs] query_params: orgIdentifier: 'auth.org != "" ? auth.org : nil' projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' @@ -636,12 +659,18 @@ commands: fields_subset: [identifier, name, agent, server, connection] text_header: "\nCreated gitops cluster {{it.agentIdentifier}}/{{it.identifier}}\n\nGet it with: harness get gitops_cluster {{it.agentIdentifier}}/{{it.identifier}}\n" + + # an extra updateFields list has to be sent for the cluster update in the PUT body, this is not supported by the CLI yet. should we ask the user to provide the list? - command: update gitops_cluster verb: update noun: gitops_cluster id_parts: 2 id_label: "" short: "Update a GitOps cluster: harness update gitops_cluster -f cluster.yaml [--force-update]" + long: | + Cluster updates require a full manifest file (-f). Field-level --set is + not supported — the API requires an updatedFields array in the PUT body, + which the CLI does not populate. Export with get --yaml, edit, then apply. handler_type: endpoint flags: - name: force-update @@ -762,13 +791,23 @@ commands: noun: gitops_repository id_parts: 2 id_label: "" - short: "Update a GitOps repository: harness update gitops_repository -f repo.yaml" + short: "Update a GitOps repository: harness update gitops_repository [-f repo.yaml] [--set repo_url=...]" + long: | + Update via --set for URL, name, or type. Credential changes (password, + SSH key) require -f with a full manifest — GET masks secrets and cannot + round-trip them via --set. handler_type: endpoint + flags_builtin: + set: true + del: true endpoint: method: PUT path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/repositories/{{ctx.idParts[1]}} - file_body: required + file_body: optional + update_strategy: get-then-put + update_body_pick: it.repository update_body_wrap: repo + yaml_exclude: [connectionState, info, refreshRequestedAt, password] query_params: orgIdentifier: 'auth.org != "" ? auth.org : nil' projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' @@ -882,13 +921,18 @@ commands: noun: gitops_application_set id_parts: 2 id_label: "" - short: "Update a GitOps ApplicationSet: harness update gitops_application_set -f appset.yaml [--upsert] [--dry-run]" + short: "Update a GitOps ApplicationSet: harness update gitops_application_set [-f appset.yaml] [--set name=...] [--upsert] [--dry-run]" long: | - Updates an ApplicationSet from an Argo CD manifest file. Export the current - manifest first with `get gitops_application_set / --yaml -o - appset.yaml`, edit the file, then apply. Edit spec/generators/template only; - do not strip metadata fields the API may require on PUT. + Two update modes: + --set name=... Quick rename via get-then-put (preserves uid/project from GET). + -f appset.yaml Full manifest for generators, template, or spec changes. + Export first: get gitops_application_set / --yaml -o appset.yaml + + Structural edits (generators, template) require -f. handler_type: endpoint + flags_builtin: + set: true + del: true flags: - name: upsert description: Create the ApplicationSet if it does not exist @@ -899,8 +943,12 @@ commands: endpoint: method: PUT path: /gitops/api/v1/applicationset - file_body: required + get_path: /gitops/api/v1/applicationset/{{ctx.idParts[1]}} + file_body: optional + update_strategy: get-then-put + update_body_pick: it.appset ?? it.applicationset update_body_wrap: applicationset + yaml_exclude: [status] query_params: agentIdentifier: ctx.idParts[0] orgIdentifier: 'auth.org != "" ? auth.org : nil' From 7bbc291a0d3bb2037afc1c7ac71a154cecbe2b7a Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Tue, 21 Jul 2026 18:02:36 +0530 Subject: [PATCH 11/11] Revert "Fix: get-then-put strategy is used for most of the updates" This reverts commit c56b0ebd08cb53ba05de3d08ca55ef0e15af74d9. --- pkg/registry/endpoint.go | 4 --- pkg/spec/gitops.spec.yaml | 68 ++++++--------------------------------- 2 files changed, 10 insertions(+), 62 deletions(-) diff --git a/pkg/registry/endpoint.go b/pkg/registry/endpoint.go index c95fa14..d8ed012 100644 --- a/pkg/registry/endpoint.go +++ b/pkg/registry/endpoint.go @@ -750,10 +750,6 @@ func runGetThenUpdate(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, c *client.Client, return nil, err } - for _, key := range ep.YamlExclude { - delete(mutable, key) - } - var updateBody any = mutable if ep.UpdateBodyWrap != "" { updateBody = map[string]any{ep.UpdateBodyWrap: mutable} diff --git a/pkg/spec/gitops.spec.yaml b/pkg/spec/gitops.spec.yaml index f3533a8..730e4af 100644 --- a/pkg/spec/gitops.spec.yaml +++ b/pkg/spec/gitops.spec.yaml @@ -147,7 +147,6 @@ nouns: expr: it.app.spec.project - id: namespace expr: it.app.spec.destination.namespace - mutable_path: spec.destination.namespace - id: sync_status label: Sync expr: it.app.status.sync.status @@ -160,13 +159,10 @@ nouns: label: Repo URL expr: it.app.spec.source.repoURL width_max: 60 - mutable_path: spec.source.repoURL - id: path expr: it.app.spec.source.path - mutable_path: spec.source.path - id: target_revision expr: it.app.spec.source.targetRevision - mutable_path: spec.source.targetRevision - id: created expr: it.createdAt ?? it.app.creationTimestampTs - id: updated @@ -207,17 +203,14 @@ nouns: expr: it.identifier - id: name expr: 'it.repository.name != "" ? it.repository.name : it.identifier' - mutable_path: name - id: agent expr: it.agentIdentifier - id: repo_url label: Repo URL expr: it.repository.repo width_max: 60 - mutable_path: repo - id: type expr: it.repository.type - mutable_path: type - id: connection label: Connection expr: it.repository.connectionState.status @@ -246,7 +239,6 @@ nouns: expr: it.identifier - id: name expr: 'it.name ?? (it.appset ?? it.applicationset ?? {}).metadata.name' - mutable_path: metadata.name - id: agent expr: it.agentIdentifier - id: project @@ -457,20 +449,8 @@ commands: noun: gitops_application id_parts: 2 id_label: "" - short: "Update a GitOps application: harness update gitops_application [-f app.yaml] [--set target_revision=...] [--cluster ] [--repo ]" - long: | - Two update modes: - --set Quick scalar edits (target_revision, path, namespace, repo_url). - Uses get-then-put — no file needed. - -f Full Argo CD Application manifest. Use for syncPolicy, labels, - multi-source, or structural changes. - - --cluster / --repo / --skip-repo-validation only affect Harness linkage - validation on PUT; omit them when not changing cluster/repo binding. + short: "Update a GitOps application: harness update gitops_application -f app.yaml [--cluster ] [--repo ]" handler_type: endpoint - flags_builtin: - set: true - del: true flags: - name: cluster description: "GitOps cluster identifier (when changing cluster/destination binding)" @@ -482,11 +462,8 @@ commands: endpoint: method: PUT path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/applications/{{ctx.idParts[1]}} - file_body: optional - update_strategy: get-then-put - update_body_pick: it.app + file_body: required update_body_wrap: application - yaml_exclude: [status, operation, creationTimestampTs] query_params: orgIdentifier: 'auth.org != "" ? auth.org : nil' projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' @@ -659,18 +636,12 @@ commands: fields_subset: [identifier, name, agent, server, connection] text_header: "\nCreated gitops cluster {{it.agentIdentifier}}/{{it.identifier}}\n\nGet it with: harness get gitops_cluster {{it.agentIdentifier}}/{{it.identifier}}\n" - - # an extra updateFields list has to be sent for the cluster update in the PUT body, this is not supported by the CLI yet. should we ask the user to provide the list? - command: update gitops_cluster verb: update noun: gitops_cluster id_parts: 2 id_label: "" short: "Update a GitOps cluster: harness update gitops_cluster -f cluster.yaml [--force-update]" - long: | - Cluster updates require a full manifest file (-f). Field-level --set is - not supported — the API requires an updatedFields array in the PUT body, - which the CLI does not populate. Export with get --yaml, edit, then apply. handler_type: endpoint flags: - name: force-update @@ -791,23 +762,13 @@ commands: noun: gitops_repository id_parts: 2 id_label: "" - short: "Update a GitOps repository: harness update gitops_repository [-f repo.yaml] [--set repo_url=...]" - long: | - Update via --set for URL, name, or type. Credential changes (password, - SSH key) require -f with a full manifest — GET masks secrets and cannot - round-trip them via --set. + short: "Update a GitOps repository: harness update gitops_repository -f repo.yaml" handler_type: endpoint - flags_builtin: - set: true - del: true endpoint: method: PUT path: /gitops/api/v1/agents/{{ctx.idParts[0]}}/repositories/{{ctx.idParts[1]}} - file_body: optional - update_strategy: get-then-put - update_body_pick: it.repository + file_body: required update_body_wrap: repo - yaml_exclude: [connectionState, info, refreshRequestedAt, password] query_params: orgIdentifier: 'auth.org != "" ? auth.org : nil' projectIdentifier: 'ctx.level == "project" ? (auth.project != "" ? auth.project : nil) : nil' @@ -921,18 +882,13 @@ commands: noun: gitops_application_set id_parts: 2 id_label: "" - short: "Update a GitOps ApplicationSet: harness update gitops_application_set [-f appset.yaml] [--set name=...] [--upsert] [--dry-run]" + short: "Update a GitOps ApplicationSet: harness update gitops_application_set -f appset.yaml [--upsert] [--dry-run]" long: | - Two update modes: - --set name=... Quick rename via get-then-put (preserves uid/project from GET). - -f appset.yaml Full manifest for generators, template, or spec changes. - Export first: get gitops_application_set / --yaml -o appset.yaml - - Structural edits (generators, template) require -f. + Updates an ApplicationSet from an Argo CD manifest file. Export the current + manifest first with `get gitops_application_set / --yaml -o + appset.yaml`, edit the file, then apply. Edit spec/generators/template only; + do not strip metadata fields the API may require on PUT. handler_type: endpoint - flags_builtin: - set: true - del: true flags: - name: upsert description: Create the ApplicationSet if it does not exist @@ -943,12 +899,8 @@ commands: endpoint: method: PUT path: /gitops/api/v1/applicationset - get_path: /gitops/api/v1/applicationset/{{ctx.idParts[1]}} - file_body: optional - update_strategy: get-then-put - update_body_pick: it.appset ?? it.applicationset + file_body: required update_body_wrap: applicationset - yaml_exclude: [status] query_params: agentIdentifier: ctx.idParts[0] orgIdentifier: 'auth.org != "" ? auth.org : nil'