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
24 changes: 24 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
1 change: 1 addition & 0 deletions cmd/provider/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions gitconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[credential]
helper = store --file=$GIT_CRED_DIR/.git-credentials
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
117 changes: 117 additions & 0 deletions internal/controller/workspace/github_app.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 17 additions & 5 deletions internal/controller/workspace/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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/<UUID> so it doesn't get removed or overwritten
// by the remote module source case
gitCredDir := filepath.Clean(filepath.Join("/tmp", dir))
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
22 changes: 11 additions & 11 deletions internal/controller/workspace/workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
Expand All @@ -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 },
},
},
Expand All @@ -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{
Expand Down Expand Up @@ -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{
Expand All @@ -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),
Expand Down
Loading