Skip to content
Merged
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
129 changes: 103 additions & 26 deletions cloudsmith/resource_repository_upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"os"
"strings"
"time"

Expand Down Expand Up @@ -49,13 +50,16 @@ const (
UpstreamType = "upstream_type"
UpstreamUrl = "upstream_url"
VerifySsl = "verify_ssl"
AuthCertificateKey = "auth_certificate_key"
AuthCertificate = "auth_certificate"
)

var (
authModes = []string{
"None",
"Username and Password",
"Token",
"Certificate and Key",
}
upstreamModes = []string{
"Proxy Only",
Expand Down Expand Up @@ -113,6 +117,43 @@ func importUpstream(_ context.Context, d *schema.ResourceData, _ interface{}) ([
return []*schema.ResourceData{d}, nil
}

// readCertificateFiles reads the certificate and key files for mTLS authentication
// Returns pointers to the certificate and key contents, and any error encountered
func readCertificateFiles(d *schema.ResourceData) (cert, key *string, err error) {
if certPath := optionalString(d, AuthCertificate); certPath != nil {
certContent, err := os.ReadFile(*certPath)
if err != nil {
return nil, nil, fmt.Errorf("error reading auth certificate file: %w", err)
}
// Remove any trailing whitespace and ensure proper PEM format
certStr := strings.TrimSpace(string(certContent))
if !strings.HasPrefix(certStr, "-----BEGIN CERTIFICATE-----") {
return nil, nil, fmt.Errorf("invalid certificate format: must be a PEM encoded certificate")
}
cert = &certStr
}

if certKeyPath := optionalString(d, AuthCertificateKey); certKeyPath != nil {
certKeyContent, err := os.ReadFile(*certKeyPath)
if err != nil {
return nil, nil, fmt.Errorf("error reading auth certificate key file: %w", err)
}
// Remove any trailing whitespace and ensure proper PEM format
certKeyStr := strings.TrimSpace(string(certKeyContent))
if !strings.HasPrefix(certKeyStr, "-----BEGIN") || !strings.Contains(certKeyStr, "PRIVATE KEY-----") {
return nil, nil, fmt.Errorf("invalid private key format: must be a PEM encoded private key")
}
key = &certKeyStr
}

// Both certificate and key must be provided together
if (cert != nil && key == nil) || (cert == nil && key != nil) {
return nil, nil, fmt.Errorf("both auth_certificate and auth_certificate_key must be provided when using Certificate and Key authentication")
}

return cert, key, nil
}

func resourceRepositoryUpstreamCreate(d *schema.ResourceData, m interface{}) error {
pc := m.(*providerConfig)

Expand Down Expand Up @@ -217,22 +258,34 @@ func resourceRepositoryUpstreamCreate(d *schema.ResourceData, m interface{}) err
upstream, resp, err = pc.APIClient.ReposApi.ReposUpstreamDebCreateExecute(req)
case Docker:
req := pc.APIClient.ReposApi.ReposUpstreamDockerCreate(pc.Auth, namespace, repository)

// Read certificate files for mTLS authentication (Docker only for now)
authCert, authCertKey, err := readCertificateFiles(d)
if err != nil {
return err
}

req = req.Data(cloudsmith.DockerUpstreamRequest{
AuthMode: authMode,
AuthSecret: authSecret,
AuthUsername: authUsername,
ExtraHeader1: extraHeader1,
ExtraHeader2: extraHeader2,
ExtraValue1: extraValue1,
ExtraValue2: extraValue2,
IsActive: isActive,
Mode: mode,
Name: name,
Priority: priority,
UpstreamUrl: upstreamUrl,
VerifySsl: verifySsl,
AuthMode: authMode,
AuthSecret: authSecret,
AuthUsername: authUsername,
AuthCertificate: authCert,
AuthCertificateKey: authCertKey,
ExtraHeader1: extraHeader1,
ExtraHeader2: extraHeader2,
ExtraValue1: extraValue1,
ExtraValue2: extraValue2,
IsActive: isActive,
Mode: mode,
Name: name,
Priority: priority,
UpstreamUrl: upstreamUrl,
VerifySsl: verifySsl,
})
upstream, resp, err = pc.APIClient.ReposApi.ReposUpstreamDockerCreateExecute(req)
if err != nil {
return err
}
case Helm:
req := pc.APIClient.ReposApi.ReposUpstreamHelmCreate(pc.Auth, namespace, repository)
req = req.Data(cloudsmith.HelmUpstreamRequest{
Expand Down Expand Up @@ -622,22 +675,34 @@ func resourceRepositoryUpstreamUpdate(d *schema.ResourceData, m interface{}) err
upstream, _, err = pc.APIClient.ReposApi.ReposUpstreamDebUpdateExecute(req)
case Docker:
req := pc.APIClient.ReposApi.ReposUpstreamDockerUpdate(pc.Auth, namespace, repository, slugPerm)

// Read certificate files for mTLS authentication (Docker only for now)
authCert, authCertKey, err := readCertificateFiles(d)
if err != nil {
return err
}

req = req.Data(cloudsmith.DockerUpstreamRequest{
AuthMode: authMode,
AuthSecret: authSecret,
AuthUsername: authUsername,
ExtraHeader1: extraHeader1,
ExtraHeader2: extraHeader2,
ExtraValue1: extraValue1,
ExtraValue2: extraValue2,
IsActive: isActive,
Mode: mode,
Name: name,
Priority: priority,
UpstreamUrl: upstreamUrl,
VerifySsl: verifySsl,
AuthMode: authMode,
AuthSecret: authSecret,
AuthUsername: authUsername,
AuthCertificate: authCert,
AuthCertificateKey: authCertKey,
ExtraHeader1: extraHeader1,
ExtraHeader2: extraHeader2,
ExtraValue1: extraValue1,
ExtraValue2: extraValue2,
IsActive: isActive,
Mode: mode,
Name: name,
Priority: priority,
UpstreamUrl: upstreamUrl,
VerifySsl: verifySsl,
})
upstream, _, err = pc.APIClient.ReposApi.ReposUpstreamDockerUpdateExecute(req)
if err != nil {
return err
}
case Helm:
req := pc.APIClient.ReposApi.ReposUpstreamHelmUpdate(pc.Auth, namespace, repository, slugPerm)
req = req.Data(cloudsmith.HelmUpstreamRequest{
Expand Down Expand Up @@ -922,6 +987,18 @@ func resourceRepositoryUpstream() *schema.Resource {
Optional: true,
ValidateFunc: validation.StringIsNotEmpty,
},
AuthCertificate: {
Type: schema.TypeString,
Description: "Path to the X.509 Certificate file to use for mTLS authentication against the upstream (Docker only)",
Optional: true,
ValidateFunc: validation.StringIsNotEmpty,
},
AuthCertificateKey: {
Type: schema.TypeString,
Description: "Path to the Certificate key file to use for mTLS authentication against the upstream (Docker only)",
Optional: true,
ValidateFunc: validation.StringIsNotEmpty,
},
Component: {
Type: schema.TypeString,
Description: "(deb only) The component to fetch from the upstream.",
Expand Down
134 changes: 128 additions & 6 deletions cloudsmith/resource_repository_upstream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@
package cloudsmith

import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"math/big"
"net/http"
"os"
"testing"
"time"

"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
Expand Down Expand Up @@ -222,15 +230,15 @@ resource "cloudsmith_repository_upstream" "ubuntu" {
func TestAccRepositoryUpstreamDocker_basic(t *testing.T) {
t.Parallel()

const dockerUpstreamResourceName = "cloudsmith_repository_upstream.dockerhub"
const dockerUpstreamResourceName = "cloudsmith_repository_upstream.fakedocker"

testAccRepositoryPythonUpstreamConfigBasic := fmt.Sprintf(`
resource "cloudsmith_repository" "test" {
name = "terraform-acc-test-upstream-docker"
namespace = "%s"
}

resource "cloudsmith_repository_upstream" "dockerhub" {
resource "cloudsmith_repository_upstream" "fakedocker" {
namespace = cloudsmith_repository.test.namespace
repository = cloudsmith_repository.test.slug
name = cloudsmith_repository.test.name
Expand All @@ -248,7 +256,7 @@ resource "cloudsmith_repository_upstream" "dockerhub" {
namespace = "%s"
}

resource "cloudsmith_repository_upstream" "dockerhub" {
resource "cloudsmith_repository_upstream" "fakedocker" {
auth_mode = "Username and Password"
auth_secret = "SuperSecretPassword123!"
auth_username = "jonny.tables"
Expand All @@ -268,6 +276,38 @@ resource "cloudsmith_repository_upstream" "dockerhub" {
}
`, namespace)

// Generate test certificates for mTLS authentication
certPath, keyPath, err := generateTestCertificateAndKey()
if err != nil {
t.Fatalf("Failed to generate test certificates: %v", err)
}
defer func() {
os.Remove(certPath)
os.Remove(keyPath)
}()

testAccRepositoryPythonUpstreamConfigCert := fmt.Sprintf(`
resource "cloudsmith_repository" "test" {
name = "terraform-acc-test-upstream-docker"
namespace = "%s"
}

resource "cloudsmith_repository_upstream" "fakedocker" {
auth_mode = "Certificate and Key"
auth_certificate = "%s"
auth_certificate_key = "%s"
is_active = false
mode = "Cache and Proxy"
name = cloudsmith_repository.test.name
namespace = cloudsmith_repository.test.namespace
priority = 5
repository = cloudsmith_repository.test.slug
upstream_type = "docker"
upstream_url = "https://fake.docker.io"
verify_ssl = true
}
`, namespace, certPath, keyPath)

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
Expand Down Expand Up @@ -309,8 +349,23 @@ resource "cloudsmith_repository_upstream" "dockerhub" {
),
},
{
ResourceName: dockerUpstreamResourceName,
ImportState: true,
Config: testAccRepositoryPythonUpstreamConfigCert,
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(dockerUpstreamResourceName, AuthMode, "Certificate and Key"),
resource.TestCheckResourceAttr(dockerUpstreamResourceName, AuthCertificate, certPath),
resource.TestCheckResourceAttr(dockerUpstreamResourceName, AuthCertificateKey, keyPath),
resource.TestCheckResourceAttr(dockerUpstreamResourceName, IsActive, "false"),
resource.TestCheckResourceAttr(dockerUpstreamResourceName, Priority, "5"),
),
},
{
ResourceName: dockerUpstreamResourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{
"auth_certificate",
"auth_certificate_key",
},
ImportStateIdFunc: func(s *terraform.State) (string, error) {
resourceState := s.RootModule().Resources[dockerUpstreamResourceName]
return fmt.Sprintf(
Expand All @@ -321,7 +376,6 @@ resource "cloudsmith_repository_upstream" "dockerhub" {
resourceState.Primary.Attributes[SlugPerm],
), nil
},
ImportStateVerify: true,
},
},
})
Expand Down Expand Up @@ -1254,3 +1308,71 @@ func testAccRepositoryUpstreamCheckDestroy(resourceName string) resource.TestChe
return nil
}
}
func generateTestCertificateAndKey() (certPath string, keyPath string, err error) {
// Generate a private key
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", "", fmt.Errorf("failed to generate private key: %w", err)
}

// Create a temporary file for the private key
keyFile, err := os.CreateTemp("", "test-key-*.pem")
if err != nil {
return "", "", fmt.Errorf("failed to create temp key file: %w", err)
}
keyPath = keyFile.Name()

// Encode and write the private key
keyPEM := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}
if err := pem.Encode(keyFile, keyPEM); err != nil {
os.Remove(keyPath)
return "", "", fmt.Errorf("failed to write private key: %w", err)
}
keyFile.Close()

// Create certificate template
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "test.cloudsmith.io",
Organization: []string{"Cloudsmith Test"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour * 24), // 24 hour validity
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}

// Create the certificate
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
os.Remove(keyPath)
return "", "", fmt.Errorf("failed to create certificate: %w", err)
}

// Create a temporary file for the certificate
certFile, err := os.CreateTemp("", "test-cert-*.pem")
if err != nil {
os.Remove(keyPath)
return "", "", fmt.Errorf("failed to create temp cert file: %w", err)
}
certPath = certFile.Name()

// Encode and write the certificate
certPEM := &pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
}
if err := pem.Encode(certFile, certPEM); err != nil {
os.Remove(keyPath)
os.Remove(certPath)
return "", "", fmt.Errorf("failed to write certificate: %w", err)
}
certFile.Close()

return certPath, keyPath, nil
}
4 changes: 3 additions & 1 deletion docs/resources/repository_upstream.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,11 @@ The following arguments are supported:

