diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b030c4a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM alpine + +RUN apk add --no-cache curl unzip bash git + +# Terraform version +ARG TERRAFORM_VERSION=1.5.7 +ARG TARGETARCH + +# Download correct binary depending on architecture +RUN curl -fsSL -o /tmp/terraform.zip \ + https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${TARGETARCH}.zip \ + && unzip /tmp/terraform.zip -d /usr/local/bin \ + && rm /tmp/terraform.zip + +# Put the manager binary in /app +WORKDIR / +COPY ./manager . +RUN chmod +x ./manager +COPY gitconfig /.gitconfig + +# Create required writable directories +RUN mkdir -p /tf /tmp /logs && chmod -R 777 /tf /logs /tmp + +ENTRYPOINT ["/manager"] diff --git a/cmd/provider/main.go b/cmd/provider/main.go index 770a4dc..3b6590c 100644 --- a/cmd/provider/main.go +++ b/cmd/provider/main.go @@ -49,6 +49,7 @@ import ( "github.com/upbound/provider-terraform/apis/v1beta1" workspace "github.com/upbound/provider-terraform/internal/controller" "github.com/upbound/provider-terraform/internal/controller/features" + _ "github.com/upbound/provider-terraform/pkg/metrics" ) func main() { diff --git a/gitconfig b/gitconfig new file mode 100644 index 0000000..618ee47 --- /dev/null +++ b/gitconfig @@ -0,0 +1,2 @@ +[credential] + helper = store --file=$GIT_CRED_DIR/.git-credentials diff --git a/go.mod b/go.mod index 8539707..4e2687e 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/crossplane/crossplane-runtime v1.16.0 github.com/crossplane/crossplane-tools v0.0.0-20230925130601-628280f8bf79 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.4.0 github.com/hashicorp/go-getter v1.7.5 diff --git a/go.sum b/go.sum index 904d831..c79e9f1 100644 --- a/go.sum +++ b/go.sum @@ -277,6 +277,8 @@ github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= diff --git a/internal/controller/workspace/github_app.go b/internal/controller/workspace/github_app.go new file mode 100644 index 0000000..b5500c9 --- /dev/null +++ b/internal/controller/workspace/github_app.go @@ -0,0 +1,117 @@ +package workspace + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v5" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/upbound/provider-terraform/pkg/metrics" +) + +// installationTokenResponse represents the GitHub API response for installation token creation. +type installationTokenResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} + +func getGitCredsFromGithubAppSecret(ctx context.Context, client client.Client) ([]byte, error) { + secret := v1.Secret{} + if err := client.Get(ctx, types.NamespacedName{ + Name: "github-app-credentials", + Namespace: "crossplane-system", + }, &secret); err != nil { + return nil, fmt.Errorf("failed to get github app credentials secret: %w", err) + } + + privateKeyPEM := string(secret.Data["github_app_private_key"]) + + appIDStr := string(secret.Data["app_id"]) + appID, err := strconv.ParseInt(appIDStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse app_id from secret: %w", err) + } + + installIDStr := string(secret.Data["installation_id"]) + installationID, err := strconv.ParseInt(installIDStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse installation_id from secret: %w", err) + } + + installationToken, err := generateInstallationToken(appID, installationID, privateKeyPEM) + if err != nil { + return nil, fmt.Errorf("failed to get installation token: %w", &err) + } + + data := fmt.Sprintf("https://x-access-token:%s@github.com", installationToken) + + return []byte(data), nil +} + +func generateInstallationToken(appID, installationID int64, privateKeyPEM string) (string, error) { + // Parse the RSA private key from PEM + key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKeyPEM)) + if err != nil { + return "", fmt.Errorf("failed to parse GitHub App private key: %w", err) + } + + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now.Add(-60 * time.Second)), // Clock drift allowance + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), // Max 10 min per GitHub docs + Issuer: fmt.Sprintf("%d", appID), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signedJWT, err := token.SignedString(key) + if err != nil { + return "", fmt.Errorf("failed to sign JWT: %w", err) + } + + // Exchange JWT for installation access token + url := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installationID) + req, err := http.NewRequest(http.MethodPost, url, nil) + if err != nil { + return "", fmt.Errorf("error creating installation token request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signedJWT)) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + httpClient := &http.Client{Timeout: 30 * time.Second} + apiStart := time.Now() + resp, err := httpClient.Do(req) + if err != nil { + metrics.RecordGitHubAPICall("unknown", "generate_installation_token", "failure", time.Since(apiStart)) + return "", fmt.Errorf("error requesting installation token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + metrics.RecordGitHubAPICall("unknown", "generate_installation_token", "failure", time.Since(apiStart)) + return "", fmt.Errorf("error reading installation token response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + metrics.RecordGitHubAPICall("unknown", "generate_installation_token", "failure", time.Since(apiStart)) + return "", fmt.Errorf("failed to create installation token (status %d): %s", resp.StatusCode, string(body)) + } + + metrics.RecordGitHubAPICall("unknown", "generate_installation_token", "success", time.Since(apiStart)) + + var tokenResp installationTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", fmt.Errorf("error unmarshaling installation token response: %w", err) + } + + return tokenResp.Token, nil +} diff --git a/internal/controller/workspace/workspace.go b/internal/controller/workspace/workspace.go index f7058c6..b038f37 100644 --- a/internal/controller/workspace/workspace.go +++ b/internal/controller/workspace/workspace.go @@ -50,6 +50,7 @@ import ( "github.com/upbound/provider-terraform/internal/controller/features" "github.com/upbound/provider-terraform/internal/terraform" "github.com/upbound/provider-terraform/internal/workdir" + "github.com/upbound/provider-terraform/pkg/metrics" ) const ( @@ -107,8 +108,8 @@ type tfclient interface { Outputs(ctx context.Context) ([]terraform.Output, error) Resources(ctx context.Context) ([]string, error) Diff(ctx context.Context, o ...terraform.Option) (bool, error) - Apply(ctx context.Context, o ...terraform.Option) error - Destroy(ctx context.Context, o ...terraform.Option) error + Apply(ctx context.Context, ws string, o ...terraform.Option) error + Destroy(ctx context.Context, ws string, o ...terraform.Option) error DeleteCurrentWorkspace(ctx context.Context) error GenerateChecksum(ctx context.Context) (string, error) } @@ -213,10 +214,15 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E if cd.Filename != gitCredentialsFilename { continue } - data, err := resource.CommonCredentialExtractor(ctx, cd.Source, c.kube, cd.CommonCredentialSelectors) + // data, err := resource.CommonCredentialExtractor(ctx, cd.Source, c.kube, cd.CommonCredentialSelectors) + // if err != nil { + // return nil, errors.Wrap(err, errGetCreds) + // } + data, err := getGitCredsFromGithubAppSecret(ctx, c.kube) if err != nil { return nil, errors.Wrap(err, errGetCreds) } + // NOTE(bobh66): Put the git credentials file in /tmp/tf/ so it doesn't get removed or overwritten // by the remote module source case gitCredDir := filepath.Clean(filepath.Join("/tmp", dir)) @@ -244,10 +250,13 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E Mode: getter.ClientModeDir, } + fetchTimer := metrics.NewModuleFetchTimer(cr.Name, cr.Spec.ForProvider.Module) err := gc.Get() if err != nil { + fetchTimer.RecordFailure() return nil, errors.Wrap(err, errRemoteModule) } + fetchTimer.RecordSuccess() case v1beta1.ModuleSourceInline: fn := tfMain @@ -265,6 +274,9 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E } for _, cd := range pc.Spec.Credentials { + if cd.Filename == gitCredentialsFilename { + continue + } data, err := resource.CommonCredentialExtractor(ctx, cd.Source, c.kube, cd.CommonCredentialSelectors) if err != nil { return nil, errors.Wrap(err, errGetCreds) @@ -442,7 +454,7 @@ func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext } o = append(o, terraform.WithArgs(cr.Spec.ForProvider.ApplyArgs)) - if err := c.tf.Apply(ctx, o...); err != nil { + if err := c.tf.Apply(ctx, cr.Name, o...); err != nil { return managed.ExternalUpdate{}, errors.Wrap(err, errApply) } @@ -473,7 +485,7 @@ func (c *external) Delete(ctx context.Context, mg resource.Managed) error { } o = append(o, terraform.WithArgs(cr.Spec.ForProvider.DestroyArgs)) - return errors.Wrap(c.tf.Destroy(ctx, o...), errDestroy) + return errors.Wrap(c.tf.Destroy(ctx, cr.Name, o...), errDestroy) } //nolint:gocyclo diff --git a/internal/controller/workspace/workspace_test.go b/internal/controller/workspace/workspace_test.go index 6097fcb..ba2f993 100644 --- a/internal/controller/workspace/workspace_test.go +++ b/internal/controller/workspace/workspace_test.go @@ -73,8 +73,8 @@ type MockTf struct { MockOutputs func(ctx context.Context) ([]terraform.Output, error) MockResources func(ctx context.Context) ([]string, error) MockDiff func(ctx context.Context, o ...terraform.Option) (bool, error) - MockApply func(ctx context.Context, o ...terraform.Option) error - MockDestroy func(ctx context.Context, o ...terraform.Option) error + MockApply func(ctx context.Context, ws string, o ...terraform.Option) error + MockDestroy func(ctx context.Context, ws string, o ...terraform.Option) error MockDeleteCurrentWorkspace func(ctx context.Context) error MockGenerateChecksum func(ctx context.Context) (string, error) } @@ -103,12 +103,12 @@ func (tf *MockTf) Diff(ctx context.Context, o ...terraform.Option) (bool, error) return tf.MockDiff(ctx, o...) } -func (tf *MockTf) Apply(ctx context.Context, o ...terraform.Option) error { - return tf.MockApply(ctx, o...) +func (tf *MockTf) Apply(ctx context.Context, ws string, o ...terraform.Option) error { + return tf.MockApply(ctx, ws, o...) } -func (tf *MockTf) Destroy(ctx context.Context, o ...terraform.Option) error { - return tf.MockDestroy(ctx, o...) +func (tf *MockTf) Destroy(ctx context.Context, ws string, o ...terraform.Option) error { + return tf.MockDestroy(ctx, ws, o...) } func (tf *MockTf) DeleteCurrentWorkspace(ctx context.Context) error { @@ -1278,7 +1278,7 @@ func TestCreate(t *testing.T) { reason: "We should return any error we encounter applying our Terraform configuration", fields: fields{ tf: &MockTf{ - MockApply: func(_ context.Context, _ ...terraform.Option) error { return errBoom }, + MockApply: func(_ context.Context, ws string, _ ...terraform.Option) error { return errBoom }, }, }, args: args{ @@ -1292,7 +1292,7 @@ func TestCreate(t *testing.T) { reason: "We should return any error we encounter getting our Terraform outputs", fields: fields{ tf: &MockTf{ - MockApply: func(_ context.Context, _ ...terraform.Option) error { return nil }, + MockApply: func(_ context.Context, ws string, _ ...terraform.Option) error { return nil }, MockOutputs: func(ctx context.Context) ([]terraform.Output, error) { return nil, errBoom }, }, }, @@ -1307,7 +1307,7 @@ func TestCreate(t *testing.T) { reason: "We should refresh our connection details with any updated outputs after successfully applying the Terraform configuration", fields: fields{ tf: &MockTf{ - MockApply: func(_ context.Context, _ ...terraform.Option) error { return nil }, + MockApply: func(_ context.Context, ws string, _ ...terraform.Option) error { return nil }, MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, MockOutputs: func(ctx context.Context) ([]terraform.Output, error) { return []terraform.Output{ @@ -1487,7 +1487,7 @@ func TestDelete(t *testing.T) { reason: "We should return any error we encounter destroying our Terraform configuration", fields: fields{ tf: &MockTf{ - MockDestroy: func(_ context.Context, _ ...terraform.Option) error { return errBoom }, + MockDestroy: func(_ context.Context, ws string, _ ...terraform.Option) error { return errBoom }, }, }, args: args{ @@ -1499,7 +1499,7 @@ func TestDelete(t *testing.T) { reason: "We should not return an error if we successfully destroy the Terraform configuration", fields: fields{ tf: &MockTf{ - MockDestroy: func(_ context.Context, _ ...terraform.Option) error { return nil }, + MockDestroy: func(_ context.Context, ws string, _ ...terraform.Option) error { return nil }, }, kube: &test.MockClient{ MockGet: test.NewMockGetFn(nil), diff --git a/internal/terraform/terraform.go b/internal/terraform/terraform.go index 5f0a64f..4dce34e 100644 --- a/internal/terraform/terraform.go +++ b/internal/terraform/terraform.go @@ -25,6 +25,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -37,6 +38,8 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/logging" "github.com/pkg/errors" + + "github.com/upbound/provider-terraform/pkg/metrics" ) // Error strings. @@ -204,8 +207,14 @@ func (h Harness) Init(ctx context.Context, o ...InitOption) error { defer rwmutex.Unlock() } + timer := metrics.NewTerraformOperationTimer(filepath.Base(h.Dir), "init") _, err := runCommand(ctx, cmd) - return Classify(err) + if err != nil { + timer.RecordFailure() + return Classify(err) + } + timer.RecordSuccess() + return nil } // Validate a Terraform configuration. Note that there may be interplay between @@ -566,25 +575,30 @@ func (h Harness) Diff(ctx context.Context, o ...Option) (bool, error) { // 0 - Succeeded, diff is empty (no changes) // 1 - Errored // 2 - Succeeded, there is a diff + timer := metrics.NewTerraformOperationTimer(filepath.Base(h.Dir), "plan") log, err := runCommand(ctx, cmd) switch cmd.ProcessState.ExitCode() { case 1: + timer.RecordFailure() ee := &exec.ExitError{} errors.As(err, &ee) if h.EnableTerraformCLILogging { h.Logger.Info(string(ee.Stderr), "operation", "plan") } case 2: + timer.RecordSuccess() if h.EnableTerraformCLILogging { h.Logger.Info(string(log), "operation", "plan") } return true, nil + default: + timer.RecordSuccess() } return false, Classify(err) } // Apply a Terraform configuration. -func (h Harness) Apply(ctx context.Context, o ...Option) error { +func (h Harness) Apply(ctx context.Context, ws string, o ...Option) error { ao := &options{} for _, fn := range o { fn(ao) @@ -611,14 +625,21 @@ func (h Harness) Apply(ctx context.Context, o ...Option) error { // In case of terraform apply // 0 - Succeeded // Non Zero output - Errored - - log, err := runCommand(ctx, cmd) + f, err := os.Create(filepath.Join("/logs", ws)) + if err != nil { + return err + } + defer f.Close() + timer := metrics.NewTerraformOperationTimer(filepath.Base(h.Dir), "apply") + log, err := runCommandv2(ctx, cmd, f) switch cmd.ProcessState.ExitCode() { case 0: + timer.RecordSuccess() if h.EnableTerraformCLILogging { h.Logger.Info(string(log), "operation", "apply") } default: + timer.RecordFailure() ee := &exec.ExitError{} errors.As(err, &ee) if h.EnableTerraformCLILogging { @@ -629,7 +650,7 @@ func (h Harness) Apply(ctx context.Context, o ...Option) error { } // Destroy a Terraform configuration. -func (h Harness) Destroy(ctx context.Context, o ...Option) error { +func (h Harness) Destroy(ctx context.Context, ws string, o ...Option) error { do := &options{} for _, fn := range o { fn(do) @@ -652,18 +673,25 @@ func (h Harness) Destroy(ctx context.Context, o ...Option) error { rwmutex.RLock() defer rwmutex.RUnlock() } - - log, err := runCommand(ctx, cmd) + f, err := os.Create(filepath.Join("/logs", ws)) + if err != nil { + return err + } + defer f.Close() + timer := metrics.NewTerraformOperationTimer(filepath.Base(h.Dir), "destroy") + log, err := runCommandv2(ctx, cmd, f) // In case of terraform destroy // 0 - Succeeded // Non Zero output - Errored switch cmd.ProcessState.ExitCode() { case 0: + timer.RecordSuccess() if h.EnableTerraformCLILogging { h.Logger.Info(string(log), "operation", "destroy") } default: + timer.RecordFailure() ee := &exec.ExitError{} errors.As(err, &ee) if h.EnableTerraformCLILogging { @@ -705,3 +733,32 @@ func runCommand(ctx context.Context, c *exec.Cmd) ([]byte, error) { return res.out, res.err } } + +// runCommand executes the requested command and sends the process SIGTERM if the context finishes before the command +func runCommandv2(ctx context.Context, c *exec.Cmd, f io.Writer) ([]byte, error) { + ch := make(chan cmdResult, 1) + go func() { + defer close(ch) + c.Stderr = f + c.Stdout = f + e := c.Run() + ch <- cmdResult{err: e} + }() + select { + case <-ctx.Done(): + err := ctx.Err() + // This could be container termination or the reconciliation deadline was exceeded. Either way send a + // SIGTERM to the running process and wait for either the command to finish or the process to get killed. + e := c.Process.Signal(syscall.SIGTERM) + if e != nil { + return nil, errors.Wrap(errors.Wrap(err, errRunCommand), errors.Wrap(e, errSigTerm).Error()) + } + e = c.Wait() + if e != nil { + return nil, errors.Wrap(errors.Wrap(err, errRunCommand), errors.Wrap(e, errWaitTerm).Error()) + } + return nil, errors.Wrap(err, errRunCommand) + case res := <-ch: + return res.out, res.err + } +} diff --git a/pkg/metrics/github.go b/pkg/metrics/github.go new file mode 100644 index 0000000..c13aebb --- /dev/null +++ b/pkg/metrics/github.go @@ -0,0 +1,15 @@ +package metrics + +import ( + "time" +) + +// RecordGitHubAPICall records a GitHub API call with its outcome. +func RecordGitHubAPICall(workspace, operation, status string, duration time.Duration) { + if workspace == "" { + workspace = "unknown" + } + + GitHubAPIRequestsTotal.WithLabelValues(workspace, operation, status).Inc() + GitHubAPIRequestDuration.WithLabelValues(workspace, operation).Observe(duration.Seconds()) +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go new file mode 100644 index 0000000..2012f27 --- /dev/null +++ b/pkg/metrics/metrics.go @@ -0,0 +1,73 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +var ( + // GitHub API metrics + GitHubAPIRequestsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "terraform_provider_github_api_requests_total", + Help: "Total number of GitHub API requests made by the provider", + }, + []string{"workspace", "operation", "status"}, + ) + + GitHubAPIRequestDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "terraform_provider_github_api_request_duration_seconds", + Help: "Duration of GitHub API requests in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"workspace", "operation"}, + ) + + // Module fetch metrics (go-getter) + ModuleFetchTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "terraform_provider_module_fetch_total", + Help: "Total number of remote module fetch operations", + }, + []string{"workspace", "module_source", "status"}, + ) + + ModuleFetchDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "terraform_provider_module_fetch_duration_seconds", + Help: "Duration of remote module fetch operations in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"workspace", "module_source"}, + ) + + // Terraform CLI operation metrics + TerraformOperationsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "terraform_provider_terraform_operations_total", + Help: "Total number of Terraform CLI operations performed", + }, + []string{"workspace", "operation", "status"}, + ) + + TerraformOperationDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "terraform_provider_terraform_operation_duration_seconds", + Help: "Duration of Terraform CLI operations in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"workspace", "operation"}, + ) +) + +func init() { + metrics.Registry.MustRegister( + GitHubAPIRequestsTotal, + GitHubAPIRequestDuration, + ModuleFetchTotal, + ModuleFetchDuration, + TerraformOperationsTotal, + TerraformOperationDuration, + ) +} diff --git a/pkg/metrics/module.go b/pkg/metrics/module.go new file mode 100644 index 0000000..ec7d7d2 --- /dev/null +++ b/pkg/metrics/module.go @@ -0,0 +1,77 @@ +package metrics + +import ( + "net/url" + "strings" + "time" +) + +// ModuleFetchTimer helps with timing module fetch operations. +type ModuleFetchTimer struct { + Workspace string + ModuleSource string + StartTime time.Time +} + +// NewModuleFetchTimer creates a new timer for a module fetch operation. +func NewModuleFetchTimer(workspace, moduleSource string) *ModuleFetchTimer { + if workspace == "" { + workspace = "unknown" + } + return &ModuleFetchTimer{ + Workspace: workspace, + ModuleSource: ExtractModuleSource(moduleSource), + StartTime: time.Now(), + } +} + +// RecordSuccess records a successful module fetch. +func (t *ModuleFetchTimer) RecordSuccess() { + t.record("success") +} + +// RecordFailure records a failed module fetch. +func (t *ModuleFetchTimer) RecordFailure() { + t.record("failure") +} + +func (t *ModuleFetchTimer) record(status string) { + duration := time.Since(t.StartTime) + ModuleFetchTotal.WithLabelValues(t.Workspace, t.ModuleSource, status).Inc() + ModuleFetchDuration.WithLabelValues(t.Workspace, t.ModuleSource).Observe(duration.Seconds()) +} + +// ExtractModuleSource extracts a clean label from a go-getter source URL. +// For git URLs: "git::https://github.com/owner/repo.git//subdir" -> "owner/repo" +// For other URLs: returns the host + path without query params. +func ExtractModuleSource(src string) string { + if src == "" { + return "unknown" + } + + // go-getter uses "type::url" format + cleaned := src + if idx := strings.Index(src, "::"); idx >= 0 { + cleaned = src[idx+2:] + } + + // Remove subdirectory specifier (//subdir) + if idx := strings.Index(cleaned, "//"); idx >= 0 { + cleaned = cleaned[:idx] + } + + // Remove .git suffix + cleaned = strings.TrimSuffix(cleaned, ".git") + + // Try to parse as URL and extract host/path + if u, err := url.Parse(cleaned); err == nil && u.Host != "" { + path := strings.Trim(u.Path, "/") + parts := strings.Split(path, "/") + if len(parts) >= 2 { + return parts[0] + "/" + parts[1] + } + return u.Host + "/" + path + } + + return cleaned +} diff --git a/pkg/metrics/terraform.go b/pkg/metrics/terraform.go new file mode 100644 index 0000000..f749d25 --- /dev/null +++ b/pkg/metrics/terraform.go @@ -0,0 +1,40 @@ +package metrics + +import ( + "time" +) + +// TerraformOperationTimer helps with timing Terraform CLI operations. +type TerraformOperationTimer struct { + Workspace string + Operation string + StartTime time.Time +} + +// NewTerraformOperationTimer creates a new timer for a Terraform CLI operation. +func NewTerraformOperationTimer(workspace, operation string) *TerraformOperationTimer { + if workspace == "" { + workspace = "unknown" + } + return &TerraformOperationTimer{ + Workspace: workspace, + Operation: operation, + StartTime: time.Now(), + } +} + +// RecordSuccess records a successful Terraform operation. +func (t *TerraformOperationTimer) RecordSuccess() { + t.record("success") +} + +// RecordFailure records a failed Terraform operation. +func (t *TerraformOperationTimer) RecordFailure() { + t.record("failure") +} + +func (t *TerraformOperationTimer) record(status string) { + duration := time.Since(t.StartTime) + TerraformOperationsTotal.WithLabelValues(t.Workspace, t.Operation, status).Inc() + TerraformOperationDuration.WithLabelValues(t.Workspace, t.Operation).Observe(duration.Seconds()) +}