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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/verb-arch.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Operations that require client-side workflows and don't map to a single API call
| `push` | `artifact` | Upload requires local archive parsing and streaming |
| `pull` | `artifact` | Download with streaming progress and destination resolution |
| `configure` | `registry` | Local file system writes (`.npmrc` etc.) — no API call |
| `synth` | `workspace` | Remote CDK synth: upload local source, run the workspace synth pipeline |
| `diff` | `workspace` | Remote CDK diff: upload local source, run the workspace diff pipeline |

---

Expand Down
10 changes: 10 additions & 0 deletions docs/verbs.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ don't add `uninstall` to a spec file on the strength of this doc.
| `pull` | Download an artifact from a Harness registry to disk. | implemented (artifact only) |
| `install` | Place a binary or component into local state. | implemented |
| `configure` | Write durable local config pointing a third-party client at Harness. | implemented (registry only) |
| `synth` | Run a remote CDK synth on an awscdk IACM workspace from local source. | implemented (workspace only) |
| `diff` | Run a remote CDK diff on an awscdk IACM workspace from local source. | implemented (workspace only) |
| `convert` | Translate one representation into another. **Takes a noun pair.** Both endpoints documents. | proposed |
| `migrate` | Move a resource between two endpoints, at least one of them live. **Takes a noun pair.** | implemented |
| `uninstall` | Remove a locally installed component. Reverse of `install`. | proposed |
Expand Down Expand Up @@ -207,6 +209,14 @@ different questions ("set up my machine" vs. "run this one command correctly").

The boundary against `update`: `configure` never touches server state.

## `synth`

Upload local source to an awscdk IACM workspace and run its default synth pipeline (`cdk synth`). A module-approved verb for the `workspace` noun; the Terraform equivalent is `execute workspace`.

## `diff`

Same remote-execution path as `synth`, running the workspace's default diff pipeline (`cdk diff`).

---

## The two pair verbs
Expand Down
1 change: 1 addition & 0 deletions modules/code/insight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ func (s *moduleInitSpy) RegisterWorkflow(id string, _ registry.WorkflowFn) {
func (s *moduleInitSpy) RegisterTextFormatter(string, cmdctx.TextFormatterFn) {}
func (s *moduleInitSpy) RegisterBodyFn(string, cmdctx.CreateBodyFn) {}
func (s *moduleInitSpy) RegisterQueryParamsFn(string, cmdctx.QueryParamsFn) {}
func (s *moduleInitSpy) RegisterIdResolveFn(string, cmdctx.IdResolveFn) {}
func (s *moduleInitSpy) RegisterFollowFn(string, cmdctx.FollowFn) {}
func (s *moduleInitSpy) RegisterFetchFn(string, cmdctx.FetchFn) {}
func (s *moduleInitSpy) RegisterListTransformFn(string, cmdctx.ListTransformFn) {}
Expand Down
1 change: 1 addition & 0 deletions modules/gitops/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ func (s *moduleInitSpy) RegisterBodyFn(id string, _ cmdctx.CreateBodyFn) {
}
}
func (s *moduleInitSpy) RegisterQueryParamsFn(string, cmdctx.QueryParamsFn) {}
func (s *moduleInitSpy) RegisterIdResolveFn(string, cmdctx.IdResolveFn) {}
func (s *moduleInitSpy) RegisterFollowFn(string, cmdctx.FollowFn) {}
func (s *moduleInitSpy) RegisterFetchFn(string, cmdctx.FetchFn) {}
func (s *moduleInitSpy) RegisterListTransformFn(string, cmdctx.ListTransformFn) {}
Expand Down
3 changes: 3 additions & 0 deletions modules/iacm/iacm.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ import "github.com/harness/cli/v3/pkg/registry"
// ModuleInit registers iacm workflows. Commands are declared in iacm.spec.yaml.
func ModuleInit(reg registry.ModuleRegistrar) {
reg.RegisterWorkflow(executeWorkspaceHandlerID, executeWorkspaceHandler)
reg.RegisterWorkflow(executeWorkspaceSynthHandlerID, executeWorkspaceSynthHandler)
reg.RegisterWorkflow(executeWorkspaceDiffHandlerID, executeWorkspaceDiffHandler)
reg.RegisterIdResolveFn(resolveStateScopeID, resolveStateScope)
}
160 changes: 125 additions & 35 deletions modules/iacm/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
Expand All @@ -30,6 +31,17 @@ import (
)

