From b54146dbe27b1094bb921c3dca799888c50cf297 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 11 Nov 2024 15:01:57 +0545 Subject: [PATCH] feat: connection from scraper (#1589) * feat: connection from scraper * test: connection from scraper --------- Co-authored-by: Moshe Immerman --- .gitignore | 1 + api/v1/playbook_actions.go | 11 +- api/v1/zz_generated.deepcopy.go | 30 ----- ...ion-control.flanksource.com_playbooks.yaml | 65 +++++++++-- config/schemas/playbook-spec.schema.json | 30 ++++- config/schemas/playbook.schema.json | 30 ++++- .../playbooks/connection-from-scraper.yaml | 18 +++ playbook/actions/exec.go | 106 ++---------------- playbook/playbook_test.go | 13 +++ playbook/runner/runner.go | 1 - playbook/runner/template.go | 15 ++- .../testdata/exec-connection-kubernetes.yaml | 18 +++ 12 files changed, 180 insertions(+), 158 deletions(-) create mode 100644 fixtures/playbooks/connection-from-scraper.yaml create mode 100644 playbook/testdata/exec-connection-kubernetes.yaml diff --git a/.gitignore b/.gitignore index b224bb3b0..442ac7ab2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ coverprofile.out junit-report.xml nohup.out .envrc +.creds diff --git a/api/v1/playbook_actions.go b/api/v1/playbook_actions.go index a629b52bb..bf600d2f7 100644 --- a/api/v1/playbook_actions.go +++ b/api/v1/playbook_actions.go @@ -8,6 +8,7 @@ import ( "github.com/flanksource/commons/duration" "github.com/flanksource/commons/utils" + "github.com/flanksource/duty/connection" "github.com/flanksource/duty/models" "github.com/flanksource/duty/types" "k8s.io/client-go/kubernetes" @@ -237,8 +238,8 @@ func (git GitCheckout) GetCertificate() types.EnvVar { type ExecAction struct { // Script can be an inline script or a path to a script that needs to be executed // On windows executed via powershell and in darwin and linux executed using bash - Script string `yaml:"script" json:"script" template:"true"` - Connections ExecConnections `yaml:"connections,omitempty" json:"connections,omitempty"` + Script string `yaml:"script" json:"script" template:"true"` + Connections connection.ExecConnections `yaml:"connections,omitempty" json:"connections,omitempty" template:"true"` // Artifacts to save Artifacts []Artifact `yaml:"artifacts,omitempty" json:"artifacts,omitempty" template:"true"` // EnvVars are the environment variables that are accessible to exec processes @@ -247,12 +248,6 @@ type ExecAction struct { Checkout *GitCheckout `yaml:"checkout,omitempty" json:"checkout,omitempty"` } -type ExecConnections struct { - AWS *AWSConnection `yaml:"aws,omitempty" json:"aws,omitempty"` - GCP *GCPConnection `yaml:"gcp,omitempty" json:"gcp,omitempty"` - Azure *AzureConnection `yaml:"azure,omitempty" json:"azure,omitempty"` -} - type connectionContext interface { gocontext.Context HydrateConnectionByURL(connectionName string) (*models.Connection, error) diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index e3fa09670..7b9163bdc 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -882,36 +882,6 @@ func (in *ExecAction) DeepCopy() *ExecAction { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ExecConnections) DeepCopyInto(out *ExecConnections) { - *out = *in - if in.AWS != nil { - in, out := &in.AWS, &out.AWS - *out = new(AWSConnection) - (*in).DeepCopyInto(*out) - } - if in.GCP != nil { - in, out := &in.GCP, &out.GCP - *out = new(GCPConnection) - (*in).DeepCopyInto(*out) - } - if in.Azure != nil { - in, out := &in.Azure, &out.Azure - *out = new(AzureConnection) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecConnections. -func (in *ExecConnections) DeepCopy() *ExecConnections { - if in == nil { - return nil - } - out := new(ExecConnections) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GCPConnection) DeepCopyInto(out *GCPConnection) { *out = *in diff --git a/config/crds/mission-control.flanksource.com_playbooks.yaml b/config/crds/mission-control.flanksource.com_playbooks.yaml index c83d32385..de6ad25fd 100644 --- a/config/crds/mission-control.flanksource.com_playbooks.yaml +++ b/config/crds/mission-control.flanksource.com_playbooks.yaml @@ -341,6 +341,8 @@ spec: type: string type: object type: object + assumeRole: + type: string connection: description: ConnectionName of the connection. It'll be used to populate the endpoint, accessKey and @@ -348,10 +350,6 @@ spec: type: string endpoint: type: string - objectPath: - description: glob path to restrict matches to a - subset - type: string region: type: string secretKey: @@ -448,10 +446,6 @@ spec: description: Skip TLS verify when connecting to aws type: boolean - usePathStyle: - description: 'Use path style path: http://s3.amazonaws.com/BUCKET/KEY - instead of http://BUCKET.s3.amazonaws.com/KEY' - type: boolean type: object azure: properties: @@ -550,6 +544,8 @@ spec: tenantID: type: string type: object + fromConfigItem: + type: string gcp: properties: connection: @@ -603,6 +599,59 @@ spec: type: object endpoint: type: string + skipTLSVerify: + description: Skip TLS verify + type: boolean + type: object + kubernetes: + properties: + connection: + type: string + kubeconfig: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + required: + - key + type: object + helmRef: + properties: + key: + description: Key is a JSONPath expression + used to fetch the key from the merged + JSON. + type: string + name: + type: string + required: + - key + type: object + secretKeyRef: + properties: + key: + type: string + name: + type: string + required: + - key + type: object + serviceAccount: + description: ServiceAccount specifies the + service account whose token should be + fetched + type: string + type: object + type: object type: object type: object env: diff --git a/config/schemas/playbook-spec.schema.json b/config/schemas/playbook-spec.schema.json index 2f5755415..6aa24b9c8 100644 --- a/config/schemas/playbook-spec.schema.json +++ b/config/schemas/playbook-spec.schema.json @@ -17,6 +17,9 @@ "sessionToken": { "$ref": "#/$defs/EnvVar" }, + "assumeRole": { + "type": "string" + }, "region": { "type": "string" }, @@ -25,12 +28,6 @@ }, "skipTLSVerify": { "type": "boolean" - }, - "objectPath": { - "type": "string" - }, - "usePathStyle": { - "type": "boolean" } }, "additionalProperties": false, @@ -203,6 +200,12 @@ }, "ExecConnections": { "properties": { + "fromConfigItem": { + "type": "string" + }, + "kubernetes": { + "$ref": "#/$defs/KubernetesConnection" + }, "aws": { "$ref": "#/$defs/AWSConnection" }, @@ -226,6 +229,9 @@ }, "credentials": { "$ref": "#/$defs/EnvVar" + }, + "skipTLSVerify": { + "type": "boolean" } }, "additionalProperties": false, @@ -498,6 +504,18 @@ }, "type": "array" }, + "KubernetesConnection": { + "properties": { + "connection": { + "type": "string" + }, + "kubeconfig": { + "$ref": "#/$defs/EnvVar" + } + }, + "additionalProperties": false, + "type": "object" + }, "NotificationAction": { "properties": { "url": { diff --git a/config/schemas/playbook.schema.json b/config/schemas/playbook.schema.json index 78b050e8d..2ec13a5ca 100644 --- a/config/schemas/playbook.schema.json +++ b/config/schemas/playbook.schema.json @@ -17,6 +17,9 @@ "sessionToken": { "$ref": "#/$defs/EnvVar" }, + "assumeRole": { + "type": "string" + }, "region": { "type": "string" }, @@ -25,12 +28,6 @@ }, "skipTLSVerify": { "type": "boolean" - }, - "objectPath": { - "type": "string" - }, - "usePathStyle": { - "type": "boolean" } }, "additionalProperties": false, @@ -203,6 +200,12 @@ }, "ExecConnections": { "properties": { + "fromConfigItem": { + "type": "string" + }, + "kubernetes": { + "$ref": "#/$defs/KubernetesConnection" + }, "aws": { "$ref": "#/$defs/AWSConnection" }, @@ -231,6 +234,9 @@ }, "credentials": { "$ref": "#/$defs/EnvVar" + }, + "skipTLSVerify": { + "type": "boolean" } }, "additionalProperties": false, @@ -503,6 +509,18 @@ }, "type": "array" }, + "KubernetesConnection": { + "properties": { + "connection": { + "type": "string" + }, + "kubeconfig": { + "$ref": "#/$defs/EnvVar" + } + }, + "additionalProperties": false, + "type": "object" + }, "ManagedFieldsEntry": { "properties": { "manager": { diff --git a/fixtures/playbooks/connection-from-scraper.yaml b/fixtures/playbooks/connection-from-scraper.yaml new file mode 100644 index 000000000..a721ccd08 --- /dev/null +++ b/fixtures/playbooks/connection-from-scraper.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: mission-control.flanksource.com/v1 +kind: Playbook +metadata: + name: kubernetes-connection-from-scraper + namespace: mc +spec: + configs: + - types: + - Kubernetes::Deployment + actions: + - exec: + script: "kubectl get deployments" + connections: + fromConfigItem: "{{.config.id}}" + name: list + category: Echoer + description: Lists all deployments diff --git a/playbook/actions/exec.go b/playbook/actions/exec.go index 08ba5ffc4..f3b199ea5 100644 --- a/playbook/actions/exec.go +++ b/playbook/actions/exec.go @@ -4,20 +4,18 @@ import ( "bytes" "fmt" "io" - "math/rand" "os" osExec "os/exec" "path/filepath" "strings" - textTemplate "text/template" "time" "github.com/flanksource/artifacts" fileUtils "github.com/flanksource/commons/files" - "github.com/flanksource/commons/logger" "github.com/flanksource/commons/hash" "github.com/flanksource/commons/utils" + "github.com/flanksource/duty/connection" "github.com/flanksource/duty/context" "github.com/flanksource/duty/models" v1 "github.com/flanksource/incident-commander/api/v1" @@ -73,70 +71,19 @@ func (c *ExecAction) Run(ctx context.Context, exec v1.ExecAction) (*ExecDetails, cmd.Dir = envParams.mountPoint } - if err := setupConnection(ctx, exec, cmd); err != nil { + if cleanup, err := connection.SetupConnection(ctx, exec.Connections, cmd); err != nil { return nil, ctx.Oops().Wrapf(err, "failed to setup connection") + } else { + defer func() { + if err := cleanup(); err != nil { + ctx.Errorf("something went wrong cleaning up connection artifacts: %v", err) + } + }() } return runCmd(ctx, cmd, exec.Artifacts...) } -func setupConnection(ctx context.Context, check v1.ExecAction, cmd *osExec.Cmd) error { - if check.Connections.AWS != nil { - if err := check.Connections.AWS.Populate(ctx, ctx.Kubernetes(), ctx.GetNamespace()); err != nil { - return fmt.Errorf("failed to hydrate aws connection: %w", err) - } - - configPath, err := saveConfig(awsConfigTemplate, check.Connections.AWS) - defer os.RemoveAll(filepath.Dir(configPath)) - if err != nil { - return fmt.Errorf("failed to store AWS credentials: %w", err) - } - - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, "AWS_EC2_METADATA_DISABLED=true") // https://github.com/aws/aws-cli/issues/5262#issuecomment-705832151 - cmd.Env = append(cmd.Env, fmt.Sprintf("AWS_SHARED_CREDENTIALS_FILE=%s", configPath)) - if check.Connections.AWS.Region != "" { - cmd.Env = append(cmd.Env, fmt.Sprintf("AWS_DEFAULT_REGION=%s", check.Connections.AWS.Region)) - } - } - - if check.Connections.Azure != nil { - if err := check.Connections.Azure.HydrateConnection(ctx); err != nil { - return fmt.Errorf("failed to hydrate connection %w", err) - } - - // login with service principal - runCmd := osExec.Command("az", "login", "--service-principal", "--username", check.Connections.Azure.ClientID.ValueStatic, "--password", check.Connections.Azure.ClientSecret.ValueStatic, "--tenant", check.Connections.Azure.TenantID) - if err := runCmd.Run(); err != nil { - return fmt.Errorf("failed to login: %w", err) - } - } - - if check.Connections.GCP != nil { - if err := check.Connections.GCP.HydrateConnection(ctx); err != nil { - return fmt.Errorf("failed to hydrate connection %w", err) - } - - configPath, err := saveConfig(gcloudConfigTemplate, check.Connections.GCP) - defer os.RemoveAll(filepath.Dir(configPath)) - if err != nil { - return fmt.Errorf("failed to store gcloud credentials: %w", err) - } - - // to configure gcloud CLI to use the service account specified in GOOGLE_APPLICATION_CREDENTIALS, - // we need to explicitly activate it - runCmd := osExec.Command("gcloud", "auth", "activate-service-account", "--key-file", configPath) - if err := runCmd.Run(); err != nil { - return fmt.Errorf("failed to activate GCP service account: %w", err) - } - - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, fmt.Sprintf("GOOGLE_APPLICATION_CREDENTIALS=%s", configPath)) - } - - return nil -} - func runCmd(ctx context.Context, cmd *osExec.Cmd, artifactConfigs ...v1.Artifact) (*ExecDetails, error) { var ( result ExecDetails @@ -199,43 +146,6 @@ func runCmd(ctx context.Context, cmd *osExec.Cmd, artifactConfigs ...v1.Artifact return &result, nil } -func saveConfig(configTemplate *textTemplate.Template, view any) (string, error) { - dirPath := filepath.Join(".creds", fmt.Sprintf("cred-%d", rand.Intn(10000000))) - if err := os.MkdirAll(dirPath, 0700); err != nil { - return "", err - } - - configPath := fmt.Sprintf("%s/credentials", dirPath) - logger.Tracef("Creating credentials file: %s", configPath) - - file, err := os.Create(configPath) - if err != nil { - return configPath, err - } - defer file.Close() - - if err := configTemplate.Execute(file, view); err != nil { - return configPath, err - } - - return configPath, nil -} - -var ( - awsConfigTemplate *textTemplate.Template - gcloudConfigTemplate *textTemplate.Template -) - -func init() { - awsConfigTemplate = textTemplate.Must(textTemplate.New("").Parse(`[default] -aws_access_key_id = {{.AccessKey.ValueStatic}} -aws_secret_access_key = {{.SecretKey.ValueStatic}} -{{if .SessionToken.ValueStatic}}aws_session_token={{.SessionToken.ValueStatic}}{{end}} -`)) - - gcloudConfigTemplate = textTemplate.Must(textTemplate.New("").Parse(`{{.Credentials}}`)) -} - type execEnv struct { envs []string mountPoint string diff --git a/playbook/playbook_test.go b/playbook/playbook_test.go index 3311bdf7a..81923a05f 100644 --- a/playbook/playbook_test.go +++ b/playbook/playbook_test.go @@ -558,6 +558,18 @@ var _ = Describe("Playbook", func() { Expect(actions[0].JSON()["item"]).To(Equal(*dummy.KubernetesNodeA.Name)) Expect(actions[1].JSON()["item"]).To(Equal(fmt.Sprintf("name=%s", *dummy.KubernetesNodeA.Name))) }) + + It("exec | connection | kubernetes", func() { + run := createAndRun(DefaultContext.WithUser(&dummy.JohnDoe), "exec-connection-kubernetes", RunParams{ + ConfigID: lo.ToPtr(dummy.KubernetesCluster.ID), + }) + + Expect(run.Status).To(Equal(models.PlaybookRunStatusCompleted), run.String(DefaultContext.DB())) + actions, err := run.GetActions(DefaultContext.DB()) + Expect(err).To(BeNil()) + Expect(len(actions)).To(Equal(1)) + Expect(actions[0].Result["stdout"]).To(HavePrefix(".creds/cred-")) + }) }) var _ = Describe("spec runner", func() { @@ -634,6 +646,7 @@ func waitFor(run *models.PlaybookRun, statuses ...models.PlaybookRunStatus) *mod if savedRun != nil { return savedRun.Status } + return models.PlaybookRunStatus("Unknown") }).WithTimeout(15 * time.Second).WithPolling(time.Second).Should(BeElementOf(s)) diff --git a/playbook/runner/runner.go b/playbook/runner/runner.go index 2e4868d68..4c2321baf 100644 --- a/playbook/runner/runner.go +++ b/playbook/runner/runner.go @@ -261,7 +261,6 @@ func ExecuteAndSaveAction(ctx context.Context, playbookID any, action *models.Pl } -// TemplateAndExecuteAction executes the given playbook action after templating it. func RunAction(ctx context.Context, run *models.PlaybookRun, action *models.PlaybookRunAction) error { playbook, err := action.GetPlaybook(ctx.DB()) if err != nil { diff --git a/playbook/runner/template.go b/playbook/runner/template.go index 966d3b468..8793c89d7 100644 --- a/playbook/runner/template.go +++ b/playbook/runner/template.go @@ -186,5 +186,18 @@ func TemplateEnv(ctx context.Context, env actions.TemplateEnv, template string) // TemplateAction all the go templates in the action func TemplateAction(ctx context.Context, actionSpec *v1.PlaybookAction, env actions.TemplateEnv) error { templater := ctx.NewStructTemplater(env.AsMap(), "template", getGomplateFuncs(ctx, env)) - return templater.Walk(&actionSpec) + if err := templater.Walk(&actionSpec); err != nil { + return err + } + + // TODO: make this work with template.Walk() + if actionSpec.Exec != nil && actionSpec.Exec.Connections.FromConfigItem != nil { + if v, err := ctx.RunTemplate(gomplate.Template{Template: *actionSpec.Exec.Connections.FromConfigItem}, env.AsMap()); err != nil { + return err + } else { + actionSpec.Exec.Connections.FromConfigItem = &v + } + } + + return nil } diff --git a/playbook/testdata/exec-connection-kubernetes.yaml b/playbook/testdata/exec-connection-kubernetes.yaml new file mode 100644 index 000000000..50d23f0bf --- /dev/null +++ b/playbook/testdata/exec-connection-kubernetes.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: mission-control.flanksource.com/v1 +kind: Playbook +metadata: + name: kubernetes-connection-from-scraper + namespace: mc +spec: + category: Echoer + description: list kubeconfig env var + configs: + - types: + - Kubernetes::Deployment + actions: + - name: echo + exec: + connections: + fromConfigItem: "{{.config.id}}" + script: "echo $KUBECONFIG"