diff --git a/internal/commands/hcp/hcp.go b/internal/commands/hcp/hcp.go index 51691ded..da47011b 100644 --- a/internal/commands/hcp/hcp.go +++ b/internal/commands/hcp/hcp.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/hcp/internal/commands/organizations" "github.com/hashicorp/hcp/internal/commands/profile" "github.com/hashicorp/hcp/internal/commands/projects" + "github.com/hashicorp/hcp/internal/commands/waypoint" "github.com/hashicorp/hcp/internal/pkg/cmd" ) @@ -22,6 +23,7 @@ func NewCmdHcp(ctx *cmd.Context) *cmd.Command { c.AddChild(profile.NewCmdProfile(ctx)) c.AddChild(organizations.NewCmdOrganizations(ctx)) c.AddChild(iam.NewCmdIam(ctx)) + c.AddChild(waypoint.NewCmdIam(ctx)) // Configure the command as the root command. cmd.ConfigureRootCommand(ctx, c) diff --git a/internal/commands/waypoint/agent/agent.go b/internal/commands/waypoint/agent/agent.go new file mode 100644 index 00000000..964544a7 --- /dev/null +++ b/internal/commands/waypoint/agent/agent.go @@ -0,0 +1,25 @@ +package agent + +import ( + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/heredoc" +) + +func NewCmdAgent(ctx *cmd.Context) *cmd.Command { + cmd := &cmd.Command{ + Name: "agent", + ShortHelp: "Waypoint Agent.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ Bold "waypoint agent" }} command group allows you to run and manage a local Waypoint + agent. + + Agents are used in conjunction with HCP Waypoint Actions to allow actions to run on your + own systems when initiated from HCP Waypoint. + `), + } + + cmd.AddChild(NewCmdRun(ctx)) + cmd.AddChild(NewCmdQueue(ctx)) + cmd.AddChild(NewCmdGroup(ctx)) + return cmd +} diff --git a/internal/commands/waypoint/agent/group.go b/internal/commands/waypoint/agent/group.go new file mode 100644 index 00000000..1fbd867e --- /dev/null +++ b/internal/commands/waypoint/agent/group.go @@ -0,0 +1,239 @@ +package agent + +import ( + "context" + "fmt" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-waypoint-service/preview/2023-08-18/client/waypoint_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-waypoint-service/preview/2023-08-18/models" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" + "github.com/pkg/errors" +) + +type GroupOpts struct { + Ctx context.Context + Profile *profile.Profile + IO iostreams.IOStreams + Output *format.Outputter + + WS waypoint_service.ClientService + + Name string + Description string +} + +func NewCmdGroup(ctx *cmd.Context) *cmd.Command { + opts := &GroupOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + Output: ctx.Output, + WS: waypoint_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "group", + ShortHelp: "Manage HCP Waypoint Agent groups.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ Bold "waypoint agent group" }} commands manage agent grousp. + `), + } + + cmd.AddChild(NewCmdGroupCreate(ctx, opts)) + cmd.AddChild(NewCmdGroupList(ctx, opts)) + cmd.AddChild(NewCmdGroupDelete(ctx, opts)) + + return cmd +} + +func NewCmdGroupCreate(ctx *cmd.Context, opts *GroupOpts) *cmd.Command { + cmd := &cmd.Command{ + Name: "create", + ShortHelp: "Create a new HCP Waypoint Agent group.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ Bold "waypoint agent group create" }} command creates a new Agent group. + `), + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "name", + Shorthand: "n", + DisplayValue: "NAME", + Description: "Name for the new group.", + Value: flagvalue.Simple("", &opts.Name), + Required: true, + }, + { + Name: "description", + Shorthand: "d", + DisplayValue: "TEXT", + Description: "Description for the group.", + Value: flagvalue.Simple("", &opts.Description), + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + return agentGroupCreate(c.Logger(), opts) + }, + } + + return cmd +} + +func agentGroupCreate(log hclog.Logger, opts *GroupOpts) error { + resp, err := opts.WS.WaypointServiceGetNamespace(&waypoint_service.WaypointServiceGetNamespaceParams{ + LocationOrganizationID: opts.Profile.OrganizationID, + LocationProjectID: opts.Profile.ProjectID, + Context: opts.Ctx, + }, nil) + if err != nil { + return errors.Wrapf(err, "Unable to access HCP project") + } + + ns := resp.Payload.Namespace + + ctx := opts.Ctx + + _, err = opts.WS.WaypointServiceCreateAgentGroup(&waypoint_service.WaypointServiceCreateAgentGroupParams{ + NamespaceID: ns.ID, + Body: &models.HashicorpCloudWaypointWaypointServiceCreateAgentGroupBody{ + Description: opts.Description, + Name: opts.Name, + }, + Context: ctx, + }, nil) + + if err != nil { + fmt.Fprintf(opts.IO.Err(), "Error listing groups: %s", err) + return err + } + + return nil +} + +func NewCmdGroupDelete(ctx *cmd.Context, opts *GroupOpts) *cmd.Command { + cmd := &cmd.Command{ + Name: "delete", + ShortHelp: "Delete a HCP Waypoint Agent group.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ Bold "waypoint agent group delete" }} command delete an Agent group. + `), + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "name", + Shorthand: "n", + DisplayValue: "NAME", + Description: "Name for the new group.", + Value: flagvalue.Simple("", &opts.Name), + Required: true, + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + return agentGroupDelete(c.Logger(), opts) + }, + } + + return cmd +} + +func agentGroupDelete(log hclog.Logger, opts *GroupOpts) error { + resp, err := opts.WS.WaypointServiceGetNamespace(&waypoint_service.WaypointServiceGetNamespaceParams{ + LocationOrganizationID: opts.Profile.OrganizationID, + LocationProjectID: opts.Profile.ProjectID, + Context: opts.Ctx, + }, nil) + if err != nil { + return errors.Wrapf(err, "Unable to access HCP project") + } + + ns := resp.Payload.Namespace + + ctx := opts.Ctx + + _, err = opts.WS.WaypointServiceDeleteAgentGroup(&waypoint_service.WaypointServiceDeleteAgentGroupParams{ + Name: opts.Name, + NamespaceID: ns.ID, + Context: ctx, + }, nil) + + if err != nil { + fmt.Fprintf(opts.IO.Err(), "Error listing groups: %s", err) + return err + } + + fmt.Fprintf(opts.IO.Out(), "Group deleted") + return nil +} + +func NewCmdGroupList(ctx *cmd.Context, opts *GroupOpts) *cmd.Command { + cmd := &cmd.Command{ + Name: "list", + ShortHelp: "List HCP Waypoint Agent groups.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ Bold "waypoint agent group list" }} command lists groups registered. + `), + RunF: func(c *cmd.Command, args []string) error { + return agentGroupList(c.Logger(), opts) + }, + } + + return cmd +} + +func agentGroupList(log hclog.Logger, opts *GroupOpts) error { + resp, err := opts.WS.WaypointServiceGetNamespace(&waypoint_service.WaypointServiceGetNamespaceParams{ + LocationOrganizationID: opts.Profile.OrganizationID, + LocationProjectID: opts.Profile.ProjectID, + Context: opts.Ctx, + }, nil) + if err != nil { + return errors.Wrapf(err, "Unable to access HCP project") + } + + ns := resp.Payload.Namespace + + ctx := opts.Ctx + + list, err := opts.WS.WaypointServiceListAgentGroups(&waypoint_service.WaypointServiceListAgentGroupsParams{ + NamespaceID: ns.ID, + Context: ctx, + }, nil) + + if err != nil { + fmt.Fprintf(opts.IO.Err(), "Error listing groups: %s", err) + return err + } + + return opts.Output.Display(groupsDisplayer(list.Payload.Groups)) +} + +type groupsDisplayer []*models.HashicorpCloudWaypointAgentGroup + +func (d groupsDisplayer) DefaultFormat() format.Format { + return format.Table +} + +func (d groupsDisplayer) Payload() any { + return d +} + +func (d groupsDisplayer) FieldTemplates() []format.Field { + return []format.Field{ + { + Name: "Name", + ValueFormat: "{{ .Name }}", + }, + { + Name: "Description", + ValueFormat: "{{ .Description }}", + }, + } +} diff --git a/internal/commands/waypoint/agent/queue.go b/internal/commands/waypoint/agent/queue.go new file mode 100644 index 00000000..55b5e39e --- /dev/null +++ b/internal/commands/waypoint/agent/queue.go @@ -0,0 +1,128 @@ +package agent + +import ( + "context" + "fmt" + "time" + + "github.com/go-openapi/strfmt" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-waypoint-service/preview/2023-08-18/client/waypoint_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-waypoint-service/preview/2023-08-18/models" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" + "github.com/pkg/errors" +) + +type QueueOpts struct { + Ctx context.Context + Profile *profile.Profile + IO iostreams.IOStreams + Output *format.Outputter + + WS waypoint_service.ClientService + + Group string + Id string + Body string + + ActionConfigId string +} + +func NewCmdQueue(ctx *cmd.Context) *cmd.Command { + opts := &QueueOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + Output: ctx.Output, + WS: waypoint_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "queue", + ShortHelp: "Queue an operation for an agent to execute.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ Bold "waypoint agent queue" }} command queues an operation for an agent to run. + `), + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "id", + Shorthand: "i", + DisplayValue: "ID", + Description: "Id of the operation to run.", + Value: flagvalue.Simple("", &opts.Id), + Required: true, + }, + { + Name: "body", + Shorthand: "d", + DisplayValue: "JSON", + Description: "JSON to pass to operation.", + Value: flagvalue.Simple("", &opts.Body), + }, + { + Name: "action-config", + DisplayValue: "ID", + Description: "Action config to associate operation with.", + Value: flagvalue.Simple("", &opts.ActionConfigId), + }, + { + Name: "group", + Shorthand: "g", + DisplayValue: "NAME", + Description: "Agent group to run for operations on.", + Value: flagvalue.Simple("", &opts.Group), + Required: true, + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + return agentQueue(c.Logger(), opts) + }, + } + + return cmd +} + +var agentQueueDuration = 60 * time.Second + +func agentQueue(log hclog.Logger, opts *QueueOpts) error { + resp, err := opts.WS.WaypointServiceGetNamespace(&waypoint_service.WaypointServiceGetNamespaceParams{ + LocationOrganizationID: opts.Profile.OrganizationID, + LocationProjectID: opts.Profile.ProjectID, + Context: opts.Ctx, + }, nil) + if err != nil { + return errors.Wrapf(err, "Unable to access HCP project") + } + + ns := resp.Payload.Namespace + + ctx := opts.Ctx + + _, err = opts.WS.WaypointServiceQueueAgentOperation(&waypoint_service.WaypointServiceQueueAgentOperationParams{ + NamespaceID: ns.ID, + Body: &models.HashicorpCloudWaypointWaypointServiceQueueAgentOperationBody{ + Operation: &models.HashicorpCloudWaypointAgentOperation{ + ID: opts.Id, + ActionConfigID: opts.ActionConfigId, + Body: strfmt.Base64(opts.Body), + Group: opts.Group, + }, + }, + Context: ctx, + }, nil) + + if err != nil { + fmt.Fprintf(opts.IO.Err(), "Error queueing operation: %s", err) + return err + } + + fmt.Fprintf(opts.IO.Out(), "Operation '%s' queued.", opts.Id) + return nil +} diff --git a/internal/commands/waypoint/agent/run.go b/internal/commands/waypoint/agent/run.go new file mode 100644 index 00000000..c6f375bc --- /dev/null +++ b/internal/commands/waypoint/agent/run.go @@ -0,0 +1,227 @@ +package agent + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-waypoint-service/preview/2023-08-18/client/waypoint_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-waypoint-service/preview/2023-08-18/models" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" + waypointagent "github.com/hashicorp/hcp/internal/pkg/waypoint-agent" + "github.com/pkg/errors" +) + +type RunOpts struct { + Ctx context.Context + Profile *profile.Profile + IO iostreams.IOStreams + Output *format.Outputter + + WS waypoint_service.ClientService + Groups []string + + ConfigPath string + Config *waypointagent.Config +} + +func NewCmdRun(ctx *cmd.Context) *cmd.Command { + opts := &RunOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + Output: ctx.Output, + WS: waypoint_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "run", + ShortHelp: "Waypoint Agent.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ Bold "waypoint agent run" }} commands executes a local Waypoint agent. + `), + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "config", + Shorthand: "c", + DisplayValue: "PATH", + Description: "Path to configuration file for agent.", + Value: flagvalue.Simple("agent.hcl", &opts.ConfigPath), + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + return agentRun(c.Logger(), opts) + }, + } + + return cmd +} + +var agentRunDuration = 60 * time.Second + +func agentRun(log hclog.Logger, opts *RunOpts) error { + cfg, err := waypointagent.ParseConfigFile(opts.ConfigPath) + if err != nil { + return err + } + + opts.Groups = cfg.Groups() + + resp, err := opts.WS.WaypointServiceGetNamespace(&waypoint_service.WaypointServiceGetNamespaceParams{ + LocationOrganizationID: opts.Profile.OrganizationID, + LocationProjectID: opts.Profile.ProjectID, + Context: opts.Ctx, + }, nil) + if err != nil { + return errors.Wrapf(err, "Unable to access HCP project") + } + + ns := resp.Payload.Namespace + + ctx := opts.Ctx + + // check the groups! + resp2, err := opts.WS.WaypointServiceValidateAgentGroups(&waypoint_service.WaypointServiceValidateAgentGroupsParams{ + NamespaceID: ns.ID, + Body: &models.HashicorpCloudWaypointWaypointServiceValidateAgentGroupsBody{ + Groups: opts.Groups, + }, + Context: opts.Ctx, + }, nil) + if err != nil { + return errors.Wrapf(err, "Error validating agent group names") + } + + if len(resp2.Payload.UnknownGroups) > 0 { + fmt.Fprintf(opts.IO.Err(), "Unknown agent groups detected:\n") + + for _, g := range resp2.Payload.UnknownGroups { + fmt.Fprintf(opts.IO.Err(), " %s\n ", g) + } + return nil + } + + retry := time.NewTimer(agentRunDuration) + defer retry.Stop() + + log.Info("Waypoint agent initialized", + "hcp-org", opts.Profile.OrganizationID, + "hcp-project", opts.Profile.ProjectID, + "waypoint-namespace", ns.ID, + "groups", opts.Groups, + ) + + exec := &waypointagent.Executor{ + Log: log, + Config: cfg, + } + + for { + opCfg, err := opts.WS.WaypointServiceRetrieveAgentOperation(&waypoint_service.WaypointServiceRetrieveAgentOperationParams{ + Body: &models.HashicorpCloudWaypointWaypointServiceRetrieveAgentOperationBody{ + Groups: opts.Groups, + }, + NamespaceID: ns.ID, + Context: ctx, + }, nil) + + if err != nil { + log.Error("error reading agent operation", "error", err) + } else if ao := opCfg.Payload.Operation; ao != nil { + runOp(log, ctx, opts, ao, exec, ns.ID) + } + + retry.Reset(agentRunDuration) + + select { + case <-opts.Ctx.Done(): + return nil + case <-retry.C: + // ok + } + } +} + +func runOp( + log hclog.Logger, + ctx context.Context, + opts *RunOpts, + ao *models.HashicorpCloudWaypointAgentOperation, + exec *waypointagent.Executor, + ns string, +) { + + var ( + status string + statusCode int + ) + + if ao.ActionConfigID != "" { + log.Info("reporting action run starting", "action-config-id", ao.ActionConfigID) + + resp, err := opts.WS.WaypointServiceStartingAction(&waypoint_service.WaypointServiceStartingActionParams{ + NamespaceID: ns, + Body: &models.HashicorpCloudWaypointWaypointServiceStartingActionBody{ + ActionConfigID: ao.ActionConfigID, + GroupName: ao.Group, + }, + Context: ctx, + }, nil) + + if err != nil { + log.Error("unable to register action as starting", "error", err) + } else { + defer func() { + log.Info("reporting action run ended", "id", resp.Payload.RunID, "status", status, "status-code", statusCode) + + opts.WS.WaypointServiceEndingAction(&waypoint_service.WaypointServiceEndingActionParams{ + NamespaceID: ns, + Body: &models.HashicorpCloudWaypointWaypointServiceEndingActionBody{ + RunID: resp.Payload.RunID, + FinalStatus: status, + StatusCode: int32(statusCode), + }, + Context: ctx, + }, nil) + }() + } + } + + ok, err := exec.IsAvailable(ao) + if err != nil { + status = "internal error: " + err.Error() + statusCode = 1 + + log.Error("error resolving operation", "group", ao.Group, "operation", ao.ID, "error", err) + return + } + + if !ok { + status = "unknown operation: " + ao.ID + statusCode = 127 + + log.Error("requested unknown operation", "id", ao.ID) + return + } + + opStat, err := exec.Execute(ctx, ao) + if err != nil { + status = "error execution operation: " + err.Error() + + log.Error("error executing operation", "group", ao.Group, "operation", ao.ID, "error", err) + return + } + + status = opStat.Status + statusCode = opStat.Code + + log.Info("finished operation", "id", ao.ID, "status", status, "status-code", statusCode) +} diff --git a/internal/commands/waypoint/waypoint.go b/internal/commands/waypoint/waypoint.go new file mode 100644 index 00000000..5a6489d7 --- /dev/null +++ b/internal/commands/waypoint/waypoint.go @@ -0,0 +1,21 @@ +package waypoint + +import ( + "github.com/hashicorp/hcp/internal/commands/waypoint/agent" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/heredoc" +) + +func NewCmdIam(ctx *cmd.Context) *cmd.Command { + cmd := &cmd.Command{ + Name: "waypoint", + ShortHelp: "HCP Waypoint.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ Bold "hcp waypoint" }} is for HCP Waypoint. + `), + } + + cmd.AddChild(agent.NewCmdAgent(ctx)) + + return cmd +} diff --git a/internal/pkg/waypoint-agent/compound_operation.go b/internal/pkg/waypoint-agent/compound_operation.go new file mode 100644 index 00000000..fd47cad0 --- /dev/null +++ b/internal/pkg/waypoint-agent/compound_operation.go @@ -0,0 +1,22 @@ +package waypointagent + +import ( + "context" + + "github.com/hashicorp/go-hclog" +) + +type CompoundOperation struct { + Operations []Operation +} + +func (c *CompoundOperation) Run(ctx context.Context, log hclog.Logger) (OperationStatus, error) { + for _, op := range c.Operations { + code, err := op.Run(ctx, log) + if err != nil { + return code, err + } + } + + return cleanStatus, nil +} diff --git a/internal/pkg/waypoint-agent/config.go b/internal/pkg/waypoint-agent/config.go new file mode 100644 index 00000000..715cdb61 --- /dev/null +++ b/internal/pkg/waypoint-agent/config.go @@ -0,0 +1,401 @@ +package waypointagent + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/buildkite/shellwords" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/hcl/v2/hclsimple" + "github.com/zclconf/go-cty/cty" +) + +type Config struct { + groupNames []string + groups map[string]*hclGroup + + opWrapper func(Operation) Operation +} + +func ParseConfig(input string) (*Config, error) { + var hc hclConfig + + var ctx hcl.EvalContext + + err := hclsimple.Decode("blah.hcl", []byte(input), &ctx, &hc) + if err != nil { + return nil, err + } + + var cfg Config + cfg.groups = make(map[string]*hclGroup) + cfg.opWrapper = func(o Operation) Operation { return o } + + for _, grp := range hc.Groups { + cfg.groupNames = append(cfg.groupNames, grp.Name) + cfg.groups[grp.Name] = grp + } + + sort.Strings(cfg.groupNames) + + return &cfg, nil +} + +func ParseConfigFile(path string) (*Config, error) { + var hc hclConfig + + var ctx hcl.EvalContext + + input, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + err = hclsimple.Decode(path, input, &ctx, &hc) + if err != nil { + return nil, err + } + + var cfg Config + cfg.groups = make(map[string]*hclGroup) + cfg.opWrapper = func(o Operation) Operation { return o } + + for _, grp := range hc.Groups { + cfg.groupNames = append(cfg.groupNames, grp.Name) + cfg.groups[grp.Name] = grp + } + + sort.Strings(cfg.groupNames) + + return &cfg, nil +} + +func (c *Config) Groups() []string { + return c.groupNames +} + +func (c *Config) IsAvailable(group, id string) (bool, error) { + grp, ok := c.groups[group] + if !ok { + return false, nil + } + + for _, act := range grp.Actions { + if act.Name == id { + return true, nil + } + } + + return false, nil +} + +func (c *Config) Action(group, id string, hctx *hcl.EvalContext) (Operation, error) { + grp, ok := c.groups[group] + if !ok { + return nil, nil + } + + if hctx == nil { + hctx = &hcl.EvalContext{} + } + + for _, act := range grp.Actions { + if act.Name == id { + return c.convertAction(hctx, act.Body) + } + } + + return nil, nil +} + +var actionSchema = hcl.BodySchema{ + Blocks: []hcl.BlockHeaderSchema{ + { + Type: "run", + }, + { + Type: "http", + }, + { + Type: "status", + }, + { + Type: "operation", + }, + }, +} + +func (c *Config) convertAction(hctx *hcl.EvalContext, body hcl.Body) (Operation, error) { + content, diag := body.Content(&actionSchema) + if diag.HasErrors() { + return nil, diag + } + + for _, blk := range content.Blocks { + switch blk.Type { + case "run": + return c.convertRun(hctx, blk) + case "http": + return c.convertHTTPAction(hctx, blk) + case "status": + return c.convertStatus(hctx, blk) + } + } + + // See if there operations + + var ops []*hcl.Block + + for _, blk := range content.Blocks { + if blk.Type == "operation" { + ops = append(ops, blk) + } + } + + if len(ops) > 0 { + return c.convertCompound(hctx, ops) + } + + return nil, fmt.Errorf("no operation specified") +} + +func (c *Config) convertCompound(hctx *hcl.EvalContext, blks []*hcl.Block) (Operation, error) { + var co CompoundOperation + + for _, blk := range blks { + op, err := c.convertAction(hctx, blk.Body) + if err != nil { + return nil, err + } + + co.Operations = append(co.Operations, op) + } + + return c.opWrapper(&co), nil +} + +var runActionSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "command", + Required: true, + }, + { + Name: "env", + }, + }, + Blocks: []hcl.BlockHeaderSchema{ + { + Type: "docker", + }, + }, +} + +const posixSpecialChars = "!\"#$&'()*,;<=>?[]\\^`{}|~" + +func (c *Config) convertRun(hctx *hcl.EvalContext, blk *hcl.Block) (Operation, error) { + body, diag := blk.Body.Content(runActionSchema) + if diag.HasErrors() { + return nil, diag + } + + val, diag := body.Attributes["command"].Expr.Value(hctx) + if diag.HasErrors() { + return nil, diag + } + + var ( + words []string + err error + ) + + if val.Type() == cty.String { + str := val.AsString() + + if strings.ContainsAny(str, posixSpecialChars) { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "sh" + } + + words = append(words, shell, "-c", str) + } else { + words, err = shellwords.Split(val.AsString()) + if err != nil { + return nil, err + } + } + } else if val.Type().IsTupleType() { + for _, v := range val.AsValueSlice() { + switch v.Type() { + case cty.String: + words = append(words, v.AsString()) + case cty.Number: + words = append(words, v.AsBigFloat().String()) + default: + return nil, fmt.Errorf("unsupported value type in arguments: %s", v.Type().GoString()) + } + } + } + + var do *DockerOptions + + if blks, ok := body.Blocks.ByType()["docker"]; ok { + blk := blks[0] + + do = &DockerOptions{} + + diag := gohcl.DecodeBody(blk.Body, hctx, do) + if diag.HasErrors() { + return nil, diag + } + } + + env := map[string]string{} + + if v, ok := body.Attributes["env"]; ok { + diag := gohcl.DecodeExpression(v.Expr, hctx, &env) + if diag.HasErrors() { + return nil, diag + } + } + + return c.opWrapper(&ShellOperation{ + Arguments: words, + Environment: env, + DockerOptions: do, + }), nil +} + +var httpActionSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "url", + Required: true, + }, + }, +} + +func (c *Config) convertHTTPAction(hctx *hcl.EvalContext, blk *hcl.Block) (Operation, error) { + body, diag := blk.Body.Content(httpActionSchema) + if diag.HasErrors() { + return nil, diag + } + + val, diag := body.Attributes["url"].Expr.Value(hctx) + if diag.HasErrors() { + return nil, diag + } + + if val.Type() != cty.String { + return nil, fmt.Errorf("url must be a string") + } + + return c.opWrapper(&HTTPOperation{ + URL: val.AsString(), + }), nil +} + +var statusSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "message", + Required: true, + }, + { + Name: "values", + }, + { + Name: "status", + }, + }, +} + +func (c *Config) convertStatus(hctx *hcl.EvalContext, blk *hcl.Block) (Operation, error) { + body, diag := blk.Body.Content(statusSchema) + if diag.HasErrors() { + return nil, diag + } + + val, diag := body.Attributes["message"].Expr.Value(hctx) + if diag.HasErrors() { + return nil, diag + } + + if val.Type() != cty.String { + return nil, fmt.Errorf("message must be a string") + } + + so := &StatusOperation{ + Message: val.AsString(), + } + + if attr, ok := body.Attributes["status"]; ok { + val, diag := attr.Expr.Value(hctx) + if diag.HasErrors() { + return nil, diag + } + + if val.Type() != cty.String { + return nil, fmt.Errorf("message must be a string") + } + + so.Status = val.AsString() + } + + if values, ok := body.Attributes["values"]; ok { + so.Values = make(map[string]string) + + m, diag := values.Expr.Value(hctx) + if diag.HasErrors() { + return nil, diag + } + + if !m.Type().IsObjectType() { + return nil, fmt.Errorf("values must be a map/object") + } + + i := m.ElementIterator() + + for i.Next() { + key, val := i.Element() + + switch { + case val.Type().Equals(cty.String): + so.Values[key.AsString()] = val.AsString() + + case val.Type().Equals(cty.Number): + bf := val.AsBigFloat() + + if bf.IsInt() { + i, _ := bf.Int64() + so.Values[key.AsString()] = fmt.Sprintf("%d", i) + } else { + f, _ := bf.Float64() + so.Values[key.AsString()] = fmt.Sprintf("%f", f) + } + default: + return nil, fmt.Errorf("values can only be strings or numbers") + } + } + } + + return c.opWrapper(so), nil +} + +type hclAction struct { + Name string `hcl:",label"` + Body hcl.Body `hcl:",remain"` +} + +type hclGroup struct { + Name string `hcl:",label"` + Actions []*hclAction `hcl:"action,block"` +} + +type hclConfig struct { + Groups []*hclGroup `hcl:"group,block"` +} diff --git a/internal/pkg/waypoint-agent/config_test.go b/internal/pkg/waypoint-agent/config_test.go new file mode 100644 index 00000000..96daa0cd --- /dev/null +++ b/internal/pkg/waypoint-agent/config_test.go @@ -0,0 +1,269 @@ +package waypointagent + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/stretchr/testify/require" + "github.com/zclconf/go-cty/cty" +) + +func TestConfig(t *testing.T) { + t.Run("can load the operations", func(t *testing.T) { + r := require.New(t) + + hcl := ` + group "test" { + action "launch" { + run { + command = "./launch.sh" + } + } + } +` + + cfg, err := ParseConfig(hcl) + r.NoError(err) + cfg.opWrapper = NoopOperations + + r.Equal([]string{"test"}, cfg.Groups()) + + op, err := cfg.Action("test", "launch", nil) + r.NoError(err) + + shell, ok := op.(*NoopWrapper).Operation.(*ShellOperation) + r.True(ok) + + r.Equal([]string{"./launch.sh"}, shell.Arguments) + }) + + t.Run("splits shell operations using shell space rules", func(t *testing.T) { + r := require.New(t) + + hcl := ` + group "test" { + action "launch" { + run { + command = "./launch.sh -delay=5ms -restart" + } + } + } +` + + cfg, err := ParseConfig(hcl) + r.NoError(err) + cfg.opWrapper = NoopOperations + + op, err := cfg.Action("test", "launch", nil) + r.NoError(err) + + shell, ok := op.(*NoopWrapper).Operation.(*ShellOperation) + r.True(ok) + + r.Equal([]string{"./launch.sh", "-delay=5ms", "-restart"}, shell.Arguments) + }) + + t.Run("can provide a list to run", func(t *testing.T) { + r := require.New(t) + + hcl := ` + group "test" { + action "launch" { + run { + command = ["./launch.sh", "-delay=5ms", "-restart"] + } + } + } +` + + cfg, err := ParseConfig(hcl) + r.NoError(err) + cfg.opWrapper = NoopOperations + + r.Equal([]string{"test"}, cfg.Groups()) + + op, err := cfg.Action("test", "launch", nil) + r.NoError(err) + + shell, ok := op.(*NoopWrapper).Operation.(*ShellOperation) + r.True(ok) + + r.Equal([]string{"./launch.sh", "-delay=5ms", "-restart"}, shell.Arguments) + }) + + t.Run("can handle http operations", func(t *testing.T) { + r := require.New(t) + + hcl := ` + group "test" { + action "launch" { + http { + url = "https://google.com" + } + } + } +` + + cfg, err := ParseConfig(hcl) + r.NoError(err) + cfg.opWrapper = NoopOperations + + r.Equal([]string{"test"}, cfg.Groups()) + + op, err := cfg.Action("test", "launch", nil) + r.NoError(err) + + ho, ok := op.(*NoopWrapper).Operation.(*HTTPOperation) + r.True(ok) + + r.Equal("https://google.com", ho.URL) + }) + + t.Run("can handle compound actions", func(t *testing.T) { + r := require.New(t) + + hcl := ` + group "test" { + action "launch" { + operation { + http { + url = "https://google.com" + } + } + + operation { + http { + url = "https://yahoo.com" + } + } + } + } +` + + cfg, err := ParseConfig(hcl) + r.NoError(err) + cfg.opWrapper = NoopOperations + + r.Equal([]string{"test"}, cfg.Groups()) + + op, err := cfg.Action("test", "launch", nil) + r.NoError(err) + + co, ok := op.(*NoopWrapper).Operation.(*CompoundOperation) + r.True(ok) + + ho, ok := co.Operations[0].(*NoopWrapper).Operation.(*HTTPOperation) + r.True(ok) + + r.Equal("https://google.com", ho.URL) + + ho, ok = co.Operations[1].(*NoopWrapper).Operation.(*HTTPOperation) + r.True(ok) + + r.Equal("https://yahoo.com", ho.URL) + }) + + t.Run("can handle status operations", func(t *testing.T) { + r := require.New(t) + + hcl := ` + group "test" { + action "launch" { + status { + message = "performing launch" + values = { "attempt" = 3 } + status = "running" + } + } + } +` + + cfg, err := ParseConfig(hcl) + r.NoError(err) + cfg.opWrapper = NoopOperations + + r.Equal([]string{"test"}, cfg.Groups()) + + op, err := cfg.Action("test", "launch", nil) + r.NoError(err) + + ho, ok := op.(*NoopWrapper).Operation.(*StatusOperation) + r.True(ok) + + r.Equal("performing launch", ho.Message) + + r.Equal("3", ho.Values["attempt"]) + + r.Equal("running", ho.Status) + }) + + t.Run("can access input variables", func(t *testing.T) { + r := require.New(t) + + str := ` + group "test" { + action "launch" { + run { + command = ["./launch.sh", "-delay", var.delay] + } + } + } +` + + cfg, err := ParseConfig(str) + r.NoError(err) + cfg.opWrapper = NoopOperations + + r.Equal([]string{"test"}, cfg.Groups()) + + var hctx hcl.EvalContext + + hctx.Variables = map[string]cty.Value{ + "var": cty.ObjectVal(map[string]cty.Value{ + "delay": cty.StringVal("5ms"), + }), + } + + op, err := cfg.Action("test", "launch", &hctx) + r.NoError(err) + + shell, ok := op.(*NoopWrapper).Operation.(*ShellOperation) + r.True(ok) + + r.Equal([]string{"./launch.sh", "-delay", "5ms"}, shell.Arguments) + }) + + t.Run("can specify docker options", func(t *testing.T) { + r := require.New(t) + + str := ` + group "test" { + action "launch" { + run { + command = ["./launch.sh", "-delay", "5ms"] + + docker { + image = "ubuntu" + } + } + } + } +` + + cfg, err := ParseConfig(str) + r.NoError(err) + cfg.opWrapper = NoopOperations + + r.Equal([]string{"test"}, cfg.Groups()) + + op, err := cfg.Action("test", "launch", nil) + r.NoError(err) + + shell, ok := op.(*NoopWrapper).Operation.(*ShellOperation) + r.True(ok) + + r.Equal([]string{"./launch.sh", "-delay", "5ms"}, shell.Arguments) + r.NotNil(shell.DockerOptions) + r.Equal("ubuntu", shell.DockerOptions.Image) + }) +} diff --git a/internal/pkg/waypoint-agent/execute.go b/internal/pkg/waypoint-agent/execute.go new file mode 100644 index 00000000..90083873 --- /dev/null +++ b/internal/pkg/waypoint-agent/execute.go @@ -0,0 +1,82 @@ +package waypointagent + +import ( + "context" + "encoding/json" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-waypoint-service/preview/2023-08-18/models" + "github.com/pkg/errors" + "github.com/zclconf/go-cty/cty" +) + +type OperationStatus struct { + Status string + Code int +} + +var ( + errStatus = OperationStatus{Code: -1} + cleanStatus = OperationStatus{Code: 0} +) + +type Operation interface { + Run(ctx context.Context, log hclog.Logger) (OperationStatus, error) +} + +type Executor struct { + Log hclog.Logger + Config *Config +} + +var ErrUnknownOperation = errors.New("unknown operation") + +func (e *Executor) IsAvailable(opInfo *models.HashicorpCloudWaypointAgentOperation) (bool, error) { + // TODO this can also validate the operation body against what the operation can support and + // return an error here. + return e.Config.IsAvailable(opInfo.Group, opInfo.ID) +} + +func (e *Executor) Execute(ctx context.Context, opInfo *models.HashicorpCloudWaypointAgentOperation) (OperationStatus, error) { + var hctx hcl.EvalContext + + input := make(map[string]cty.Value) + + if len(opInfo.Body) != 0 { + + var rawInput map[string]any + + err := json.Unmarshal(opInfo.Body, &rawInput) + if err != nil { + return errStatus, err + } + + for k, v := range rawInput { + switch sv := v.(type) { + case float64: + input[k] = cty.NumberFloatVal(sv) + case string: + input[k] = cty.StringVal(sv) + case bool: + input[k] = cty.BoolVal(sv) + default: + // TODO how should we deal with these? + } + } + } + + hctx.Variables = map[string]cty.Value{ + "waypoint": cty.ObjectVal(map[string]cty.Value{ + "config_id": cty.StringVal(opInfo.ActionConfigID), + }), + "var": cty.ObjectVal(input), + } + + op, err := e.Config.Action(opInfo.Group, opInfo.ID, &hctx) + if err != nil { + return errStatus, err + } + + return op.Run(ctx, e.Log) +} diff --git a/internal/pkg/waypoint-agent/execute_test.go b/internal/pkg/waypoint-agent/execute_test.go new file mode 100644 index 00000000..f2d8abac --- /dev/null +++ b/internal/pkg/waypoint-agent/execute_test.go @@ -0,0 +1,118 @@ +package waypointagent + +import ( + "context" + "encoding/json" + "testing" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-waypoint-service/preview/2023-08-18/models" + "github.com/stretchr/testify/require" +) + +type noop struct { + ran int + err error +} + +func (n *noop) Run() error { + n.ran++ + return n.err +} + +func TestExecutor(t *testing.T) { + t.Run("can lookup the availability of an operation", func(t *testing.T) { + r := require.New(t) + + var ( + e Executor + ) + + hcl := ` + group "test" { + action "launch" { + run { + command = "./launch.sh" + } + } + } +` + + cfg, err := ParseConfig(hcl) + r.NoError(err) + + e.cfg = cfg + + ok, err := e.IsAvailable("test", &models.HashicorpCloudWaypointDequeueOperationResponse{ + OperationID: "launch", + }) + + r.NoError(err) + + r.True(ok) + }) + + t.Run("executes the operation based on the id", func(t *testing.T) { + r := require.New(t) + + var ( + e Executor + ) + + hcl := ` + group "test" { + action "launch" { + run { + command = "true" + } + } + } +` + + cfg, err := ParseConfig(hcl) + r.NoError(err) + + e.cfg = cfg + + err = e.Execute(context.TODO(), "test", &models.HashicorpCloudWaypointDequeueOperationResponse{ + OperationID: "launch", + }) + + r.NoError(err) + }) + + t.Run("variables can be passed through", func(t *testing.T) { + r := require.New(t) + + var ( + e Executor + ) + + hcl := ` + group "test" { + action "launch" { + run { + command = "echo '${var.type}'" + } + } + } +` + + cfg, err := ParseConfig(hcl) + r.NoError(err) + + e.cfg = cfg + + data, err := json.Marshal(map[string]any{ + "type": "nerf", + }) + r.NoError(err) + + err = e.Execute(context.TODO(), "test", &models.HashicorpCloudWaypointDequeueOperationResponse{ + OperationID: "launch", + OperationBody: data, + }) + + r.NoError(err) + }) + +} diff --git a/internal/pkg/waypoint-agent/http_operation.go b/internal/pkg/waypoint-agent/http_operation.go new file mode 100644 index 00000000..d9736d6c --- /dev/null +++ b/internal/pkg/waypoint-agent/http_operation.go @@ -0,0 +1,23 @@ +package waypointagent + +import ( + "context" + "net/http" + + "github.com/hashicorp/go-hclog" +) + +type HTTPOperation struct { + URL string +} + +func (h *HTTPOperation) Run(ctx context.Context, log hclog.Logger) (OperationStatus, error) { + resp, err := http.Get(h.URL) + if err != nil { + return errStatus, err + } + + defer resp.Body.Close() + + return cleanStatus, nil +} diff --git a/internal/pkg/waypoint-agent/noop_wrapper.go b/internal/pkg/waypoint-agent/noop_wrapper.go new file mode 100644 index 00000000..1c412b7d --- /dev/null +++ b/internal/pkg/waypoint-agent/noop_wrapper.go @@ -0,0 +1,19 @@ +package waypointagent + +import ( + "context" + + "github.com/hashicorp/go-hclog" +) + +type NoopWrapper struct { + Operation Operation +} + +func (n *NoopWrapper) Run(ctx context.Context, log hclog.Logger) (OperationStatus, error) { + return cleanStatus, nil +} + +func NoopOperations(o Operation) Operation { + return &NoopWrapper{Operation: o} +} diff --git a/internal/pkg/waypoint-agent/shell_operation.go b/internal/pkg/waypoint-agent/shell_operation.go new file mode 100644 index 00000000..1e02da1b --- /dev/null +++ b/internal/pkg/waypoint-agent/shell_operation.go @@ -0,0 +1,69 @@ +package waypointagent + +import ( + "bytes" + "context" + "os" + "os/exec" + "slices" + + "github.com/hashicorp/go-hclog" +) + +type ShellOperation struct { + Arguments []string + Environment map[string]string + DockerOptions *DockerOptions +} + +type DockerOptions struct { + Image string `hcl:"image"` +} + +func (s *ShellOperation) Run(ctx context.Context, log hclog.Logger) (OperationStatus, error) { + if s.DockerOptions != nil { + return s.runUnderDocker(ctx, log) + } + + return s.exec(ctx, log, s.Arguments) +} + +func (s *ShellOperation) exec(ctx context.Context, log hclog.Logger, cmd []string) (OperationStatus, error) { + c := exec.CommandContext(ctx, s.Arguments[0], s.Arguments[1:]...) + + c.Env = slices.Clone(os.Environ()) + + for k, v := range s.Environment { + c.Env = append(c.Env, k+"="+v) + } + + var out bytes.Buffer + + c.Stdout = &out + c.Stderr = &out + + err := c.Run() + log.Debug("output from shell operation", "command", s.Arguments[0], "output", out.String(), "error", err) + + status := OperationStatus{ + Code: c.ProcessState.ExitCode(), + } + + data := bytes.TrimSpace(out.Bytes()) + + if idx := bytes.LastIndexByte(data, '\n'); idx != -1 { + status.Status = "output: " + string(data[idx:]) + } else if len(data) > 0 { + status.Status = "output: " + string(data) + } + + return status, nil +} + +func (s *ShellOperation) runUnderDocker(ctx context.Context, log hclog.Logger) (OperationStatus, error) { + args := append([]string{ + "run", "--rm", s.DockerOptions.Image, + }, s.Arguments...) + + return s.exec(ctx, log, args) +} diff --git a/internal/pkg/waypoint-agent/status_operation.go b/internal/pkg/waypoint-agent/status_operation.go new file mode 100644 index 00000000..8858205f --- /dev/null +++ b/internal/pkg/waypoint-agent/status_operation.go @@ -0,0 +1,49 @@ +package waypointagent + +import ( + "context" + + "github.com/hashicorp/go-hclog" +) + +type StatusOperation struct { + //as action_service.ClientService + + token string + namespace string + + Message string + Values map[string]string + Status string +} + +func (s *StatusOperation) Run(ctx context.Context, log hclog.Logger) (OperationStatus, error) { + /* + ret, err := s.as.ActionSendStatusLogToken(&action.ActionSendStatusLogParams{ + ActionConfigID: s.cfgId, + ActionRunSeq: s.runId, + NamespaceID: s.namespace, + + Body: &models.HashicorpCloudWaypointActionsdriverSendStatusLogRequest{ + StatusLog: &models.HashicorpCloudWaypointActionsdriverStatusLog{ + EmittedAt: strfmt.DateTime(time.Now()), + Log: s.Message, + Metadata: s.Values, + }, + }, + }, nil) + if err != nil { + return err + } + + if ret.IsClientError() { + log.Error("error sending status log (client side)", "error", ret.Error()) + } + + if ret.IsServerError() { + log.Error("error sending status log (server side)", "error", ret.Error()) + } + */ + + return cleanStatus, nil +} diff --git a/internal/pkg/waypoint-agent/tfe_modules_op.go b/internal/pkg/waypoint-agent/tfe_modules_op.go new file mode 100644 index 00000000..ea59bb8e --- /dev/null +++ b/internal/pkg/waypoint-agent/tfe_modules_op.go @@ -0,0 +1,42 @@ +package waypointagent + +/* +type TFEModuleDetailsOperation struct { + TFCToken string + + Namespace string + Name string + Provider string + Version string +} + +func (s *TFEModuleDetailsOperation) Run(ctx context.Context, log hclog.Logger) error { + moduleSource := fmt.Sprintf("%s/%s/%s/%s", s.Namespace, s.Name, s.Provider, s.Version) + modulePath := fmt.Sprintf("https://app.terraform.io/api/registry/v1/modules/" + moduleSource) + + req, err := http.NewRequest("GET", modulePath, nil) + if err != nil { + return errors.Wrapf(err, "failed to get module details for module: "+moduleSource) + } + req = req.WithContext(ctx) + req.Header.Set("Authorization", "Bearer "+s.TFCToken) + req.Header.Set("content-type", "application/vnd.api+json") + + client := &http.Client{} + rawResp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "failed to get module details for module: "+moduleSource) + } + defer rawResp.Body.Close() + resp := struct { + Root struct { + Readme string `json:"readme"` + } + }{} + err = json.NewDecoder(rawResp.Body).Decode(&resp) + if err != nil { + return errors.Wrapf(err, "failed to get module details for module: "+moduleSource) + } + return &pb.TFModuleDetails{Readme: resp.Root.Readme}, nil +} +*/