| Argument | Required | Type | Enumeration | Description |
|:-----------------------:|:--------:|:------------:|:-----------------------------------------------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|
| `auth_mode` | N | string | `"None"`<br>`"Username and Password"`<br>`"Token"` | The authentication mode to use when accessing the upstream. |
| `auth_mode` | N | string | `"None"`<br>`"Username and Password"`<br>`"Token"`<br>`"Certificate and Key"` | The authentication mode to use when accessing the upstream. |
| `auth_secret` | N | string | N/A | Used in conjunction with an `auth_mode` of `"Username and Password"` or `"Token"` to hold the password or token used when accessing the upstream. |
| `auth_username` | N | string | N/A | Used only in conjunction with an `auth_mode` of `"Username and Password"` to declare the username used when accessing the upstream. |
| `auth_certificate` | N | string | N/A | Used only in conjunction with an `auth_mode` of `"Certificate and Key"` to specify the path to the certificate file for mTLS authentication. |
| `auth_certificate_key` | N | string | N/A | Used only in conjunction with an `auth_mode` of `"Certificate and Key"` to specify the path to the certificate key file for mTLS authentication. |
| `component` | N | string | N/A | Used only in conjunction with an `upstream_type` of `"deb"` to declare the [component](https://wiki.debian.org/DebianRepository/Format#Components) to fetch from the upstream. |
| `distro_version` | N | string | N/A | Used only in conjunction with an `upstream_type` of `"rpm"` to declare the distribution/version that packages found on this upstream will be associated with. |
| `distro_versions` | N | list<string> | N/A | Used only in conjunction with an `upstream_type` of `"deb"` to declare the array of distributions/versions that packages found on this upstream will be associated with. |
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/cloudsmith-io/terraform-provider-cloudsmith
go 1.19

require (
github.com/cloudsmith-io/cloudsmith-api-go v0.0.42-0.20241129202450-bd5381591ce5
github.com/cloudsmith-io/cloudsmith-api-go v0.0.42
github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320
github.com/hashicorp/terraform-plugin-sdk/v2 v2.24.1
github.com/samber/lo v1.36.0
Expand Down
Loading