const executeWorkspaceHandlerID = "execute_workspace"
const executeWorkspaceSynthHandlerID = "execute_workspace_synth"
const executeWorkspaceDiffHandlerID = "execute_workspace_diff"

// Remote execution operations. plan is the Terraform/OpenTofu default;
// synth and diff run the awscdk workspace's default pipeline of that name.
const (
operationPlan = "plan"
operationSynth = "synth"
operationDiff = "diff"
)
const resolveStateScopeID = "resolve_state_scope"

// workspaceConfig mirrors the .harness/workspace.yaml file.
type workspaceConfig struct {
Expand All @@ -39,12 +51,15 @@ type workspaceConfig struct {
}

type workspace struct {
Account string `json:"account"`
Org string `json:"org"`
Project string `json:"project"`
Identifier string `json:"identifier"`
RepositoryPath string `json:"repository_path,omitempty"`
DefaultPipelines map[string]*defaultPipelineOverride `json:"default_pipelines"`
Account string `json:"account"`
Org string `json:"org"`
Project string `json:"project"`
Identifier string `json:"identifier"`
RepositoryPath string `json:"repository_path,omitempty"`
// TerraformStateJSON is the id of the stored state file; empty until the
// workspace has been applied.
TerraformStateJSON string `json:"terraform_state_json,omitempty"`
DefaultPipelines map[string]*defaultPipelineOverride `json:"default_pipelines"`
}

type defaultPipelineOverride struct {
Expand All @@ -67,28 +82,30 @@ func (e *iacmAPIError) Error() string { return e.Message }
// ---- handler ----

func executeWorkspaceHandler(ctx *cmdctx.Ctx) error {
return runWorkspaceExecution(ctx, operationPlan)
}

func executeWorkspaceSynthHandler(ctx *cmdctx.Ctx) error {
return runWorkspaceExecution(ctx, operationSynth)
}

func executeWorkspaceDiffHandler(ctx *cmdctx.Ctx) error {
return runWorkspaceExecution(ctx, operationDiff)
}

func runWorkspaceExecution(ctx *cmdctx.Ctx, operation string) error {
a := ctx.Auth

workspaceID := ctx.Id
workspaceID := cmdctx.GetString(ctx.FlagValues, "workspace")
orgID := a.OrgID
projectID := a.ProjectID

if workspaceID == "" {
cfg, err := loadWorkspaceConfig()
if err != nil {
return err
}
workspaceID = cfg.Workspace
if cfg.Org != "" {
orgID = cfg.Org
}
if cfg.Project != "" {
projectID = cfg.Project
}
fmt.Printf("Using workspace from .harness/workspace.yaml: %s\n", workspaceID)
return errors.New("workspace is required: pass --workspace <id> or set workspace_id in .harness/workspace.yaml")
}
if workspaceID == "" {
return errors.New("workspace-id is required (pass as argument or set in .harness/workspace.yaml)")

// Fail before the source is zipped and uploaded.
if err := requireTTYForUI(ctx); err != nil {
return err
}

targets := cmdctx.GetStringSlice(ctx.FlagValues, "target")
Expand All @@ -106,7 +123,36 @@ func executeWorkspaceHandler(ctx *cmdctx.Ctx) error {
}()

hc := &http.Client{Timeout: 60 * time.Second}
return executePlan(bgCtx, ctx, hc, a, orgID, projectID, workspaceID, targets, replacements, force)
return executePlan(bgCtx, ctx, hc, a, orgID, projectID, workspaceID, operation, targets, replacements, force)
}

// resolveStateScope fills --workspace from .harness/workspace.yaml when the
// flag was omitted, and applies that file's org and project to the request.
func resolveStateScope(ctx *cmdctx.Ctx) error {
if cmdctx.GetString(ctx.FlagValues, "workspace") != "" {
return nil
}
cfg, err := loadWorkspaceConfig()
if err != nil {
return err
}
if cfg.Workspace == "" {
return errors.New("workspace is required: pass --workspace <id> or set workspace_id in .harness/workspace.yaml")
}
if ctx.FlagValues == nil {
ctx.FlagValues = map[string]any{}
}
ctx.FlagValues["workspace"] = cfg.Workspace
if ctx.Auth != nil {
if cfg.Org != "" {
ctx.Auth.OrgID = cfg.Org
}
if cfg.Project != "" {
ctx.Auth.ProjectID = cfg.Project
}
}
fmt.Printf("Using workspace from .harness/workspace.yaml: %s\n", cfg.Workspace)
return nil
}

func loadWorkspaceConfig() (*workspaceConfig, error) {
Expand All @@ -117,7 +163,7 @@ func loadWorkspaceConfig() (*workspaceConfig, error) {
data, err := os.ReadFile(filepath.Join(wd, ".harness", "workspace.yaml"))
if err != nil {
if os.IsNotExist(err) {
return nil, errors.New("workspace not found: pass a <workspace-id> argument or add .harness/workspace.yaml")
return nil, errors.New("workspace not found: pass --workspace <id> or add .harness/workspace.yaml")
}
return nil, fmt.Errorf("reading workspace config: %w", err)
}
Expand All @@ -133,7 +179,7 @@ func executePlan(
cmdCtx *cmdctx.Ctx,
hc *http.Client,
a *auth.ResolvedAuth,
orgID, projectID, workspaceID string,
orgID, projectID, workspaceID, operation string,
targets, replacements []string,
force bool,
) error {
Expand All @@ -144,7 +190,7 @@ func executePlan(
}
fmt.Printf("Workspace found: %s\n", ws.Identifier)

defaultPipeline, err := getDefaultPipeline(ws.DefaultPipelines)
defaultPipeline, err := getDefaultPipeline(ws.DefaultPipelines, operation)
if err != nil {
return err
}
Expand All @@ -165,7 +211,7 @@ func executePlan(
}

fmt.Println("Zipping source code...")
zipData, err := zipSourceCode(repoRoot)
zipData, err := zipSourceCode(repoRoot, operation)
if err != nil {
return fmt.Errorf("zipping source code: %w", err)
}
Expand All @@ -192,7 +238,7 @@ func executePlan(
}

fmt.Println("Triggering pipeline execution...")
exec, err = triggerRemoteExecution(bgCtx, hc, a, orgID, projectID, workspaceID, exec.ID)
exec, err = triggerRemoteExecution(bgCtx, hc, a, orgID, projectID, workspaceID, exec.ID, operation)
if err != nil {
return fmt.Errorf("triggering execution: %w", err)
}
Expand All @@ -206,6 +252,15 @@ func executePlan(
}
fmt.Printf("Pipeline execution: %s\n", execURL)

if cmdctx.GetBool(cmdCtx.FlagValues, "ui") {
return openLogViewer(cmdCtx, defaultPipeline, exec.PipelineExecutionID)
}
if !cmdctx.GetBool(cmdCtx.FlagValues, "follow") {
fmt.Printf("%s started. Watch the logs with: harness get execution_log %s/%s --follow\n",
operation, defaultPipeline, exec.PipelineExecutionID)
return nil
}

fmt.Println("\n=== Pipeline Execution Logs ===")
return logstream.FollowMulti(cmdCtx, exec.PipelineExecutionID, "", "", logstream.MultiStyleMarkers, map[string]bool{
"IACMIntegrationStageStepPMS": true,
Expand Down Expand Up @@ -329,8 +384,13 @@ func uploadRemoteExecution(ctx context.Context, hc *http.Client, a *auth.Resolve
return &exec, nil
}

func triggerRemoteExecution(ctx context.Context, hc *http.Client, a *auth.ResolvedAuth, org, project, workspaceID, execID string) (*remoteExecution, error) {
func triggerRemoteExecution(ctx context.Context, hc *http.Client, a *auth.ResolvedAuth, org, project, workspaceID, execID, operation string) (*remoteExecution, error) {
path := fmt.Sprintf("/gateway/iacm/api/orgs/%s/projects/%s/workspaces/%s/remote-executions/%s/execute", org, project, workspaceID, execID)
// plan is the server default for Terraform/OpenTofu; only send the
// parameter for CDK operations so older servers keep working.
if operation != operationPlan {
path += "?operation=" + url.QueryEscape(operation)
}
var exec remoteExecution
if err := doIACM(ctx, hc, a, "POST", path, nil, &exec); err != nil {
return nil, err
Expand All @@ -340,18 +400,19 @@ func triggerRemoteExecution(ctx context.Context, hc *http.Client, a *auth.Resolv

// ---- workspace helpers ----

func getDefaultPipeline(pipelines map[string]*defaultPipelineOverride) (string, error) {
dp, ok := pipelines["plan"]
if !ok {
return "", errors.New("workspace has no default pipeline configured for 'plan'")
func getDefaultPipeline(pipelines map[string]*defaultPipelineOverride, operation string) (string, error) {
noPipeline := fmt.Errorf("workspace has no default pipeline configured for '%s'", operation)
dp, ok := pipelines[operation]
if !ok || dp == nil {
return "", noPipeline
}
if dp.WorkspacePipeline != nil {
return *dp.WorkspacePipeline, nil
}
if dp.ProjectPipeline != nil {
return *dp.ProjectPipeline, nil
}
return "", errors.New("workspace has no default pipeline configured for 'plan'")
return "", noPipeline
}

func resolveRepoRoot(wd string, ws *workspace) (string, string, error) {
Expand All @@ -375,7 +436,11 @@ func resolveRepoRoot(wd string, ws *workspace) (string, string, error) {
return wd, fmt.Sprintf("Folder path: %s\nUploading: %s", repoPath, wd), nil
}

func zipSourceCode(root string) ([]byte, error) {
// cdkSkippedDirs are dependency and build output folders that the remote
// pipeline regenerates, so synth and diff uploads leave them out.
var cdkSkippedDirs = map[string]bool{"node_modules": true, "cdk.out": true}

func zipSourceCode(root, operation string) ([]byte, error) {
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
Expand All @@ -391,6 +456,9 @@ func zipSourceCode(root string) ([]byte, error) {
if fi.IsDir() && fi.Name() == ".git" {
return filepath.SkipDir
}
if fi.IsDir() && operation != operationPlan && cdkSkippedDirs[fi.Name()] {
return filepath.SkipDir
}
if fi.IsDir() {
return nil
}
Expand Down Expand Up @@ -422,3 +490,25 @@ func zipSourceCode(root string) ([]byte, error) {
}
return buf.Bytes(), nil
}

// requireTTYForUI rejects --ui when there is no terminal to draw it in.
func requireTTYForUI(ctx *cmdctx.Ctx) error {
if cmdctx.GetBool(ctx.FlagValues, "ui") && !console.IsBothTTY() {
return errors.New("--ui requires an interactive terminal (both stdin and stdout must be a TTY)")
}
return nil
}

// pipelineLogHandlerID is the pipeline module's "get execution_log" workflow.
const pipelineLogHandlerID = "pipeline:get_pipeline_log"

// openLogViewer opens the interactive log viewer that
// "harness get execution_log <pipeline>/<execution-id> --ui" shows.
func openLogViewer(ctx *cmdctx.Ctx, pipelineID, executionID string) error {
ctx.Id = pipelineID + "/" + executionID
ctx.FlagValues = map[string]any{"ui": true}
if err := ctx.Resolver.RunUIHandler(ctx, pipelineLogHandlerID); err != nil {
return fmt.Errorf("opening the log viewer: %w (watch the run with: harness get execution_log %s --ui)", err, ctx.Id)
}
return nil
}
Loading
Loading