diff --git a/README.md b/README.md index b1bdada4..5b107efc 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ Please see the [relevant unit test cases](https://github.com/helmfile/vals/blob/ - [Terraform in S3 bucket (tfstates3)](#terraform-in-s3-bucket-tfstates3) - [Terraform in AzureRM Blob storage (tfstateazurerm)](#terraform-in-azurerm-blob-storage-tfstateazurerm) - [Terraform in Terraform Cloud / Terraform Enterprise (tfstateremote)](#terraform-in-terraform-cloud--terraform-enterprise-tfstateremote) + - [Terraform in GitLab (tfstategitlab)](#terraform-in-gitlab-tfstategitlab) - [SOPS](#sops) - [Keychain](#keychain) - [Echo](#echo) @@ -607,6 +608,37 @@ which is equivalent to the following input for `vals`: $ echo 'foo: ref+tfstateremote://app.terraform.io/myorg/myworkspace/output.virtual_network.name' | vals eval -f - ``` +### Terraform in GitLab (tfstategitlab) + +- `ref+tfstategitlab://gitlab.com/api/v4/projects/{project_id}/terraform/state/{state_name}/RESOURCE_NAME` + +Examples: + +- `ref+tfstategitlab://gitlab.com/api/v4/projects/123/terraform/state/default/aws_vpc.main.id` +- `ref+tfstategitlab://my-gitlab.example.com/api/v4/projects/456/terraform/state/production/output.database_url` + +It allows to use Terraform state stored in GitLab using the GitLab Terraform state API. You can try to read the state with command (with exported variable `GITLAB_TOKEN` or `TFE_TOKEN`): + +``` +$ tfstate-lookup -s https://gitlab.com/api/v4/projects/123/terraform/state/default aws_vpc.main.id +``` + +which is equivalent to the following input for `vals`: + +``` +$ echo 'foo: ref+tfstategitlab://gitlab.com/api/v4/projects/123/terraform/state/default/aws_vpc.main.id' | vals eval -f - +``` + +#### Authentication + +The provider supports several authentication methods: + +- **GitLab Private Token**: Set `GITLAB_TOKEN` environment variable with your GitLab personal access token +- **TFE Token**: Set `TFE_TOKEN` environment variable (for compatibility with Terraform Enterprise workflows) +- **Basic Authentication**: Set both `TFE_USER` and `TFE_TOKEN` environment variables for HTTP basic authentication + +The GitLab project must have the Terraform state feature enabled and the token must have sufficient permissions to read the Terraform state. + ### SOPS - The whole content of a SOPS-encrypted file: `ref+sops://base64_data_or_path_to_file?key_type=[filepath|base64]&format=[binary|dotenv|yaml]` diff --git a/pkg/providers/tfstate/tfstate.go b/pkg/providers/tfstate/tfstate.go index 9e438b34..d32df136 100644 --- a/pkg/providers/tfstate/tfstate.go +++ b/pkg/providers/tfstate/tfstate.go @@ -3,6 +3,9 @@ package tfstate import ( "context" "fmt" + "io" + "net/http" + "net/url" "os" "strings" "sync" @@ -16,6 +19,8 @@ type provider struct { backend string awsProfile string azSubscriptionId string + gitlabToken string + tfeToken string } func New(cfg api.StaticConfig, backend string) *provider { @@ -23,6 +28,8 @@ func New(cfg api.StaticConfig, backend string) *provider { p.backend = backend p.awsProfile = cfg.String("aws_profile") p.azSubscriptionId = cfg.String("az_subscription_id") + p.gitlabToken = cfg.String("gitlab_token") + p.tfeToken = cfg.String("tfe_token") return p } @@ -59,6 +66,58 @@ var ( tfstateMu sync.Mutex ) +// readGitLabHTTP reads Terraform state from GitLab HTTP API with authentication +func (p *provider) readGitLabHTTP(ctx context.Context, url string) (io.ReadCloser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + // Try to get GitLab token from provider config, then from environment variables + token := p.gitlabToken + if token == "" { + token = os.Getenv("GITLAB_TOKEN") + } + if token == "" { + token = os.Getenv("TFE_TOKEN") + } + + // Check for TFE_USER for basic authentication + tfeUser := os.Getenv("TFE_USER") + + if token != "" { + if tfeUser != "" { + // Use basic authentication with TFE_USER and TFE_TOKEN + req.SetBasicAuth(tfeUser, token) + } else { + // Use GitLab private token authentication + req.Header.Set("PRIVATE-TOKEN", token) + } + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp.Body, nil +} + +// isGitLabURL checks if the URL is a GitLab Terraform state API URL +func (p *provider) isGitLabURL(urlStr string) bool { + // Check if it's an HTTP/HTTPS URL with GitLab API pattern + u, err := url.Parse(urlStr) + if err != nil { + return false + } + + // Check for GitLab API pattern in path + // GitLab Terraform state API URLs typically look like: + // https://gitlab.com/api/v4/projects/PROJECT_ID/terraform/state/STATE_NAME + return (u.Scheme == "http" || u.Scheme == "https") && + strings.Contains(u.Path, "/api/v4/projects/") && + strings.Contains(u.Path, "/terraform/state/") +} + // Read state either from file or from backend func (p *provider) ReadTFState(f, k string) (*tfstate.TFState, error) { tfstateMu.Lock() @@ -87,6 +146,30 @@ func (p *provider) ReadTFState(f, k string) (*tfstate.TFState, error) { }() } + // Allow setting GitLab token via provider config + if p.gitlabToken != "" { + v := os.Getenv("GITLAB_TOKEN") + err := os.Setenv("GITLAB_TOKEN", p.gitlabToken) + if err != nil { + return nil, fmt.Errorf("setting GITLAB_TOKEN envvar: %w", err) + } + defer func() { + _ = os.Setenv("GITLAB_TOKEN", v) + }() + } + + // Allow setting TFE token via provider config + if p.tfeToken != "" { + v := os.Getenv("TFE_TOKEN") + err := os.Setenv("TFE_TOKEN", p.tfeToken) + if err != nil { + return nil, fmt.Errorf("setting TFE_TOKEN envvar: %w", err) + } + defer func() { + _ = os.Setenv("TFE_TOKEN", v) + }() + } + switch p.backend { case "": state, err := tfstate.ReadFile(context.TODO(), f) @@ -94,8 +177,41 @@ func (p *provider) ReadTFState(f, k string) (*tfstate.TFState, error) { return nil, fmt.Errorf("reading tfstate for %s: %w", k, err) } return state, nil + case "gitlab": + // For GitLab, f contains the full URL path + fullURL := "https://" + f + if p.isGitLabURL(fullURL) { + // Read GitLab state with authentication + src, err := p.readGitLabHTTP(context.TODO(), fullURL) + if err != nil { + return nil, fmt.Errorf("reading GitLab tfstate for %s: %w", k, err) + } + defer func() { + _ = src.Close() + }() + return tfstate.Read(context.TODO(), src) + } + // Fall back to regular HTTP reading + state, err := tfstate.ReadURL(context.TODO(), fullURL) + if err != nil { + return nil, fmt.Errorf("reading tfstate for %s: %w", k, err) + } + return state, nil default: url := p.backend + "://" + f + + // Check if this is a GitLab URL even for other backends (like remote) + if p.isGitLabURL(url) { + src, err := p.readGitLabHTTP(context.TODO(), url) + if err != nil { + return nil, fmt.Errorf("reading GitLab tfstate for %s: %w", k, err) + } + defer func() { + _ = src.Close() + }() + return tfstate.Read(context.TODO(), src) + } + state, err := tfstate.ReadURL(context.TODO(), url) if err != nil { return nil, fmt.Errorf("reading tfstate for %s: %w", k, err) diff --git a/pkg/stringprovider/stringprovider.go b/pkg/stringprovider/stringprovider.go index 98d4506f..4d2248ce 100644 --- a/pkg/stringprovider/stringprovider.go +++ b/pkg/stringprovider/stringprovider.go @@ -57,6 +57,8 @@ func New(l *log.Logger, provider api.StaticConfig) (api.LazyLoadedStringProvider return tfstate.New(provider, "azurerm"), nil case "tfstateremote": return tfstate.New(provider, "remote"), nil + case "tfstategitlab": + return tfstate.New(provider, "gitlab"), nil case "azurekeyvault": return azurekeyvault.New(provider), nil case "gitlab": diff --git a/vals.go b/vals.go index 36426419..e7a750e4 100644 --- a/vals.go +++ b/vals.go @@ -91,6 +91,7 @@ const ( ProviderTFStateS3 = "tfstates3" ProviderTFStateAzureRM = "tfstateazurerm" ProviderTFStateRemote = "tfstateremote" + ProviderTFStateGitLab = "tfstategitlab" ProviderAzureKeyVault = "azurekeyvault" ProviderEnvSubst = "envsubst" ProviderKeychain = "keychain" @@ -240,6 +241,9 @@ func (r *Runtime) prepare() (*expansion.ExpandRegexMatch, error) { case ProviderTFStateRemote: p := tfstate.New(conf, "remote") return p, nil + case ProviderTFStateGitLab: + p := tfstate.New(conf, "gitlab") + return p, nil case ProviderAzureKeyVault: p := azurekeyvault.New(conf) return p, nil