├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── examples ├── provider │ └── provider.tf ├── resources │ └── oci_append │ │ └── resource.tf └── README.md ├── terraform-registry-manifest.json ├── tools └── tools.go ├── .chainguard └── source.yaml ├── docs ├── index.md ├── resources │ ├── tags.md │ ├── tag.md │ └── append.md ├── data-sources │ ├── exec_test.md │ └── structure_test.md └── functions │ ├── parse.md │ └── get.md ├── .gitignore ├── .golangci.yml ├── main.go ├── README.md ├── internal └── provider │ ├── provider_test.go │ ├── tag_resource_test.go │ ├── types.go │ ├── tags_resource_test.go │ ├── provider.go │ ├── parse_function.go │ ├── parse_function_test.go │ ├── tag_resource.go │ ├── exec_test_data_source_test.go │ ├── tags_resource.go │ ├── get_function_test.go │ ├── get_function.go │ ├── exec_test_data_source.go │ ├── structure_test_data_source.go │ ├── structure_test_data_source_test.go │ ├── append_resource_test.go │ └── append_resource.go ├── pkg ├── validators │ ├── url_validator.go │ ├── repo_validator.go │ ├── json_validator.go │ ├── tag_validator.go │ ├── digest_validator.go │ └── ref_validator.go └── structure │ ├── env.go │ └── structure.go ├── testing └── testing.go ├── .goreleaser.yml ├── cmd └── check │ └── main.go ├── go.mod └── LICENSE /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @hashicorp/terraform-devex 2 | -------------------------------------------------------------------------------- /examples/provider/provider.tf: -------------------------------------------------------------------------------- 1 | provider "oci" { 2 | # example configuration here 3 | } 4 | -------------------------------------------------------------------------------- /examples/resources/oci_append/resource.tf: -------------------------------------------------------------------------------- 1 | resource "oci_append" "example" { 2 | base_image = "alpine:3.18" 3 | } 4 | -------------------------------------------------------------------------------- /terraform-registry-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "metadata": { 4 | "protocol_versions": ["6.0"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package tools 4 | 5 | import ( 6 | // Documentation generation 7 | _ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs" 8 | ) 9 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | HashiCorp Community Guidelines apply to you when interacting with the community here on GitHub and contributing code. 4 | 5 | Please read the full text at https://www.hashicorp.com/community-guidelines 6 | -------------------------------------------------------------------------------- /.chainguard/source.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | spec: 5 | authorities: 6 | - keyless: 7 | # allow commits signed by users using GitHub or Google OIDC 8 | identities: 9 | - issuer: https://accounts.google.com 10 | - issuer: https://github.com/login/oauth 11 | - key: 12 | # allow commits signed by GitHub, e.g. the UI 13 | kms: https://github.com/web-flow.gpg 14 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "oci Provider" 4 | description: |- 5 | 6 | --- 7 | 8 | # oci Provider 9 | 10 | 11 | 12 | ## Example Usage 13 | 14 | ```terraform 15 | provider "oci" { 16 | # example configuration here 17 | } 18 | ``` 19 | 20 | 21 | ## Schema 22 | 23 | ### Optional 24 | 25 | - `default_exec_timeout_seconds` (Number) Default timeout for exec tests 26 | - `skip_exec_tests` (Boolean) If true, skip oci_exec_test tests 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # See GitHub's documentation for more information on this file: 2 | # https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | - package-ecosystem: "gomod" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | groups: 14 | terraform: 15 | patterns: 16 | - "github.com/hashicorp/*" 17 | -------------------------------------------------------------------------------- /docs/resources/tags.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "oci_tags Resource - terraform-provider-oci" 4 | subcategory: "" 5 | description: |- 6 | Tag many digests with many tags. 7 | --- 8 | 9 | # oci_tags (Resource) 10 | 11 | Tag many digests with many tags. 12 | 13 | 14 | 15 | 16 | ## Schema 17 | 18 | ### Required 19 | 20 | - `repo` (String) Repository for the tags. 21 | - `tags` (Map of String) Map of tag -> digest to apply. 22 | 23 | ### Read-Only 24 | 25 | - `id` (String) The resulting fully-qualified image ref by digest (e.g. {repo}:tag@sha256:deadbeef). 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | check 2 | 3 | *.dll 4 | *.exe 5 | .DS_Store 6 | example.tf 7 | terraform.tfplan 8 | terraform.tfstate 9 | bin/ 10 | dist/ 11 | modules-dev/ 12 | website/.vagrant 13 | website/.bundle 14 | website/build 15 | website/node_modules 16 | .vagrant/ 17 | *.backup 18 | ./*.tfstate 19 | .terraform/ 20 | *.log 21 | *.bak 22 | *~ 23 | .*.swp 24 | .idea 25 | *.iml 26 | *.test 27 | *.iml 28 | 29 | # Ignore federated GCP credentials. 30 | gha-creds-*.json 31 | 32 | website/vendor 33 | 34 | # Test exclusions 35 | !command/test-fixtures/**/*.tfstate 36 | !command/test-fixtures/**/.terraform/ 37 | 38 | # Keep windows files with windows line endings 39 | *.winfile eol=crlf 40 | 41 | terraform-provider-oci 42 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This directory contains examples that are mostly used for documentation, but can also be run/tested manually via the Terraform CLI. 4 | 5 | The document generation tool looks for files in the following locations by default. All other *.tf files besides the ones mentioned below are ignored by the documentation tool. This is useful for creating examples that can run and/or ar testable even if some parts are not relevant for the documentation. 6 | 7 | * **provider/provider.tf** example file for the provider index page 8 | * **data-sources/`full data source name`/data-source.tf** example file for the named data source page 9 | * **resources/`full resource name`/resource.tf** example file for the named data source page 10 | -------------------------------------------------------------------------------- /docs/resources/tag.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "oci_tag Resource - terraform-provider-oci" 4 | subcategory: "" 5 | description: |- 6 | Tag an existing image by digest. 7 | --- 8 | 9 | # oci_tag (Resource) 10 | 11 | Tag an existing image by digest. 12 | 13 | 14 | 15 | 16 | ## Schema 17 | 18 | ### Required 19 | 20 | - `digest_ref` (String) Image ref by digest to apply the tag to. 21 | - `tag` (String) Tag to apply to the image. 22 | 23 | ### Read-Only 24 | 25 | - `id` (String) The resulting fully-qualified image ref by digest (e.g. {repo}:tag@sha256:deadbeef). 26 | - `tagged_ref` (String) The resulting fully-qualified image ref by digest (e.g. {repo}:tag@sha256:deadbeef). 27 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - durationcheck 6 | - errcheck 7 | - forcetypeassert 8 | - godot 9 | - ineffassign 10 | - makezero 11 | - misspell 12 | - nilerr 13 | - predeclared 14 | - staticcheck 15 | - unconvert 16 | - unparam 17 | - unused 18 | exclusions: 19 | generated: lax 20 | presets: 21 | - comments 22 | - common-false-positives 23 | - legacy 24 | - std-error-handling 25 | paths: 26 | - third_party$ 27 | - builtin$ 28 | - examples$ 29 | issues: 30 | max-issues-per-linter: 0 31 | max-same-issues: 0 32 | formatters: 33 | enable: 34 | - gofmt 35 | exclusions: 36 | generated: lax 37 | paths: 38 | - third_party$ 39 | - builtin$ 40 | - examples$ 41 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | 8 | "github.com/chainguard-dev/terraform-provider-oci/internal/provider" 9 | "github.com/hashicorp/terraform-plugin-framework/providerserver" 10 | ) 11 | 12 | //go:generate terraform fmt -recursive ./examples/. 13 | //go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs 14 | 15 | const version string = "dev" 16 | 17 | func main() { 18 | var debug bool 19 | 20 | flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") 21 | flag.Parse() 22 | 23 | err := providerserver.Serve(context.Background(), provider.New(version), providerserver.ServeOpts{ 24 | Address: "registry.terraform.io/chainguard-dev/oci", 25 | Debug: debug, 26 | }) 27 | if err != nil { 28 | log.Fatal(err.Error()) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Terraform Provider for OCI operations 2 | 3 | [![Tests](https://github.com/chainguard-dev/terraform-provider-oci/actions/workflows/test.yml/badge.svg)](https://github.com/chainguard-dev/terraform-provider-oci/actions/workflows/test.yml) 4 | 5 | 🚨 **This is a work in progress.** 🚨 6 | 7 | https://registry.terraform.io/providers/chainguard-dev/oci 8 | 9 | This provider is intended to provide some behavior similar to [`crane`](https://github.com/google/go-containerregistry/blob/main/cmd/crane/README.md). 10 | 11 | ## Developing the Provider 12 | 13 | To compile the provider, run `go install`. This will build the provider and put the provider binary in the `$GOPATH/bin` directory. 14 | 15 | To generate or update documentation, run `go generate`. 16 | 17 | In order to run the full suite of Acceptance tests, run: 18 | 19 | ```shell 20 | TF_ACC=1 go test ./internal/provider/... 21 | ``` 22 | -------------------------------------------------------------------------------- /internal/provider/provider_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/hashicorp/terraform-plugin-framework/providerserver" 7 | "github.com/hashicorp/terraform-plugin-go/tfprotov6" 8 | ) 9 | 10 | // testAccProtoV6ProviderFactories are used to instantiate a provider during 11 | // acceptance testing. The factory function will be invoked for every Terraform 12 | // CLI command executed to create a provider server to which the CLI can 13 | // reattach. 14 | var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ 15 | "oci": providerserver.NewProtocol6WithError(New("test")()), 16 | } 17 | 18 | func testAccPreCheck(t *testing.T) { 19 | // You can add code here to run prior to any test case execution, for example assertions 20 | // about the appropriate environment variables being set are common to see in a pre-check 21 | // function. 22 | } 23 | -------------------------------------------------------------------------------- /pkg/validators/url_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | 7 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 8 | ) 9 | 10 | // URLValidator is a string validator that checks that the string is a valid URL. 11 | type URLValidator struct{} 12 | 13 | var _ validator.String = URLValidator{} 14 | 15 | func (v URLValidator) Description(context.Context) string { return "value must be a valid URL" } 16 | func (v URLValidator) MarkdownDescription(ctx context.Context) string { return v.Description(ctx) } 17 | 18 | func (v URLValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { 19 | if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { 20 | return 21 | } 22 | val := req.ConfigValue.ValueString() 23 | if _, err := url.Parse(val); err != nil { 24 | resp.Diagnostics.AddError("Invalid url", err.Error()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/validators/repo_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-containerregistry/pkg/name" 7 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 8 | ) 9 | 10 | type RepoValidator struct{} 11 | 12 | var _ validator.String = RepoValidator{} 13 | 14 | func (v RepoValidator) Description(context.Context) string { 15 | return `value must be a valid OCI repository name (e.g., "example.com/image")` 16 | } 17 | func (v RepoValidator) MarkdownDescription(ctx context.Context) string { return v.Description(ctx) } 18 | 19 | func (v RepoValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { 20 | if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { 21 | return 22 | } 23 | val := req.ConfigValue.ValueString() 24 | if _, err := name.NewRepository(val); err != nil { 25 | resp.Diagnostics.AddError("Invalid OCI repository", err.Error()) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/validators/json_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 8 | ) 9 | 10 | // JSONValidator is a string validator that checks that the string is valid JSON. 11 | type JSONValidator struct{} 12 | 13 | var _ validator.String = JSONValidator{} 14 | 15 | func (v JSONValidator) Description(context.Context) string { return "value must be valid json" } 16 | func (v JSONValidator) MarkdownDescription(ctx context.Context) string { return v.Description(ctx) } 17 | 18 | func (v JSONValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { 19 | if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { 20 | return 21 | } 22 | val := req.ConfigValue.ValueString() 23 | 24 | var untyped interface{} 25 | if err := json.Unmarshal([]byte(val), &untyped); err != nil { 26 | resp.Diagnostics.AddError("Invalid json", err.Error()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/validators/tag_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-containerregistry/pkg/name" 7 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 8 | ) 9 | 10 | // TagValidator is a string validator that checks that the string is valid OCI reference by digest. 11 | type TagValidator struct{} 12 | 13 | var _ validator.String = DigestValidator{} 14 | 15 | func (v TagValidator) Description(context.Context) string { 16 | return `value must be a valid OCI tag element (e.g., "latest", "v1.2.3")` 17 | } 18 | func (v TagValidator) MarkdownDescription(ctx context.Context) string { return v.Description(ctx) } 19 | 20 | func (v TagValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { 21 | if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { 22 | return 23 | } 24 | if _, err := name.NewTag("example.com:" + req.ConfigValue.ValueString()); err != nil { 25 | resp.Diagnostics.AddError("Invalid OCI tag name", err.Error()) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/validators/digest_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-containerregistry/pkg/name" 7 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 8 | ) 9 | 10 | // DigestValidator is a string validator that checks that the string is valid OCI reference by digest. 11 | type DigestValidator struct{} 12 | 13 | var _ validator.String = DigestValidator{} 14 | 15 | func (v DigestValidator) Description(context.Context) string { 16 | return `value must be a valid OCI digest reference (e.g., "example.com/image@sha256:abcdef...")` 17 | } 18 | func (v DigestValidator) MarkdownDescription(ctx context.Context) string { return v.Description(ctx) } 19 | 20 | func (v DigestValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { 21 | if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { 22 | return 23 | } 24 | val := req.ConfigValue.ValueString() 25 | if _, err := name.NewDigest(val); err != nil { 26 | resp.Diagnostics.AddError("Invalid OCI digest", err.Error()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/validators/ref_validator.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-containerregistry/pkg/name" 7 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 8 | ) 9 | 10 | // RefValidator is a string validator that checks that the string is a valid OCI reference. 11 | type RefValidator struct{} 12 | 13 | var _ validator.String = RefValidator{} 14 | 15 | func (v RefValidator) Description(context.Context) string { 16 | return `value must be a valid OCI reference (e.g., "example.com/image:tag" or "example.com/image@sha256:abcdef...")` 17 | } 18 | func (v RefValidator) MarkdownDescription(ctx context.Context) string { return v.Description(ctx) } 19 | 20 | func (v RefValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { 21 | if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { 22 | return 23 | } 24 | val := req.ConfigValue.ValueString() 25 | if _, err := name.ParseReference(val); err != nil { 26 | resp.Diagnostics.AddError("Invalid image reference", err.Error()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /docs/data-sources/exec_test.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "oci_exec_test Data Source - terraform-provider-oci" 4 | subcategory: "" 5 | description: |- 6 | Exec test data source 7 | --- 8 | 9 | # oci_exec_test (Data Source) 10 | 11 | Exec test data source 12 | 13 | 14 | 15 | 16 | ## Schema 17 | 18 | ### Required 19 | 20 | - `digest` (String) Image digest to test 21 | - `script` (String) Script to run against the image 22 | 23 | ### Optional 24 | 25 | - `env` (List of Object) Environment variables for the test (see [below for nested schema](#nestedatt--env)) 26 | - `timeout_seconds` (Number) Timeout for the test in seconds (default is 5 minutes) 27 | - `working_dir` (String) Working directory for the test 28 | 29 | ### Read-Only 30 | 31 | - `exit_code` (Number) Exit code of the test 32 | - `id` (String) Fully qualified image digest of the image. 33 | - `output` (String, Deprecated) Output of the test 34 | - `tested_ref` (String) Tested image ref by digest. 35 | 36 | 37 | ### Nested Schema for `env` 38 | 39 | Optional: 40 | 41 | - `name` (String) 42 | - `value` (String) 43 | -------------------------------------------------------------------------------- /pkg/structure/env.go: -------------------------------------------------------------------------------- 1 | package structure 2 | 3 | // verifyEnv contains a best-effort list of PATH-esque environment variables 4 | // which should be evaluated for literal `$VAR` string usage. 5 | var verifyEnv = map[string]string{ 6 | "CDC_AGENT_PATH": ":", 7 | "CRI_CONFIG_PATH": ":", 8 | "GATUS_CONFIG_PATH": ":", 9 | "GCONV_PATH": ":", 10 | "GEM_PATH": ":", 11 | "GETCONF_DIR": ":", 12 | "GOPATH": ":", 13 | "JAVA_HOME": ":", 14 | "KO_DATA_PATH": ":", 15 | "LD_LIBRARY_PATH": ":", 16 | "LD_ORIGIN_PATH": ":", 17 | "LD_PRELOAD": ":", 18 | "LIBRARY_PATH": ":", 19 | "LOCPATH": ":", 20 | "LUA_CPATH": ";", // the LUA path delimiter is a semicolon (https://www.lua.org/pil/8.1.html) 21 | "LUA_PATH": ";", // the LUA path delimiter is a semicolon (https://www.lua.org/pil/8.1.html) 22 | "MAAC_PATH": ":", 23 | "MCAC_PATH": ":", 24 | "NKEYS_PATH": ":", 25 | "NIS_PATH": ":", 26 | "NLSPATH": ":", 27 | "OPENSEARCH_PATH_CONF": ":", 28 | "PATH": ":", 29 | "PERLLIB": ":", 30 | "PYTHONPATH": ":", 31 | "RESOLV_HOST_CONF": ":", 32 | "TMPDIR": ":", 33 | "TZDIR": ":", 34 | "ZAP_PATH": ":", 35 | } 36 | -------------------------------------------------------------------------------- /docs/resources/append.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "oci_append Resource - terraform-provider-oci" 4 | subcategory: "" 5 | description: |- 6 | Append layers to an existing image. 7 | --- 8 | 9 | # oci_append (Resource) 10 | 11 | Append layers to an existing image. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "oci_append" "example" { 17 | base_image = "alpine:3.18" 18 | } 19 | ``` 20 | 21 | 22 | ## Schema 23 | 24 | ### Required 25 | 26 | - `layers` (Attributes List) Layers to append to the base image. (see [below for nested schema](#nestedatt--layers)) 27 | 28 | ### Optional 29 | 30 | - `base_image` (String) Base image to append layers to. 31 | 32 | ### Read-Only 33 | 34 | - `id` (String) The resulting fully-qualified digest (e.g. {repo}@sha256:deadbeef). 35 | - `image_ref` (String) The resulting fully-qualified digest (e.g. {repo}@sha256:deadbeef). 36 | 37 | 38 | ### Nested Schema for `layers` 39 | 40 | Required: 41 | 42 | - `files` (Attributes Map) Files to add to the layer. (see [below for nested schema](#nestedatt--layers--files)) 43 | 44 | 45 | ### Nested Schema for `layers.files` 46 | 47 | Optional: 48 | 49 | - `contents` (String) Content of the file. 50 | - `path` (String) Path to a file. 51 | -------------------------------------------------------------------------------- /testing/testing.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "net/http/httptest" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/google/go-containerregistry/pkg/name" 10 | "github.com/google/go-containerregistry/pkg/registry" 11 | ) 12 | 13 | // SetupRegistry starts a local registry for testing. 14 | // 15 | // If TF_OCI_REGISTRY is set, it will be used instead. 16 | func SetupRegistry(t *testing.T) (name.Registry, func()) { 17 | t.Helper() 18 | if got := os.Getenv("TF_OCI_REGISTRY"); got != "" { 19 | reg, err := name.NewRegistry(got) 20 | if err != nil { 21 | t.Fatalf("failed to parse TF_OCI_REGISTRY: %v", err) 22 | } 23 | return reg, func() {} 24 | } 25 | srv := httptest.NewServer(registry.New()) 26 | t.Logf("Started registry: %s", srv.URL) 27 | reg, err := name.NewRegistry(strings.TrimPrefix(srv.URL, "http://")) 28 | if err != nil { 29 | t.Fatalf("failed to parse test registry: %v", err) 30 | } 31 | return reg, srv.Close 32 | } 33 | 34 | // SetupRegistry starts a local registry for testing and returns a repository within that registry. 35 | // 36 | // If TF_OCI_REGISTRY is set, that registry will be used instead. 37 | func SetupRepository(t *testing.T, repo string) (name.Repository, func()) { 38 | reg, cleanup := SetupRegistry(t) 39 | // TODO: use reg.Repo after https://github.com/google/go-containerregistry/pull/1671 40 | r, err := name.NewRepository(reg.RegistryStr() + "/" + repo) 41 | if err != nil { 42 | t.Fatalf("failed to create repository: %v", err) 43 | } 44 | return r, cleanup 45 | } 46 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | before: 3 | hooks: 4 | # this is just an example and not a requirement for provider building/publishing 5 | - go mod tidy 6 | builds: 7 | - env: 8 | # goreleaser does not work with CGO, it could also complicate 9 | # usage by users in CI/CD systems like Terraform Cloud where 10 | # they are unable to install libraries. 11 | - CGO_ENABLED=0 12 | mod_timestamp: '{{ .CommitTimestamp }}' 13 | flags: 14 | - -trimpath 15 | ldflags: 16 | - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' 17 | goos: 18 | - linux 19 | - darwin 20 | goarch: 21 | - amd64 22 | - arm64 23 | ignore: 24 | - goos: darwin 25 | goarch: '386' 26 | binary: '{{ .ProjectName }}_v{{ .Version }}' 27 | archives: 28 | - formats: 29 | - zip 30 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' 31 | checksum: 32 | extra_files: 33 | - glob: 'terraform-registry-manifest.json' 34 | name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' 35 | name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' 36 | algorithm: sha256 37 | signs: 38 | - artifacts: checksum 39 | args: 40 | # if you are using this in a GitHub action or some other automated pipeline, you 41 | # need to pass the batch flag to indicate its not interactive. 42 | - "--batch" 43 | - "--local-user" 44 | - "{{ .Env.GPG_FINGERPRINT }}" # set this environment variable for your signing key 45 | - "--output" 46 | - "${signature}" 47 | - "--detach-sign" 48 | - "${artifact}" 49 | release: 50 | extra_files: 51 | - glob: 'terraform-registry-manifest.json' 52 | name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' 53 | -------------------------------------------------------------------------------- /docs/functions/parse.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "parse function - terraform-provider-oci" 4 | subcategory: "" 5 | description: |- 6 | Parses a pinned OCI string into its constituent parts. 7 | --- 8 | 9 | # function: parse 10 | 11 | Converts a fully qualified OCI image reference with a digest into an object representation with the following properties: 12 | 13 | - `registry` - The registry hostname (e.g., `cgr.dev`) 14 | - `repo` - The repository path without the registry (e.g., `chainguard/wolfi-base`) 15 | - `registry_repo` - The full registry and repository path (e.g., `cgr.dev/chainguard/wolfi-base`) 16 | - `digest` - The digest identifier (e.g., `sha256:abcd1234...`) 17 | - `pseudo_tag` - A pseudo tag format combining unused with the digest (e.g., `unused@sha256:abcd1234...`) 18 | - `ref` - The complete reference string as provided 19 | 20 | **Note:** The input must include a digest. References with only a tag (without a digest) will result in an error. 21 | 22 | ## Example 23 | 24 | ```terraform 25 | output "parsed" { 26 | value = provider::oci::parse("cgr.dev/chainguard/wolfi-base@sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab") 27 | } 28 | ``` 29 | 30 | This returns: 31 | ```json 32 | { 33 | "registry": "cgr.dev", 34 | "repo": "chainguard/wolfi-base", 35 | "registry_repo": "cgr.dev/chainguard/wolfi-base", 36 | "digest": "sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab", 37 | "pseudo_tag": "unused@sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab", 38 | "ref": "cgr.dev/chainguard/wolfi-base@sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab" 39 | } 40 | ``` 41 | 42 | 43 | 44 | ## Signature 45 | 46 | 47 | ```text 48 | parse(input string) object 49 | ``` 50 | 51 | ## Arguments 52 | 53 | 54 | 1. `input` (String) The OCI reference string to parse. 55 | -------------------------------------------------------------------------------- /docs/data-sources/structure_test.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "oci_structure_test Data Source - terraform-provider-oci" 4 | subcategory: "" 5 | description: |- 6 | Structure test data source 7 | --- 8 | 9 | # oci_structure_test (Data Source) 10 | 11 | Structure test data source 12 | 13 | 14 | 15 | 16 | ## Schema 17 | 18 | ### Required 19 | 20 | - `conditions` (List of Object) List of conditions to test (see [below for nested schema](#nestedatt--conditions)) 21 | - `digest` (String) Image digest to test 22 | 23 | ### Read-Only 24 | 25 | - `id` (String) Fully qualified image digest of the image. 26 | - `tested_ref` (String) Tested image ref by digest. 27 | 28 | 29 | ### Nested Schema for `conditions` 30 | 31 | Required: 32 | 33 | - `dirs` (List of Object) (see [below for nested schema](#nestedobjatt--conditions--dirs)) 34 | - `env` (List of Object) (see [below for nested schema](#nestedobjatt--conditions--env)) 35 | - `files` (List of Object) (see [below for nested schema](#nestedobjatt--conditions--files)) 36 | - `permissions` (List of Object) (see [below for nested schema](#nestedobjatt--conditions--permissions)) 37 | 38 | 39 | ### Nested Schema for `conditions.dirs` 40 | 41 | Required: 42 | 43 | - `files_only` (Boolean) 44 | - `mode` (String) 45 | - `path` (String) 46 | - `recursive` (Boolean) 47 | 48 | 49 | 50 | ### Nested Schema for `conditions.env` 51 | 52 | Required: 53 | 54 | - `key` (String) 55 | - `value` (String) 56 | 57 | 58 | 59 | ### Nested Schema for `conditions.files` 60 | 61 | Required: 62 | 63 | - `mode` (String) 64 | - `optional` (Boolean) 65 | - `path` (String) 66 | - `regex` (String) 67 | 68 | 69 | 70 | ### Nested Schema for `conditions.permissions` 71 | 72 | Required: 73 | 74 | - `block` (String) 75 | - `override` (List of String) 76 | - `path` (String) 77 | -------------------------------------------------------------------------------- /cmd/check/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/chainguard-dev/terraform-provider-oci/pkg/structure" 10 | "github.com/google/go-containerregistry/pkg/authn" 11 | "github.com/google/go-containerregistry/pkg/name" 12 | v1 "github.com/google/go-containerregistry/pkg/v1" 13 | "github.com/google/go-containerregistry/pkg/v1/remote" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | func main() { 18 | var files, envs []string 19 | var platform string 20 | 21 | cmd := &cobra.Command{ 22 | Use: "check", 23 | Short: "Check a container image for compliance with a set of conditions", 24 | Args: cobra.ExactArgs(1), 25 | SilenceUsage: true, 26 | RunE: func(cmd *cobra.Command, args []string) error { 27 | ref, err := name.ParseReference(args[0]) 28 | if err != nil { 29 | return fmt.Errorf("failed to parse reference: %v", err) 30 | } 31 | plat, err := v1.ParsePlatform(platform) 32 | if err != nil { 33 | return fmt.Errorf("failed to parse platform: %v", err) 34 | } 35 | img, err := remote.Image(ref, 36 | remote.WithAuthFromKeychain(authn.DefaultKeychain), 37 | remote.WithPlatform(*plat), 38 | ) 39 | if err != nil { 40 | return fmt.Errorf("failed to fetch image: %v", err) 41 | } 42 | 43 | var conds structure.Conditions 44 | fc := structure.FilesCondition{Want: map[string]structure.File{}} 45 | for _, f := range files { 46 | path, regex, _ := strings.Cut(f, "=") 47 | fc.Want[path] = structure.File{Regex: regexp.MustCompile(regex).String()} 48 | } 49 | conds = append(conds, fc) 50 | 51 | ec := structure.EnvCondition{Want: map[string]string{}} 52 | for _, e := range envs { 53 | k, v, _ := strings.Cut(e, "=") 54 | ec.Want[k] = v 55 | } 56 | conds = append(conds, ec) 57 | 58 | return conds.Check(img) 59 | }, 60 | } 61 | cmd.Flags().StringSliceVarP(&files, "file", "f", nil, `Files to check (e.g., "/etc/passwd=.*nonroot:.*" or "/etc/passwd" to check existence only)`) 62 | cmd.Flags().StringSliceVarP(&envs, "env", "e", nil, `Environment variables to check (e.g., "PATH=/usr/local/bin")`) 63 | cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Platform to check (e.g., linux/amd64)") 64 | if err := cmd.Execute(); err != nil { 65 | os.Exit(1) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | permissions: {} 8 | 9 | jobs: 10 | goreleaser: 11 | permissions: 12 | contents: write # To publish the release. 13 | id-token: write # To federate for the GPG key. 14 | 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Harden the runner (Audit all outbound calls) 18 | uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 19 | with: 20 | egress-policy: audit 21 | 22 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 23 | - run: git fetch --prune --unshallow 24 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 25 | with: 26 | go-version-file: 'go.mod' 27 | cache: false 28 | 29 | # This is provisioned here: https://github.com/chainguard-dev/secrets/blob/main/terraform-provider-oci.tf 30 | - uses: step-security/google-github-auth@f0e5c257a9534a30b5df12f43329c1eb7b85a5be # v3.0.0 31 | id: auth 32 | with: 33 | workload_identity_provider: "projects/12758742386/locations/global/workloadIdentityPools/github-pool/providers/github-provider" 34 | service_account: "terraform-provider-oci@chainguard-github-secrets.iam.gserviceaccount.com" 35 | - uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 36 | with: 37 | project_id: "chainguard-github-secrets" 38 | - uses: google-github-actions/get-secretmanager-secrets@bc9c54b29fdffb8a47776820a7d26e77b379d262 # v3.0.0 39 | id: secrets 40 | with: 41 | secrets: |- 42 | token:chainguard-github-secrets/terraform-provider-oci-signing-key 43 | 44 | - id: import_gpg 45 | uses: step-security/ghaction-import-gpg@69c854a83c7f79463f8bdf46772ab09826c560cd # v6.3.1 46 | with: 47 | gpg_private_key: ${{ steps.secrets.outputs.token }} 48 | 49 | - run: | 50 | gpg --keyserver keys.openpgp.org --send-keys ${{ steps.import_gpg.outputs.fingerprint }} 51 | 52 | - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 53 | with: 54 | version: latest 55 | args: release --clean 56 | env: 57 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | -------------------------------------------------------------------------------- /internal/provider/tag_resource_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | ocitesting "github.com/chainguard-dev/terraform-provider-oci/testing" 8 | "github.com/google/go-containerregistry/pkg/v1/random" 9 | "github.com/google/go-containerregistry/pkg/v1/remote" 10 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 11 | ) 12 | 13 | func TestAccTagResource(t *testing.T) { 14 | repo, cleanup := ocitesting.SetupRepository(t, "test") 15 | defer cleanup() 16 | 17 | // Push an image to the local registry. 18 | ref1 := repo.Tag("1") 19 | t.Logf("Using ref1: %s", ref1) 20 | img1, err := random.Image(1024, 1) 21 | if err != nil { 22 | t.Fatalf("failed to create image: %v", err) 23 | } 24 | if err := remote.Write(ref1, img1); err != nil { 25 | t.Fatalf("failed to write image: %v", err) 26 | } 27 | d1, err := img1.Digest() 28 | if err != nil { 29 | t.Fatalf("failed to get digest: %v", err) 30 | } 31 | dig1 := ref1.Context().Digest(d1.String()) 32 | 33 | // Push an image to the local registry. 34 | ref2 := repo.Tag("2") 35 | t.Logf("Using ref2: %s", ref2) 36 | img2, err := random.Image(1024, 1) 37 | if err != nil { 38 | t.Fatalf("failed to create image: %v", err) 39 | } 40 | if err := remote.Write(ref2, img2); err != nil { 41 | t.Fatalf("failed to write image: %v", err) 42 | } 43 | d2, err := img2.Digest() 44 | if err != nil { 45 | t.Fatalf("failed to get digest: %v", err) 46 | } 47 | dig2 := ref2.Context().Digest(d2.String()) 48 | 49 | want1 := fmt.Sprintf("%s:test@%s", repo, d1) 50 | want2 := fmt.Sprintf("%s:test2@%s", repo, d2) 51 | 52 | resource.Test(t, resource.TestCase{ 53 | PreCheck: func() { testAccPreCheck(t) }, 54 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 55 | Steps: []resource.TestStep{ 56 | // Create and Read testing 57 | { 58 | Config: fmt.Sprintf(`resource "oci_tag" "test" { 59 | digest_ref = %q 60 | tag = "test" 61 | }`, dig1), 62 | Check: resource.ComposeAggregateTestCheckFunc( 63 | resource.TestCheckResourceAttr("oci_tag.test", "tagged_ref", want1), 64 | resource.TestCheckResourceAttr("oci_tag.test", "id", want1), 65 | ), 66 | }, 67 | // Update and Read testing 68 | { 69 | Config: fmt.Sprintf(`resource "oci_tag" "test" { 70 | digest_ref = %q 71 | tag = "test2" 72 | }`, dig2), 73 | Check: resource.ComposeAggregateTestCheckFunc( 74 | resource.TestCheckResourceAttr("oci_tag.test", "tagged_ref", want2), 75 | resource.TestCheckResourceAttr("oci_tag.test", "id", want2), 76 | ), 77 | }, 78 | // Delete testing automatically occurs in TestCase 79 | }, 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /docs/functions/get.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "get function - terraform-provider-oci" 4 | subcategory: "" 5 | description: |- 6 | Fetches an OCI image or index from a registry and returns detailed metadata. 7 | --- 8 | 9 | # function: get 10 | 11 | Fetches an OCI image or index from a registry and returns detailed information about it, including the manifest, configuration, and platform-specific images. 12 | 13 | The function accepts either a tag or digest reference and returns an object with the following properties: 14 | 15 | - `full_ref` - The complete reference with digest (e.g., `cgr.dev/chainguard/wolfi-base@sha256:abcd1234...`) 16 | - `digest` - The digest of the image or index (e.g., `sha256:abcd1234...`) 17 | - `tag` - The tag if one was provided in the input (e.g., `latest`) 18 | - `manifest` - The OCI manifest object containing: 19 | - `schema_version` - The manifest schema version 20 | - `media_type` - The media type of the manifest 21 | - `config` - The config descriptor (for images) 22 | - `layers` - List of layer descriptors (for images) 23 | - `annotations` - Key-value annotations 24 | - `manifests` - List of manifest descriptors (for indexes) 25 | - `subject` - Subject descriptor (for referrers) 26 | - `images` - Map of platform strings to image objects (populated for multi-arch indexes). Each image contains: 27 | - `digest` - The platform-specific image digest 28 | - `image_ref` - The full reference for the platform-specific image 29 | - `config` - The image configuration (populated for single-arch images only). Contains: 30 | - `env` - List of environment variables 31 | - `user` - The user to run as 32 | - `working_dir` - The working directory 33 | - `entrypoint` - The entrypoint command 34 | - `cmd` - The default command arguments 35 | - `created_at` - Timestamp when the image was created 36 | 37 | **Note:** This function makes a network request to the registry to fetch the image metadata. Authentication is handled via the default keychain (Docker config, credential helpers, etc.). 38 | 39 | ## Example: Single-arch image 40 | 41 | ```terraform 42 | output "image_info" { 43 | value = provider::oci::get("cgr.dev/chainguard/wolfi-base:latest") 44 | } 45 | ``` 46 | 47 | ## Example: Multi-arch index 48 | 49 | ```terraform 50 | locals { 51 | multi_arch = provider::oci::get("cgr.dev/chainguard/static:latest") 52 | } 53 | 54 | output "linux_amd64" { 55 | value = local.multi_arch.images["linux/amd64"] 56 | } 57 | ``` 58 | 59 | ## Example: Using the config 60 | 61 | ```terraform 62 | locals { 63 | image = provider::oci::get("cgr.dev/chainguard/wolfi-base:latest") 64 | } 65 | 66 | output "entrypoint" { 67 | value = local.image.config.entrypoint 68 | } 69 | 70 | output "environment" { 71 | value = local.image.config.env 72 | } 73 | ``` 74 | 75 | 76 | 77 | ## Signature 78 | 79 | 80 | ```text 81 | get(input string) object 82 | ``` 83 | 84 | ## Arguments 85 | 86 | 87 | 1. `input` (String) The OCI reference string to get. 88 | -------------------------------------------------------------------------------- /internal/provider/types.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | v1 "github.com/google/go-containerregistry/pkg/v1" 8 | "github.com/google/go-containerregistry/pkg/v1/remote" 9 | ) 10 | 11 | type Image struct { 12 | Digest string `tfsdk:"digest"` 13 | ImageRef string `tfsdk:"image_ref"` 14 | } 15 | 16 | type Manifest struct { 17 | SchemaVersion int64 `tfsdk:"schema_version"` 18 | MediaType string `tfsdk:"media_type"` 19 | Config *Descriptor `tfsdk:"config"` 20 | Layers []Descriptor `tfsdk:"layers"` 21 | Annotations map[string]string `tfsdk:"annotations"` 22 | Manifests []Descriptor `tfsdk:"manifests"` 23 | Subject *Descriptor `tfsdk:"subject"` 24 | } 25 | 26 | func (m *Manifest) FromDescriptor(desc *remote.Descriptor) error { 27 | switch { 28 | case desc.MediaType.IsImage(): 29 | img, err := desc.Image() 30 | if err != nil { 31 | return err 32 | } 33 | imf, err := img.Manifest() 34 | if err != nil { 35 | return err 36 | } 37 | m.SchemaVersion = imf.SchemaVersion 38 | m.MediaType = string(imf.MediaType) 39 | m.Config = ToDescriptor(&imf.Config) 40 | m.Layers = ToDescriptors(imf.Layers) 41 | m.Annotations = imf.Annotations 42 | m.Subject = ToDescriptor(imf.Subject) 43 | m.Manifests = nil 44 | return nil 45 | 46 | case desc.MediaType.IsIndex(): 47 | idx, err := desc.ImageIndex() 48 | if err != nil { 49 | return err 50 | } 51 | imf, err := idx.IndexManifest() 52 | if err != nil { 53 | return err 54 | } 55 | m.SchemaVersion = imf.SchemaVersion 56 | m.MediaType = string(imf.MediaType) 57 | m.Manifests = ToDescriptors(imf.Manifests) 58 | m.Annotations = imf.Annotations 59 | m.Subject = ToDescriptor(imf.Subject) 60 | m.Config = nil 61 | m.Layers = nil 62 | return nil 63 | } 64 | 65 | return fmt.Errorf("unsupported media type: %s", desc.MediaType) 66 | } 67 | 68 | func ToDescriptor(d *v1.Descriptor) *Descriptor { 69 | if d == nil { 70 | return nil 71 | } 72 | return &Descriptor{ 73 | MediaType: string(d.MediaType), 74 | Size: d.Size, 75 | Digest: d.Digest.String(), 76 | Platform: ToPlatform(d.Platform), 77 | } 78 | } 79 | 80 | func ToPlatform(p *v1.Platform) *Platform { 81 | if p == nil { 82 | return nil 83 | } 84 | return &Platform{ 85 | Architecture: p.Architecture, 86 | OS: p.OS, 87 | Variant: p.Variant, 88 | OSVersion: p.OSVersion, 89 | } 90 | } 91 | 92 | func ToDescriptors(d []v1.Descriptor) []Descriptor { 93 | out := make([]Descriptor, len(d)) 94 | for i, desc := range d { 95 | out[i] = *ToDescriptor(&desc) 96 | } 97 | return out 98 | } 99 | 100 | type Descriptor struct { 101 | MediaType string `tfsdk:"media_type"` 102 | Size int64 `tfsdk:"size"` 103 | Digest string `tfsdk:"digest"` 104 | Platform *Platform `tfsdk:"platform"` 105 | } 106 | 107 | type Platform struct { 108 | Architecture string `tfsdk:"architecture"` 109 | OS string `tfsdk:"os"` 110 | Variant string `tfsdk:"variant"` 111 | OSVersion string `tfsdk:"os_version"` 112 | } 113 | 114 | type Config struct { 115 | Env []string `tfsdk:"env"` 116 | User string `tfsdk:"user"` 117 | WorkingDir string `tfsdk:"working_dir"` 118 | Entrypoint []string `tfsdk:"entrypoint"` 119 | Cmd []string `tfsdk:"cmd"` 120 | CreatedAt string `tfsdk:"created_at"` 121 | } 122 | 123 | func (c *Config) FromConfigFile(cf *v1.ConfigFile) { 124 | if c == nil { 125 | c = &Config{} 126 | } 127 | if cf == nil { 128 | return 129 | } 130 | 131 | c.Env = cf.Config.Env 132 | c.User = cf.Config.User 133 | c.WorkingDir = cf.Config.WorkingDir 134 | c.Entrypoint = cf.Config.Entrypoint 135 | c.Cmd = cf.Config.Cmd 136 | c.CreatedAt = cf.Created.Format(time.RFC3339) 137 | } 138 | -------------------------------------------------------------------------------- /internal/provider/tags_resource_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | 8 | ocitesting "github.com/chainguard-dev/terraform-provider-oci/testing" 9 | "github.com/google/go-containerregistry/pkg/name" 10 | v1 "github.com/google/go-containerregistry/pkg/v1" 11 | "github.com/google/go-containerregistry/pkg/v1/random" 12 | "github.com/google/go-containerregistry/pkg/v1/remote" 13 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 14 | ) 15 | 16 | func TestAccTagsResource(t *testing.T) { 17 | repo, cleanup := ocitesting.SetupRepository(t, "repo") 18 | defer cleanup() 19 | 20 | // Push an image to the local registry. 21 | ref1 := repo.Tag("1") 22 | img1, err := random.Image(1024, 1) 23 | if err != nil { 24 | t.Fatalf("failed to create image: %v", err) 25 | } 26 | if err := remote.Write(ref1, img1); err != nil { 27 | t.Fatalf("failed to write image: %v", err) 28 | } 29 | d1, err := img1.Digest() 30 | if err != nil { 31 | t.Fatalf("failed to get digest: %v", err) 32 | } 33 | dig1 := ref1.Context().Digest(d1.String()) 34 | t.Logf("Using ref1: %s -> %s", ref1, dig1) 35 | 36 | // Push another image to the local registry. 37 | ref2 := repo.Tag("2") 38 | img2, err := random.Image(1024, 1) 39 | if err != nil { 40 | t.Fatalf("failed to create image: %v", err) 41 | } 42 | if err := remote.Write(ref2, img2); err != nil { 43 | t.Fatalf("failed to write image: %v", err) 44 | } 45 | d2, err := img2.Digest() 46 | if err != nil { 47 | t.Fatalf("failed to get digest: %v", err) 48 | } 49 | dig2 := ref2.Context().Digest(d2.String()) 50 | t.Logf("Using ref2: %s -> %s", ref2, dig2) 51 | 52 | // Tag the digests with some tags. 53 | marshal := func(a any) string { 54 | b, err := json.MarshalIndent(a, "", " ") 55 | if err != nil { 56 | t.Fatalf("failed to marshal: %v", err) 57 | } 58 | return string(b) 59 | } 60 | resource.Test(t, resource.TestCase{ 61 | PreCheck: func() { testAccPreCheck(t) }, 62 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 63 | Steps: []resource.TestStep{{ 64 | Config: fmt.Sprintf(`resource "oci_tags" "test" { 65 | repo = %q 66 | tags = %s 67 | }`, repo, marshal(map[string]v1.Hash{ 68 | "foo": d1, 69 | "bar": d1, 70 | "baz": d1, 71 | "hello": d2, 72 | "world": d2, 73 | })), 74 | }}, 75 | }) 76 | 77 | // Check those tags were applied, and the original tags didn't change. 78 | checkTags := func(want map[string][]string) { 79 | for dig, tags := range want { 80 | d, err := name.NewDigest(dig) 81 | if err != nil { 82 | t.Fatalf("error parsing digest ref: %v", err) 83 | } 84 | for _, tag := range tags { 85 | got, err := remote.Head(repo.Tag(tag)) 86 | if err != nil { 87 | t.Errorf("failed to get image with tag %q: %v", tag, err) 88 | } 89 | if got.Digest.String() != d.DigestStr() { 90 | t.Errorf("image with tag %q has wrong digest: got %s, want %s", tag, got.Digest, d.DigestStr()) 91 | } 92 | } 93 | } 94 | } 95 | checkTags(map[string][]string{ 96 | dig1.String(): {"1", "foo", "bar", "baz"}, 97 | dig2.String(): {"2", "hello", "world"}, 98 | }) 99 | 100 | // Update some tags. 101 | resource.Test(t, resource.TestCase{ 102 | PreCheck: func() { testAccPreCheck(t) }, 103 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 104 | Steps: []resource.TestStep{{ 105 | Config: fmt.Sprintf(`resource "oci_tags" "test" { 106 | repo = %q 107 | tags = %s 108 | }`, repo, marshal(map[string]v1.Hash{ 109 | // "foo" isn't specified, but this doesn't untag it. 110 | "bar": d1, 111 | "baz": d1, 112 | "hello": d1, // "hello" moved from 2 to 1. 113 | "world": d2, 114 | "goodbye": d1, // new tag on 1. 115 | "kevin": d2, // new tag on 2. 116 | })), 117 | }}, 118 | }) 119 | checkTags(map[string][]string{ 120 | dig1.String(): {"1", "foo", "bar", "baz", "hello", "goodbye"}, 121 | dig2.String(): {"2", "world", "kevin"}, 122 | }) 123 | } 124 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # Terraform Provider testing workflow. 2 | name: Tests 3 | 4 | # This GitHub action runs your tests for each pull request and push. 5 | # Optionally, you can turn it on using a schedule for regular testing. 6 | on: 7 | pull_request: 8 | branches: ["main"] 9 | paths-ignore: 10 | - "README.md" 11 | push: 12 | branches: ["main"] 13 | paths-ignore: 14 | - "README.md" 15 | 16 | # Testing only needs permissions to read the repository contents. 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | # Ensure project builds before running testing matrix 22 | build: 23 | name: Build 24 | runs-on: ubuntu-latest 25 | timeout-minutes: 5 26 | steps: 27 | - name: Harden the runner (Audit all outbound calls) 28 | uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 29 | with: 30 | egress-policy: audit 31 | 32 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 33 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 34 | with: 35 | go-version-file: "go.mod" 36 | - run: | 37 | go mod download 38 | go build -v . 39 | 40 | generate: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Harden the runner (Audit all outbound calls) 44 | uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 45 | with: 46 | egress-policy: audit 47 | 48 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 49 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 50 | with: 51 | go-version-file: "go.mod" 52 | cache: true 53 | - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 54 | - run: go generate ./... 55 | - name: git diff 56 | run: | 57 | git diff --compact-summary --exit-code || \ 58 | (echo; echo "Unexpected difference in directories after code generation. Run 'go generate ./...' command and commit."; exit 1) 59 | 60 | # Run acceptance tests in a matrix with Terraform CLI versions 61 | test: 62 | name: Terraform Provider Acceptance Tests 63 | needs: build 64 | runs-on: ubuntu-latest 65 | timeout-minutes: 15 66 | strategy: 67 | fail-fast: false 68 | matrix: 69 | # list whatever Terraform versions here you would like to support 70 | terraform: 71 | - "1.10.*" 72 | - "1.11.*" 73 | - "1.12.*" 74 | steps: 75 | - name: Harden the runner (Audit all outbound calls) 76 | uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 77 | with: 78 | egress-policy: audit 79 | 80 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 81 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 82 | with: 83 | go-version-file: "go.mod" 84 | cache: true 85 | - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 86 | with: 87 | terraform_version: ${{ matrix.terraform }} 88 | terraform_wrapper: false 89 | - run: go mod download 90 | - env: 91 | TF_ACC: "1" 92 | run: go test -v -cover ./internal/provider/ 93 | timeout-minutes: 10 94 | 95 | golangci: 96 | name: lint 97 | runs-on: ubuntu-latest 98 | steps: 99 | - name: Harden the runner (Audit all outbound calls) 100 | uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 101 | with: 102 | egress-policy: audit 103 | 104 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 105 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 106 | with: 107 | go-version-file: "go.mod" 108 | cache: false 109 | - name: golangci-lint 110 | uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 111 | with: 112 | version: v2.2 113 | -------------------------------------------------------------------------------- /internal/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/google/go-containerregistry/pkg/authn" 8 | "github.com/google/go-containerregistry/pkg/v1/google" 9 | "github.com/google/go-containerregistry/pkg/v1/remote" 10 | "github.com/hashicorp/terraform-plugin-framework/datasource" 11 | "github.com/hashicorp/terraform-plugin-framework/function" 12 | "github.com/hashicorp/terraform-plugin-framework/provider" 13 | "github.com/hashicorp/terraform-plugin-framework/provider/schema" 14 | "github.com/hashicorp/terraform-plugin-framework/resource" 15 | ) 16 | 17 | var _ provider.ProviderWithFunctions = &OCIProvider{} 18 | 19 | // OCIProvider defines the provider implementation. 20 | type OCIProvider struct { 21 | // version is set to the provider version on release, "dev" when the 22 | // provider is built and ran locally, and "test" when running acceptance 23 | // testing. 24 | version string 25 | 26 | defaultExecTimeoutSeconds int64 27 | skipExecTests bool 28 | } 29 | 30 | // OCIProviderModel describes the provider data model. 31 | type OCIProviderModel struct { 32 | DefaultExecTimeoutSeconds *int64 `tfsdk:"default_exec_timeout_seconds"` 33 | SkipExecTests *bool `tfsdk:"skip_exec_tests"` 34 | } 35 | 36 | type ProviderOpts struct { 37 | ropts []remote.Option 38 | defaultExecTimeoutSeconds int64 39 | skipExecTests bool 40 | } 41 | 42 | func (p *ProviderOpts) withContext(ctx context.Context) []remote.Option { 43 | return append([]remote.Option{remote.WithContext(ctx)}, p.ropts...) 44 | } 45 | 46 | func (p *OCIProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { 47 | resp.TypeName = "oci" 48 | resp.Version = p.version 49 | } 50 | 51 | func (p *OCIProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { 52 | resp.Schema = schema.Schema{ 53 | Attributes: map[string]schema.Attribute{ 54 | "default_exec_timeout_seconds": schema.Int64Attribute{ 55 | MarkdownDescription: "Default timeout for exec tests", 56 | Optional: true, 57 | }, 58 | "skip_exec_tests": schema.BoolAttribute{ 59 | MarkdownDescription: "If true, skip oci_exec_test tests", 60 | Optional: true, 61 | }, 62 | }, 63 | } 64 | } 65 | 66 | func (p *OCIProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { 67 | var data OCIProviderModel 68 | resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) 69 | if resp.Diagnostics.HasError() { 70 | return 71 | } 72 | 73 | kc := authn.NewMultiKeychain(google.Keychain, authn.RefreshingKeychain(authn.DefaultKeychain, 30*time.Minute)) 74 | ropts := []remote.Option{remote.WithAuthFromKeychain(kc), remote.WithUserAgent("terraform-provider-oci")} 75 | 76 | // These errors are impossible in current impl, but we can't return an err, so panic. 77 | puller, err := remote.NewPuller(ropts...) 78 | if err != nil { 79 | resp.Diagnostics.AddError("NewPuller", err.Error()) 80 | return 81 | } 82 | 83 | pusher, err := remote.NewPusher(ropts...) 84 | if err != nil { 85 | resp.Diagnostics.AddError("NewPusher", err.Error()) 86 | return 87 | } 88 | 89 | ropts = append(ropts, remote.Reuse(puller), remote.Reuse(pusher)) 90 | 91 | opts := &ProviderOpts{ 92 | ropts: ropts, 93 | } 94 | if p.defaultExecTimeoutSeconds != 0 { 95 | // This is only for testing, so we can inject provider config 96 | opts.defaultExecTimeoutSeconds = p.defaultExecTimeoutSeconds 97 | } else if data.DefaultExecTimeoutSeconds != nil { 98 | opts.defaultExecTimeoutSeconds = *data.DefaultExecTimeoutSeconds 99 | } 100 | 101 | opts.skipExecTests = p.skipExecTests || (data.SkipExecTests != nil && *data.SkipExecTests) 102 | 103 | resp.DataSourceData = opts 104 | resp.ResourceData = opts 105 | } 106 | 107 | func (p *OCIProvider) Resources(ctx context.Context) []func() resource.Resource { 108 | return []func() resource.Resource{ 109 | NewAppendResource, 110 | NewTagResource, 111 | NewTagsResource, 112 | } 113 | } 114 | 115 | func (p *OCIProvider) DataSources(ctx context.Context) []func() datasource.DataSource { 116 | return []func() datasource.DataSource{ 117 | NewStructureTestDataSource, 118 | NewExecTestDataSource, 119 | } 120 | } 121 | 122 | func (p *OCIProvider) Functions(ctx context.Context) []func() function.Function { 123 | return []func() function.Function{ 124 | NewParseFunction, 125 | NewGetFunction, 126 | } 127 | } 128 | 129 | func New(version string) func() provider.Provider { 130 | return func() provider.Provider { 131 | return &OCIProvider{ 132 | version: version, 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /internal/provider/parse_function.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/google/go-containerregistry/pkg/name" 8 | "github.com/hashicorp/terraform-plugin-framework/attr" 9 | "github.com/hashicorp/terraform-plugin-framework/function" 10 | "github.com/hashicorp/terraform-plugin-framework/types/basetypes" 11 | ) 12 | 13 | // Ensure provider defined types fully satisfy framework interfaces. 14 | var _ function.Function = &ParseFunction{} 15 | 16 | const parseFuncMarkdownDesc = `Converts a fully qualified OCI image reference with a digest into an object representation with the following properties: 17 | 18 | - ` + "`registry`" + ` - The registry hostname (e.g., ` + "`cgr.dev`" + `) 19 | - ` + "`repo`" + ` - The repository path without the registry (e.g., ` + "`chainguard/wolfi-base`" + `) 20 | - ` + "`registry_repo`" + ` - The full registry and repository path (e.g., ` + "`cgr.dev/chainguard/wolfi-base`" + `) 21 | - ` + "`digest`" + ` - The digest identifier (e.g., ` + "`sha256:abcd1234...`" + `) 22 | - ` + "`pseudo_tag`" + ` - A pseudo tag format combining unused with the digest (e.g., ` + "`unused@sha256:abcd1234...`" + `) 23 | - ` + "`ref`" + ` - The complete reference string as provided 24 | 25 | **Note:** The input must include a digest. References with only a tag (without a digest) will result in an error. 26 | 27 | ## Example 28 | 29 | ` + "```" + `terraform 30 | output "parsed" { 31 | value = provider::oci::parse("cgr.dev/chainguard/wolfi-base@sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab") 32 | } 33 | ` + "```" + ` 34 | 35 | This returns: 36 | ` + "```" + `json 37 | { 38 | "registry": "cgr.dev", 39 | "repo": "chainguard/wolfi-base", 40 | "registry_repo": "cgr.dev/chainguard/wolfi-base", 41 | "digest": "sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab", 42 | "pseudo_tag": "unused@sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab", 43 | "ref": "cgr.dev/chainguard/wolfi-base@sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab" 44 | } 45 | ` + "```" 46 | 47 | func NewParseFunction() function.Function { 48 | return &ParseFunction{} 49 | } 50 | 51 | // ParseFunction defines the function implementation. 52 | type ParseFunction struct{} 53 | 54 | // Metadata should return the name of the function, such as parse_xyz. 55 | func (s *ParseFunction) Metadata(_ context.Context, _ function.MetadataRequest, resp *function.MetadataResponse) { 56 | resp.Name = "parse" 57 | } 58 | 59 | // Definition should return the definition for the function. 60 | func (s *ParseFunction) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) { 61 | resp.Definition = function.Definition{ 62 | Summary: "Parses a pinned OCI string into its constituent parts.", 63 | MarkdownDescription: parseFuncMarkdownDesc, 64 | Parameters: []function.Parameter{ 65 | function.StringParameter{ 66 | Name: "input", 67 | Description: "The OCI reference string to parse.", 68 | }, 69 | }, 70 | Return: function.ObjectReturn{ 71 | AttributeTypes: map[string]attr.Type{ 72 | "registry": basetypes.StringType{}, 73 | "repo": basetypes.StringType{}, 74 | "registry_repo": basetypes.StringType{}, 75 | "digest": basetypes.StringType{}, 76 | "pseudo_tag": basetypes.StringType{}, 77 | "ref": basetypes.StringType{}, 78 | }, 79 | }, 80 | } 81 | } 82 | 83 | // Run should return the result of the function logic. It is called when 84 | // Terraform reaches a function call in the configuration. Argument data 85 | // values should be read from the [RunRequest] and the result value set in 86 | // the [RunResponse]. 87 | func (s *ParseFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { 88 | var input string 89 | if ferr := req.Arguments.GetArgument(ctx, 0, &input); ferr != nil { 90 | resp.Error = ferr 91 | return 92 | } 93 | 94 | // Parse the input string into its constituent parts. 95 | ref, err := name.ParseReference(input) 96 | if err != nil { 97 | resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse OCI reference: %v", err)) 98 | return 99 | } 100 | 101 | if _, ok := ref.(name.Tag); ok { 102 | resp.Error = function.NewFuncError(fmt.Sprintf("Reference %s contains only a tag, but a digest is required", input)) 103 | return 104 | } 105 | 106 | result := struct { 107 | Registry string `tfsdk:"registry"` 108 | Repo string `tfsdk:"repo"` 109 | RegistryRepo string `tfsdk:"registry_repo"` 110 | Digest string `tfsdk:"digest"` 111 | PseudoTag string `tfsdk:"pseudo_tag"` 112 | Ref string `tfsdk:"ref"` 113 | }{ 114 | Registry: ref.Context().RegistryStr(), 115 | Repo: ref.Context().RepositoryStr(), 116 | RegistryRepo: ref.Context().RegistryStr() + "/" + ref.Context().RepositoryStr(), 117 | Digest: ref.Identifier(), 118 | PseudoTag: fmt.Sprintf("unused@%s", ref.Identifier()), 119 | Ref: ref.String(), 120 | } 121 | 122 | resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) 123 | } 124 | -------------------------------------------------------------------------------- /internal/provider/parse_function_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 8 | "github.com/hashicorp/terraform-plugin-testing/knownvalue" 9 | "github.com/hashicorp/terraform-plugin-testing/statecheck" 10 | "github.com/hashicorp/terraform-plugin-testing/tfversion" 11 | ) 12 | 13 | var digestRE = regexp.MustCompile("^sha256:[0-9a-f]{64}$") 14 | 15 | func TestParseFunction(t *testing.T) { 16 | // A naked ref string errors due to missing digest 17 | resource.Test(t, resource.TestCase{ 18 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 19 | TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, 20 | Steps: []resource.TestStep{{ 21 | Config: `output "parsed" { value = provider::oci::parse("") }`, 22 | ExpectError: regexp.MustCompile(""), // any error is ok 23 | }}, 24 | }) 25 | 26 | // A fully qualified tag ref string errors due to missing digest 27 | resource.Test(t, resource.TestCase{ 28 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 29 | TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, 30 | Steps: []resource.TestStep{{ 31 | Config: `output "parsed" { value = provider::oci::parse("cgr.dev/foo/sample:latest") }`, 32 | ExpectError: regexp.MustCompile(""), // any error is ok 33 | }}, 34 | }) 35 | 36 | // A fully qualified ref 37 | resource.Test(t, resource.TestCase{ 38 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 39 | TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, 40 | Steps: []resource.TestStep{{ 41 | Config: `output "parsed" { value = provider::oci::parse("cgr.dev/foo/sample@sha256:1234567890123456789012345678901234567890123456789012345678901234") }`, 42 | ConfigStateChecks: []statecheck.StateCheck{ 43 | statecheck.ExpectKnownOutputValue("parsed", knownvalue.ObjectExact(map[string]knownvalue.Check{ 44 | "registry": knownvalue.StringExact("cgr.dev"), 45 | "repo": knownvalue.StringExact("foo/sample"), 46 | "registry_repo": knownvalue.StringExact("cgr.dev/foo/sample"), 47 | "digest": knownvalue.StringExact("sha256:1234567890123456789012345678901234567890123456789012345678901234"), 48 | "pseudo_tag": knownvalue.StringExact("unused@sha256:1234567890123456789012345678901234567890123456789012345678901234"), 49 | "ref": knownvalue.StringExact("cgr.dev/foo/sample@sha256:1234567890123456789012345678901234567890123456789012345678901234"), 50 | })), 51 | }, 52 | }}, 53 | }) 54 | 55 | // A shorthand digest ref string has everything (including a pseudo tag) 56 | resource.Test(t, resource.TestCase{ 57 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 58 | TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, 59 | Steps: []resource.TestStep{{ 60 | Config: `output "parsed" { value = provider::oci::parse("sample@sha256:1234567890123456789012345678901234567890123456789012345678901234") }`, 61 | ConfigStateChecks: []statecheck.StateCheck{ 62 | statecheck.ExpectKnownOutputValue("parsed", knownvalue.ObjectExact(map[string]knownvalue.Check{ 63 | "registry": knownvalue.StringExact("index.docker.io"), 64 | "repo": knownvalue.StringExact("library/sample"), 65 | "registry_repo": knownvalue.StringExact("index.docker.io/library/sample"), 66 | "digest": knownvalue.StringExact("sha256:1234567890123456789012345678901234567890123456789012345678901234"), 67 | "pseudo_tag": knownvalue.StringExact("unused@sha256:1234567890123456789012345678901234567890123456789012345678901234"), 68 | "ref": knownvalue.StringExact("sample@sha256:1234567890123456789012345678901234567890123456789012345678901234"), 69 | })), 70 | }, 71 | }}, 72 | }) 73 | 74 | // A shorthand tagged and digest ref string has everything (including a replaced pseudo tag) 75 | resource.Test(t, resource.TestCase{ 76 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 77 | TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, 78 | Steps: []resource.TestStep{{ 79 | Config: `output "parsed" { value = provider::oci::parse("sample:cursed@sha256:1234567890123456789012345678901234567890123456789012345678901234") }`, 80 | ConfigStateChecks: []statecheck.StateCheck{ 81 | statecheck.ExpectKnownOutputValue("parsed", knownvalue.ObjectExact(map[string]knownvalue.Check{ 82 | "registry": knownvalue.StringExact("index.docker.io"), 83 | "repo": knownvalue.StringExact("library/sample"), 84 | "registry_repo": knownvalue.StringExact("index.docker.io/library/sample"), 85 | "digest": knownvalue.StringExact("sha256:1234567890123456789012345678901234567890123456789012345678901234"), 86 | "pseudo_tag": knownvalue.StringExact("unused@sha256:1234567890123456789012345678901234567890123456789012345678901234"), 87 | "ref": knownvalue.StringExact("sample:cursed@sha256:1234567890123456789012345678901234567890123456789012345678901234"), 88 | })), 89 | }, 90 | }}, 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chainguard-dev/terraform-provider-oci 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/google/go-containerregistry v0.20.7 7 | github.com/hashicorp/terraform-plugin-docs v0.24.0 8 | github.com/hashicorp/terraform-plugin-framework v1.17.0 9 | github.com/hashicorp/terraform-plugin-go v0.29.0 10 | github.com/hashicorp/terraform-plugin-log v0.10.0 11 | github.com/hashicorp/terraform-plugin-testing v1.14.0 12 | github.com/jonjohnsonjr/targz v0.0.0-20241113200849-4986e08f3fb4 13 | github.com/spf13/cobra v1.10.2 14 | ) 15 | 16 | require ( 17 | cloud.google.com/go/compute/metadata v0.7.0 // indirect 18 | github.com/BurntSushi/toml v1.2.1 // indirect 19 | github.com/Kunde21/markdownfmt/v3 v3.1.0 // indirect 20 | github.com/Masterminds/goutils v1.1.1 // indirect 21 | github.com/Masterminds/semver/v3 v3.2.0 // indirect 22 | github.com/Masterminds/sprig/v3 v3.2.3 // indirect 23 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 24 | github.com/agext/levenshtein v1.2.2 // indirect 25 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 26 | github.com/armon/go-radix v1.0.0 // indirect 27 | github.com/bgentry/speakeasy v0.1.0 // indirect 28 | github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect 29 | github.com/cloudflare/circl v1.6.1 // indirect 30 | github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect 31 | github.com/docker/cli v29.0.3+incompatible // indirect 32 | github.com/docker/distribution v2.8.3+incompatible // indirect 33 | github.com/docker/docker-credential-helpers v0.9.3 // indirect 34 | github.com/fatih/color v1.18.0 // indirect 35 | github.com/golang/protobuf v1.5.4 // indirect 36 | github.com/google/go-cmp v0.7.0 // indirect 37 | github.com/google/uuid v1.6.0 // indirect 38 | github.com/hashicorp/cli v1.1.7 // indirect 39 | github.com/hashicorp/errwrap v1.1.0 // indirect 40 | github.com/hashicorp/go-checkpoint v0.5.0 // indirect 41 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 42 | github.com/hashicorp/go-cty v1.5.0 // indirect 43 | github.com/hashicorp/go-hclog v1.6.3 // indirect 44 | github.com/hashicorp/go-multierror v1.1.1 // indirect 45 | github.com/hashicorp/go-plugin v1.7.0 // indirect 46 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 47 | github.com/hashicorp/go-uuid v1.0.3 // indirect 48 | github.com/hashicorp/go-version v1.7.0 // indirect 49 | github.com/hashicorp/hc-install v0.9.2 // indirect 50 | github.com/hashicorp/hcl/v2 v2.24.0 // indirect 51 | github.com/hashicorp/logutils v1.0.0 // indirect 52 | github.com/hashicorp/terraform-exec v0.24.0 // indirect 53 | github.com/hashicorp/terraform-json v0.27.2 // indirect 54 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 // indirect 55 | github.com/hashicorp/terraform-registry-address v0.4.0 // indirect 56 | github.com/hashicorp/terraform-svchost v0.1.1 // indirect 57 | github.com/hashicorp/yamux v0.1.2 // indirect 58 | github.com/huandu/xstrings v1.3.3 // indirect 59 | github.com/imdario/mergo v0.3.15 // indirect 60 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 61 | github.com/klauspost/compress v1.18.1 // indirect 62 | github.com/mattn/go-colorable v0.1.14 // indirect 63 | github.com/mattn/go-isatty v0.0.20 // indirect 64 | github.com/mattn/go-runewidth v0.0.9 // indirect 65 | github.com/mitchellh/copystructure v1.2.0 // indirect 66 | github.com/mitchellh/go-homedir v1.1.0 // indirect 67 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 68 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 69 | github.com/mitchellh/mapstructure v1.5.0 // indirect 70 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 71 | github.com/oklog/run v1.1.0 // indirect 72 | github.com/opencontainers/go-digest v1.0.0 // indirect 73 | github.com/opencontainers/image-spec v1.1.1 // indirect 74 | github.com/posener/complete v1.2.3 // indirect 75 | github.com/shopspring/decimal v1.3.1 // indirect 76 | github.com/sirupsen/logrus v1.9.3 // indirect 77 | github.com/spf13/cast v1.5.0 // indirect 78 | github.com/spf13/pflag v1.0.9 // indirect 79 | github.com/vbatts/tar-split v0.12.2 // indirect 80 | github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect 81 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 82 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 83 | github.com/yuin/goldmark v1.7.7 // indirect 84 | github.com/yuin/goldmark-meta v1.1.0 // indirect 85 | github.com/zclconf/go-cty v1.17.0 // indirect 86 | go.abhg.dev/goldmark/frontmatter v0.2.0 // indirect 87 | golang.org/x/crypto v0.45.0 // indirect 88 | golang.org/x/exp v0.0.0-20230809150735-7b3493d9a819 // indirect 89 | golang.org/x/mod v0.30.0 // indirect 90 | golang.org/x/net v0.47.0 // indirect 91 | golang.org/x/oauth2 v0.33.0 // indirect 92 | golang.org/x/sync v0.18.0 // indirect 93 | golang.org/x/sys v0.38.0 // indirect 94 | golang.org/x/text v0.31.0 // indirect 95 | golang.org/x/tools v0.39.0 // indirect 96 | google.golang.org/appengine v1.6.8 // indirect 97 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect 98 | google.golang.org/grpc v1.75.1 // indirect 99 | google.golang.org/protobuf v1.36.9 // indirect 100 | gopkg.in/yaml.v2 v2.4.0 // indirect 101 | gopkg.in/yaml.v3 v3.0.1 // indirect 102 | ) 103 | -------------------------------------------------------------------------------- /internal/provider/tag_resource.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/chainguard-dev/terraform-provider-oci/pkg/validators" 8 | "github.com/google/go-containerregistry/pkg/name" 9 | "github.com/google/go-containerregistry/pkg/v1/remote" 10 | "github.com/hashicorp/terraform-plugin-framework/path" 11 | "github.com/hashicorp/terraform-plugin-framework/resource" 12 | "github.com/hashicorp/terraform-plugin-framework/resource/schema" 13 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" 14 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" 15 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 16 | "github.com/hashicorp/terraform-plugin-framework/types" 17 | ) 18 | 19 | var ( 20 | _ resource.Resource = &TagResource{} 21 | _ resource.ResourceWithImportState = &TagResource{} 22 | ) 23 | 24 | func NewTagResource() resource.Resource { 25 | return &TagResource{} 26 | } 27 | 28 | // TagResource defines the resource implementation. 29 | type TagResource struct { 30 | popts ProviderOpts 31 | } 32 | 33 | // TagResourceModel describes the resource data model. 34 | type TagResourceModel struct { 35 | Id types.String `tfsdk:"id"` 36 | TaggedRef types.String `tfsdk:"tagged_ref"` 37 | 38 | DigestRef types.String `tfsdk:"digest_ref"` 39 | Tag types.String `tfsdk:"tag"` 40 | } 41 | 42 | func (r *TagResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { 43 | resp.TypeName = req.ProviderTypeName + "_tag" 44 | } 45 | 46 | func (r *TagResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { 47 | resp.Schema = schema.Schema{ 48 | MarkdownDescription: "Tag an existing image by digest.", 49 | Attributes: map[string]schema.Attribute{ 50 | "digest_ref": schema.StringAttribute{ 51 | MarkdownDescription: "Image ref by digest to apply the tag to.", 52 | Required: true, 53 | Validators: []validator.String{validators.DigestValidator{}}, 54 | PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, 55 | }, 56 | "tag": schema.StringAttribute{ 57 | MarkdownDescription: "Tag to apply to the image.", 58 | Required: true, 59 | Validators: []validator.String{validators.TagValidator{}}, 60 | PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, 61 | }, 62 | 63 | "tagged_ref": schema.StringAttribute{ 64 | Computed: true, 65 | MarkdownDescription: "The resulting fully-qualified image ref by digest (e.g. {repo}:tag@sha256:deadbeef).", 66 | PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, 67 | }, 68 | "id": schema.StringAttribute{ 69 | Computed: true, 70 | MarkdownDescription: "The resulting fully-qualified image ref by digest (e.g. {repo}:tag@sha256:deadbeef).", 71 | PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, 72 | }, 73 | }, 74 | } 75 | } 76 | 77 | func (r *TagResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { 78 | // Prevent panic if the provider has not been configured. 79 | if req.ProviderData == nil { 80 | return 81 | } 82 | 83 | popts, ok := req.ProviderData.(*ProviderOpts) 84 | if !ok || popts == nil { 85 | resp.Diagnostics.AddError("Client Error", "invalid provider data") 86 | return 87 | } 88 | r.popts = *popts 89 | } 90 | 91 | func (r *TagResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { 92 | var data *TagResourceModel 93 | resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) 94 | if resp.Diagnostics.HasError() { 95 | return 96 | } 97 | 98 | digest, err := r.doTag(ctx, data) 99 | if err != nil { 100 | resp.Diagnostics.AddError("Tag Error", fmt.Sprintf("Error tagging image: %s", err.Error())) 101 | return 102 | } 103 | 104 | data.Id = types.StringValue(digest) 105 | data.TaggedRef = types.StringValue(digest) 106 | 107 | // Save data into Terraform state 108 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 109 | } 110 | 111 | func (r *TagResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { 112 | var data *TagResourceModel 113 | resp.Diagnostics.Append(req.State.Get(ctx, &data)...) 114 | if resp.Diagnostics.HasError() { 115 | return 116 | } 117 | 118 | // Don't actually tag, but check whether the digest is already tagged so we get a useful diff. 119 | // If the digest is already tagged, we'll set the ID and tagged_ref to the correct output value. 120 | // Otherwise, we'll set them to empty strings so that the create will run when applied. 121 | 122 | d, err := name.NewDigest(data.DigestRef.ValueString()) 123 | if err != nil { 124 | resp.Diagnostics.AddError("Tag Error", fmt.Sprintf("Error parsing digest ref: %s", err.Error())) 125 | return 126 | } 127 | 128 | t := d.Context().Tag(data.Tag.ValueString()) 129 | desc, err := remote.Get(t, r.popts.withContext(ctx)...) 130 | if err != nil { 131 | resp.Diagnostics.AddError("Tag Error", fmt.Sprintf("Error getting image: %s", err.Error())) 132 | return 133 | } 134 | 135 | if desc.Digest.String() != d.DigestStr() { 136 | data.Id = types.StringValue("") 137 | data.TaggedRef = types.StringValue("") 138 | } else { 139 | id := fmt.Sprintf("%s@%s", t.Name(), desc.Digest.String()) 140 | data.Id = types.StringValue(id) 141 | data.TaggedRef = types.StringValue(id) 142 | } 143 | 144 | // Save updated data into Terraform state 145 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 146 | } 147 | 148 | func (r *TagResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { 149 | var data *TagResourceModel 150 | resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) 151 | if resp.Diagnostics.HasError() { 152 | return 153 | } 154 | 155 | digest, err := r.doTag(ctx, data) 156 | if err != nil { 157 | resp.Diagnostics.AddError("Tag Error", fmt.Sprintf("Error tagging image: %s", err.Error())) 158 | return 159 | } 160 | 161 | data.Id = types.StringValue(digest) 162 | data.TaggedRef = types.StringValue(digest) 163 | 164 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 165 | } 166 | 167 | func (r *TagResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { 168 | resp.Diagnostics.Append(req.State.Get(ctx, &TagResourceModel{})...) 169 | } 170 | 171 | func (r *TagResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { 172 | resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) 173 | } 174 | 175 | func (r *TagResource) doTag(ctx context.Context, data *TagResourceModel) (string, error) { 176 | d, err := name.NewDigest(data.DigestRef.ValueString()) 177 | if err != nil { 178 | return "", fmt.Errorf("digest_ref must be a digest reference: %v", err) 179 | } 180 | t := d.Context().Tag(data.Tag.ValueString()) 181 | if err != nil { 182 | return "", fmt.Errorf("error parsing tag: %v", err) 183 | } 184 | desc, err := remote.Get(d, r.popts.withContext(ctx)...) 185 | if err != nil { 186 | return "", fmt.Errorf("error fetching digest: %v", err) 187 | } 188 | if err := remote.Tag(t, desc, r.popts.withContext(ctx)...); err != nil { 189 | return "", fmt.Errorf("error tagging digest: %v", err) 190 | } 191 | digest := fmt.Sprintf("%s@%s", t.Name(), desc.Digest.String()) 192 | return digest, nil 193 | } 194 | -------------------------------------------------------------------------------- /internal/provider/exec_test_data_source_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "testing" 7 | 8 | "github.com/google/go-containerregistry/pkg/name" 9 | "github.com/google/go-containerregistry/pkg/v1/remote" 10 | "github.com/hashicorp/terraform-plugin-framework/providerserver" 11 | "github.com/hashicorp/terraform-plugin-go/tfprotov6" 12 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 13 | ) 14 | 15 | func TestAccExecTestDataSource(t *testing.T) { 16 | img, err := remote.Image(name.MustParseReference("cgr.dev/chainguard/wolfi-base:latest")) 17 | if err != nil { 18 | t.Fatalf("failed to fetch image: %v", err) 19 | } 20 | d, err := img.Digest() 21 | if err != nil { 22 | t.Fatalf("failed to get image digest: %v", err) 23 | } 24 | 25 | resource.Test(t, resource.TestCase{ 26 | PreCheck: func() { testAccPreCheck(t) }, 27 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 28 | Steps: []resource.TestStep{{ 29 | Config: fmt.Sprintf(`data "oci_exec_test" "test" { 30 | digest = "cgr.dev/chainguard/wolfi-base@%s" 31 | 32 | script = "docker run --rm $${IMAGE_NAME} echo hello | grep hello" 33 | }`, d.String()), 34 | Check: resource.ComposeAggregateTestCheckFunc( 35 | resource.TestCheckResourceAttr("data.oci_exec_test.test", "digest", fmt.Sprintf("cgr.dev/chainguard/wolfi-base@%s", d.String())), 36 | resource.TestMatchResourceAttr("data.oci_exec_test.test", "id", regexp.MustCompile(".*cgr.dev/chainguard/wolfi-base@"+d.String())), 37 | resource.TestCheckResourceAttr("data.oci_exec_test.test", "exit_code", "0"), 38 | resource.TestCheckResourceAttr("data.oci_exec_test.test", "output", ""), 39 | ), 40 | }, { 41 | Config: fmt.Sprintf(`data "oci_exec_test" "env" { 42 | digest = "cgr.dev/chainguard/wolfi-base@%s" 43 | 44 | env { 45 | name = "FOO" 46 | value = "bar" 47 | } 48 | env { 49 | name = "BAR" 50 | value = "baz" 51 | } 52 | 53 | script = "echo IMAGE_NAME=$${IMAGE_NAME} IMAGE_REPOSITORY=$${IMAGE_REPOSITORY} IMAGE_REGISTRY=$${IMAGE_REGISTRY} FOO=bar BAR=baz FREE_PORT=$${FREE_PORT}" 54 | }`, d.String()), 55 | Check: resource.ComposeAggregateTestCheckFunc( 56 | resource.TestCheckResourceAttr("data.oci_exec_test.env", "digest", fmt.Sprintf("cgr.dev/chainguard/wolfi-base@%s", d.String())), 57 | resource.TestMatchResourceAttr("data.oci_exec_test.env", "id", regexp.MustCompile(".*cgr.dev/chainguard/wolfi-base@"+d.String())), 58 | resource.TestCheckResourceAttr("data.oci_exec_test.env", "exit_code", "0"), 59 | resource.TestCheckResourceAttr("data.oci_exec_test.env", "output", ""), 60 | ), 61 | }, { 62 | Config: fmt.Sprintf(`data "oci_exec_test" "fail" { 63 | digest = "cgr.dev/chainguard/wolfi-base@%s" 64 | 65 | script = "echo failed && exit 12" 66 | }`, d.String()), 67 | ExpectError: regexp.MustCompile(`Test failed for ref\ncgr.dev/chainguard/wolfi-base@sha256:[0-9a-f]{64},\ngot error: exit status 12\nfailed`), 68 | // We don't get the exit code or output because the datasource failed. 69 | }, { 70 | Config: fmt.Sprintf(`data "oci_exec_test" "timeout" { 71 | digest = "cgr.dev/chainguard/wolfi-base@%s" 72 | timeout_seconds = 1 73 | 74 | script = "sleep 6" 75 | }`, d.String()), 76 | ExpectError: regexp.MustCompile(`Test for ref\ncgr.dev/chainguard/wolfi-base@sha256:[0-9a-f]{64}\ntimed out after 1 seconds`), 77 | }, { 78 | Config: fmt.Sprintf(`data "oci_exec_test" "working_dir" { 79 | digest = "cgr.dev/chainguard/wolfi-base@%s" 80 | working_dir = "${path.module}/../../" 81 | 82 | script = "grep 'Terraform Provider for OCI operations' README.md" 83 | }`, d.String()), 84 | Check: resource.ComposeAggregateTestCheckFunc( 85 | resource.TestCheckResourceAttr("data.oci_exec_test.working_dir", "digest", fmt.Sprintf("cgr.dev/chainguard/wolfi-base@%s", d.String())), 86 | resource.TestMatchResourceAttr("data.oci_exec_test.working_dir", "id", regexp.MustCompile(".*cgr.dev/chainguard/wolfi-base@"+d.String())), 87 | resource.TestCheckResourceAttr("data.oci_exec_test.working_dir", "exit_code", "0"), 88 | resource.TestCheckResourceAttr("data.oci_exec_test.working_dir", "output", ""), 89 | ), 90 | }}, 91 | }) 92 | 93 | resource.Test(t, resource.TestCase{ 94 | PreCheck: func() { testAccPreCheck(t) }, 95 | ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ 96 | "oci": providerserver.NewProtocol6WithError(&OCIProvider{ 97 | defaultExecTimeoutSeconds: 1, 98 | }), 99 | }, Steps: []resource.TestStep{{ 100 | Config: fmt.Sprintf(`data "oci_exec_test" "provider-timeout" { 101 | digest = "cgr.dev/chainguard/wolfi-base@%s" 102 | 103 | script = "sleep 6" 104 | }`, d.String()), 105 | ExpectError: regexp.MustCompile(`Test for ref\ncgr.dev/chainguard/wolfi-base@sha256:[0-9a-f]{64}\ntimed out after 1 seconds`), 106 | }}, 107 | }) 108 | } 109 | 110 | func TestAccExecTestDataSource_FreePort(t *testing.T) { 111 | img, err := remote.Image(name.MustParseReference("cgr.dev/chainguard/wolfi-base:latest")) 112 | if err != nil { 113 | t.Fatalf("failed to fetch image: %v", err) 114 | } 115 | d, err := img.Digest() 116 | if err != nil { 117 | t.Fatalf("failed to get image digest: %v", err) 118 | } 119 | 120 | // Test that we can spin up a bunch of parallel tasks that each get 121 | // a unique free port, even if they don't run anything on that port. 122 | cfg := "" 123 | checks := []resource.TestCheckFunc{} 124 | num := 10 125 | for i := 0; i < num; i++ { 126 | cfg += fmt.Sprintf(`data "oci_exec_test" "freeport-%d" { 127 | digest = "cgr.dev/chainguard/wolfi-base@%s" 128 | script = "docker run --rm $${IMAGE_NAME} echo $${FREE_PORT}" 129 | } 130 | `, i, d.String()) 131 | checks = append(checks, 132 | resource.TestCheckResourceAttr(fmt.Sprintf("data.oci_exec_test.freeport-%d", i), "digest", fmt.Sprintf("cgr.dev/chainguard/wolfi-base@%s", d.String())), 133 | resource.TestMatchResourceAttr(fmt.Sprintf("data.oci_exec_test.freeport-%d", i), "id", regexp.MustCompile(".*cgr.dev/chainguard/wolfi-base@"+d.String())), 134 | ) 135 | } 136 | 137 | resource.Test(t, resource.TestCase{ 138 | PreCheck: func() { testAccPreCheck(t) }, 139 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 140 | Steps: []resource.TestStep{{ 141 | Config: cfg, 142 | Check: resource.ComposeAggregateTestCheckFunc(checks...), 143 | }}, 144 | }) 145 | } 146 | 147 | func TestAccExecTestDataSource_SkipExecTests(t *testing.T) { 148 | img, err := remote.Image(name.MustParseReference("cgr.dev/chainguard/wolfi-base:latest")) 149 | if err != nil { 150 | t.Fatalf("failed to fetch image: %v", err) 151 | } 152 | d, err := img.Digest() 153 | if err != nil { 154 | t.Fatalf("failed to get image digest: %v", err) 155 | } 156 | 157 | resource.Test(t, resource.TestCase{ 158 | PreCheck: func() { testAccPreCheck(t) }, 159 | ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ 160 | "oci": providerserver.NewProtocol6WithError(&OCIProvider{ 161 | skipExecTests: true, 162 | }), 163 | }, Steps: []resource.TestStep{{ 164 | Config: fmt.Sprintf(`data "oci_exec_test" "skipped" { 165 | digest = "cgr.dev/chainguard/wolfi-base@%s" 166 | script = "exit 1" 167 | }`, d.String()), 168 | }}, 169 | }) 170 | 171 | t.Setenv("OCI_SKIP_EXEC_TESTS", "true") 172 | 173 | resource.Test(t, resource.TestCase{ 174 | PreCheck: func() { testAccPreCheck(t) }, 175 | ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ 176 | "oci": providerserver.NewProtocol6WithError(&OCIProvider{skipExecTests: false}), 177 | }, Steps: []resource.TestStep{{ 178 | Config: fmt.Sprintf(`data "oci_exec_test" "skipped" { 179 | digest = "cgr.dev/chainguard/wolfi-base@%s" 180 | script = "exit 1" 181 | }`, d.String()), 182 | }}, 183 | }) 184 | } 185 | -------------------------------------------------------------------------------- /internal/provider/tags_resource.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/json" 7 | "fmt" 8 | 9 | "github.com/chainguard-dev/terraform-provider-oci/pkg/validators" 10 | "github.com/google/go-containerregistry/pkg/name" 11 | "github.com/google/go-containerregistry/pkg/v1/remote" 12 | "github.com/hashicorp/terraform-plugin-framework/path" 13 | "github.com/hashicorp/terraform-plugin-framework/resource" 14 | "github.com/hashicorp/terraform-plugin-framework/resource/schema" 15 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" 16 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" 17 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" 18 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 19 | "github.com/hashicorp/terraform-plugin-framework/types" 20 | "github.com/hashicorp/terraform-plugin-framework/types/basetypes" 21 | ) 22 | 23 | var ( 24 | _ resource.Resource = &TagsResource{} 25 | _ resource.ResourceWithImportState = &TagsResource{} 26 | ) 27 | 28 | func NewTagsResource() resource.Resource { 29 | return &TagsResource{} 30 | } 31 | 32 | // TagsResource defines the resource implementation. 33 | type TagsResource struct { 34 | popts ProviderOpts 35 | } 36 | 37 | // TagsResourceModel describes the resource data model. 38 | type TagsResourceModel struct { 39 | Id types.String `tfsdk:"id"` 40 | 41 | Repo string `tfsdk:"repo"` 42 | Tags map[string]string `tfsdk:"tags"` // tag -> digest 43 | } 44 | 45 | func (r *TagsResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { 46 | resp.TypeName = req.ProviderTypeName + "_tags" 47 | } 48 | 49 | func (r *TagsResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { 50 | resp.Schema = schema.Schema{ 51 | MarkdownDescription: "Tag many digests with many tags.", 52 | Attributes: map[string]schema.Attribute{ 53 | "repo": schema.StringAttribute{ 54 | MarkdownDescription: "Repository for the tags.", 55 | Required: true, 56 | Validators: []validator.String{validators.RepoValidator{}}, 57 | PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, 58 | }, 59 | "tags": schema.MapAttribute{ 60 | MarkdownDescription: "Map of tag -> digest to apply.", 61 | Required: true, 62 | ElementType: basetypes.StringType{}, 63 | // TODO: validator -- check that digests and tags are well formed. 64 | PlanModifiers: []planmodifier.Map{mapplanmodifier.RequiresReplace()}, 65 | }, 66 | 67 | // TODO: any outputs? 68 | 69 | "id": schema.StringAttribute{ 70 | Computed: true, 71 | MarkdownDescription: "The resulting fully-qualified image ref by digest (e.g. {repo}:tag@sha256:deadbeef).", 72 | PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, 73 | }, 74 | }, 75 | } 76 | } 77 | 78 | func (r *TagsResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { 79 | // Prevent panic if the provider has not been configured. 80 | if req.ProviderData == nil { 81 | return 82 | } 83 | 84 | popts, ok := req.ProviderData.(*ProviderOpts) 85 | if !ok || popts == nil { 86 | resp.Diagnostics.AddError("Client Error", "invalid provider data") 87 | return 88 | } 89 | r.popts = *popts 90 | } 91 | 92 | func (r *TagsResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { 93 | var data *TagsResourceModel 94 | resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) 95 | if resp.Diagnostics.HasError() { 96 | return 97 | } 98 | 99 | digest, err := r.doTags(ctx, data) 100 | if err != nil { 101 | resp.Diagnostics.AddError("Tag Error", fmt.Sprintf("Error tagging image: %s", err.Error())) 102 | return 103 | } 104 | 105 | data.Id = types.StringValue(digest) 106 | 107 | // Save data into Terraform state 108 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 109 | } 110 | 111 | func (r *TagsResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { 112 | var data *TagsResourceModel 113 | resp.Diagnostics.Append(req.State.Get(ctx, &data)...) 114 | if resp.Diagnostics.HasError() { 115 | return 116 | } 117 | 118 | // Don't actually tag, but check whether the digests are already tagged with all requested tags, so we get a useful diff. 119 | // If the digests are already tagged with all requested tags, we'll set the ID to the correct output value. 120 | // Otherwise, we'll set them to empty strings so that the create will run when applied. 121 | // TODO: Can we get a better diff about what new updates will be applied? 122 | if id, err := r.checkTags(ctx, data); err != nil { 123 | data.Id = types.StringValue("") 124 | } else { 125 | data.Id = types.StringValue(id) 126 | } 127 | 128 | // Save updated data into Terraform state 129 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 130 | } 131 | 132 | func (r *TagsResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { 133 | var data *TagsResourceModel 134 | resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) 135 | if resp.Diagnostics.HasError() { 136 | return 137 | } 138 | 139 | id, err := r.doTags(ctx, data) 140 | if err != nil { 141 | resp.Diagnostics.AddError("Tag Error", fmt.Sprintf("Error tagging images: %s", err.Error())) 142 | return 143 | } 144 | 145 | data.Id = types.StringValue(id) 146 | 147 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 148 | } 149 | 150 | func (r *TagsResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { 151 | resp.Diagnostics.Append(req.State.Get(ctx, &TagsResourceModel{})...) 152 | } 153 | 154 | func (r *TagsResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { 155 | resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) 156 | } 157 | 158 | func (r *TagsResource) checkTags(ctx context.Context, data *TagsResourceModel) (string, error) { 159 | repo, err := name.NewRepository(data.Repo) 160 | if err != nil { 161 | return "", fmt.Errorf("error parsing repo ref: %w", err) 162 | } 163 | 164 | for tag, digest := range data.Tags { 165 | t := repo.Tag(tag) 166 | desc, err := remote.Head(t, r.popts.withContext(ctx)...) 167 | if err != nil { 168 | return "", fmt.Errorf("error getting tag %q: %w", t, err) 169 | } 170 | if desc.Digest.String() != digest { 171 | return "", fmt.Errorf("tag %q does not point to digest %q (got %q)", tag, digest, desc.Digest.String()) 172 | } 173 | } 174 | // ID is the SHA256 of the JSONified map. 175 | b, err := json.Marshal(data.Tags) 176 | if err != nil { 177 | return "", fmt.Errorf("error marshaling tags: %w", err) 178 | } 179 | return fmt.Sprintf("%x", sha256.Sum256(b)), nil 180 | } 181 | 182 | func (r *TagsResource) doTags(ctx context.Context, data *TagsResourceModel) (string, error) { 183 | repo, err := name.NewRepository(data.Repo) 184 | if err != nil { 185 | return "", fmt.Errorf("error parsing repo ref: %w", err) 186 | } 187 | 188 | for tag, digest := range data.Tags { 189 | t := repo.Tag(tag) 190 | d := repo.Digest(digest) 191 | desc, err := remote.Get(d, r.popts.withContext(ctx)...) 192 | if err != nil { 193 | return "", fmt.Errorf("error getting digest %q: %w", digest, err) 194 | } 195 | if err := remote.Tag(t, desc, r.popts.withContext(ctx)...); err != nil { 196 | return "", fmt.Errorf("error tagging %q with %q: %w", digest, tag, err) 197 | } 198 | } 199 | 200 | // ID is the SHA256 of the JSONified map. 201 | b, err := json.Marshal(data.Tags) 202 | if err != nil { 203 | return "", fmt.Errorf("error marshaling tags: %w", err) 204 | } 205 | return fmt.Sprintf("%x", sha256.Sum256(b)), nil 206 | } 207 | -------------------------------------------------------------------------------- /internal/provider/get_function_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "fmt" 5 | "math/big" 6 | "testing" 7 | "time" 8 | 9 | ocitesting "github.com/chainguard-dev/terraform-provider-oci/testing" 10 | v1 "github.com/google/go-containerregistry/pkg/v1" 11 | "github.com/google/go-containerregistry/pkg/v1/empty" 12 | "github.com/google/go-containerregistry/pkg/v1/mutate" 13 | "github.com/google/go-containerregistry/pkg/v1/random" 14 | "github.com/google/go-containerregistry/pkg/v1/remote" 15 | ggcrtypes "github.com/google/go-containerregistry/pkg/v1/types" 16 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 17 | "github.com/hashicorp/terraform-plugin-testing/knownvalue" 18 | "github.com/hashicorp/terraform-plugin-testing/statecheck" 19 | "github.com/hashicorp/terraform-plugin-testing/tfversion" 20 | ) 21 | 22 | func TestGetFunction(t *testing.T) { 23 | repo, cleanup := ocitesting.SetupRepository(t, "test") 24 | defer cleanup() 25 | 26 | // Push an image to the local registry. 27 | ref := repo.Tag("latest") 28 | t.Logf("Using ref: %s", ref) 29 | img, err := random.Image(1024, 3) 30 | if err != nil { 31 | t.Fatalf("failed to create image: %v", err) 32 | } 33 | img = mutate.MediaType(img, ggcrtypes.OCIManifestSchema1) 34 | img = mutate.Annotations(img, map[string]string{ //nolint:forcetypeassert 35 | "foo": "bar", 36 | }).(v1.Image) 37 | img, err = mutate.Config(img, v1.Config{ 38 | Env: []string{"FOO=BAR"}, 39 | User: "nobody", 40 | Entrypoint: []string{"/bin/sh"}, 41 | Cmd: []string{"-c", "echo hello world"}, 42 | WorkingDir: "/tmp", 43 | }) 44 | if err != nil { 45 | t.Fatalf("failed to mutate image: %v", err) 46 | } 47 | now := time.Now() 48 | img, err = mutate.CreatedAt(img, v1.Time{Time: now}) 49 | if err != nil { 50 | t.Fatalf("failed to mutate image: %v", err) 51 | } 52 | if err := remote.Write(ref, img); err != nil { 53 | t.Fatalf("failed to write image: %v", err) 54 | } 55 | 56 | d, err := img.Digest() 57 | if err != nil { 58 | t.Fatalf("failed to get image digest: %v", err) 59 | } 60 | 61 | isDescriptor := func(mt ggcrtypes.MediaType) knownvalue.Check { 62 | return knownvalue.ObjectExact(map[string]knownvalue.Check{ 63 | "digest": knownvalue.StringRegexp(digestRE), 64 | "media_type": knownvalue.StringExact(string(mt)), 65 | "size": knownvalue.NotNull(), 66 | "platform": knownvalue.Null(), 67 | }) 68 | } 69 | 70 | resource.Test(t, resource.TestCase{ 71 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 72 | TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, 73 | Steps: []resource.TestStep{{ 74 | Config: fmt.Sprintf(`output "gotten" { value = provider::oci::get(%q) }`, ref), 75 | ConfigStateChecks: []statecheck.StateCheck{ 76 | statecheck.ExpectKnownOutputValue("gotten", knownvalue.ObjectExact(map[string]knownvalue.Check{ 77 | "full_ref": knownvalue.StringExact(fmt.Sprintf("%s@%s", ref.Context().Name(), d.String())), 78 | "digest": knownvalue.StringExact(d.String()), 79 | "tag": knownvalue.StringExact("latest"), 80 | "manifest": knownvalue.ObjectExact(map[string]knownvalue.Check{ 81 | "schema_version": knownvalue.NumberExact(big.NewFloat(2)), 82 | "media_type": knownvalue.StringExact(string(ggcrtypes.OCIManifestSchema1)), 83 | "config": isDescriptor(ggcrtypes.DockerConfigJSON), 84 | "layers": knownvalue.ListExact([]knownvalue.Check{ 85 | isDescriptor(ggcrtypes.DockerLayer), 86 | isDescriptor(ggcrtypes.DockerLayer), 87 | isDescriptor(ggcrtypes.DockerLayer), 88 | }), 89 | "annotations": knownvalue.MapExact(map[string]knownvalue.Check{"foo": knownvalue.StringExact("bar")}), 90 | "manifests": knownvalue.Null(), 91 | "subject": knownvalue.Null(), 92 | }), 93 | "config": knownvalue.ObjectExact(map[string]knownvalue.Check{ 94 | "env": knownvalue.ListExact([]knownvalue.Check{knownvalue.StringExact("FOO=BAR")}), 95 | "user": knownvalue.StringExact("nobody"), 96 | "entrypoint": knownvalue.ListExact([]knownvalue.Check{knownvalue.StringExact("/bin/sh")}), 97 | "cmd": knownvalue.ListExact([]knownvalue.Check{knownvalue.StringExact("-c"), knownvalue.StringExact("echo hello world")}), 98 | "working_dir": knownvalue.StringExact("/tmp"), 99 | "created_at": knownvalue.StringExact(now.Format(time.RFC3339)), 100 | }), 101 | "images": knownvalue.Null(), 102 | })), 103 | }, 104 | }}, 105 | }) 106 | 107 | // Push an index to the local registry. 108 | var idx v1.ImageIndex = empty.Index 109 | for _, plat := range []v1.Platform{ 110 | {OS: "linux", Architecture: "amd64"}, 111 | {OS: "windows", Architecture: "arm64", Variant: "v3", OSVersion: "1-rc365"}, 112 | } { 113 | plat := plat 114 | img, err := random.Image(1024, 3) 115 | if err != nil { 116 | t.Fatalf("failed to create image: %v", err) 117 | } 118 | img = mutate.MediaType(img, ggcrtypes.OCIManifestSchema1) 119 | idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ 120 | Add: img, 121 | Descriptor: v1.Descriptor{Platform: &plat}, 122 | }) 123 | } 124 | idx = mutate.IndexMediaType(idx, ggcrtypes.OCIImageIndex) 125 | idx = mutate.Annotations(idx, map[string]string{ //nolint:forcetypeassert 126 | "foo": "bar", 127 | }).(v1.ImageIndex) 128 | 129 | ref = repo.Tag("index") 130 | t.Logf("Using ref: %s", ref) 131 | if err := remote.WriteIndex(ref, idx); err != nil { 132 | t.Fatalf("failed to write index: %v", err) 133 | } 134 | 135 | d, err = idx.Digest() 136 | if err != nil { 137 | t.Fatalf("failed to get index digest: %v", err) 138 | } 139 | 140 | // An index specified by tag has a .tag attribute, and all the other index manifest attributes. 141 | resource.Test(t, resource.TestCase{ 142 | PreCheck: func() { testAccPreCheck(t) }, 143 | TerraformVersionChecks: []tfversion.TerraformVersionCheck{tfversion.SkipBelow(tfversion.Version1_8_0)}, 144 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 145 | Steps: []resource.TestStep{{ 146 | Config: fmt.Sprintf(`output "gotten" { value = provider::oci::get(%q) }`, ref), 147 | ConfigStateChecks: []statecheck.StateCheck{ 148 | statecheck.ExpectKnownOutputValue("gotten", knownvalue.ObjectExact(map[string]knownvalue.Check{ 149 | "full_ref": knownvalue.StringExact(fmt.Sprintf("%s@%s", ref.Context().Name(), d.String())), 150 | "digest": knownvalue.StringExact(d.String()), 151 | "tag": knownvalue.StringExact("index"), 152 | "manifest": knownvalue.ObjectExact(map[string]knownvalue.Check{ 153 | "schema_version": knownvalue.NumberExact(big.NewFloat(2)), 154 | "media_type": knownvalue.StringExact(string(ggcrtypes.OCIImageIndex)), 155 | "manifests": knownvalue.ListExact([]knownvalue.Check{ 156 | knownvalue.ObjectExact(map[string]knownvalue.Check{ 157 | "digest": knownvalue.StringRegexp(digestRE), 158 | "platform": knownvalue.ObjectExact(map[string]knownvalue.Check{ 159 | "os": knownvalue.StringExact("linux"), 160 | "architecture": knownvalue.StringExact("amd64"), 161 | "variant": knownvalue.StringExact(""), 162 | "os_version": knownvalue.StringExact(""), 163 | }), 164 | "media_type": knownvalue.StringExact(string(ggcrtypes.OCIManifestSchema1)), 165 | "size": knownvalue.NotNull(), 166 | }), 167 | knownvalue.ObjectExact(map[string]knownvalue.Check{ 168 | "digest": knownvalue.StringRegexp(digestRE), 169 | "platform": knownvalue.ObjectExact(map[string]knownvalue.Check{ 170 | "os": knownvalue.StringExact("windows"), 171 | "architecture": knownvalue.StringExact("arm64"), 172 | "variant": knownvalue.StringExact("v3"), 173 | "os_version": knownvalue.StringExact("1-rc365"), 174 | }), 175 | "media_type": knownvalue.StringExact(string(ggcrtypes.OCIManifestSchema1)), 176 | "size": knownvalue.NotNull(), 177 | }), 178 | }), 179 | "annotations": knownvalue.MapExact(map[string]knownvalue.Check{"foo": knownvalue.StringExact("bar")}), 180 | "layers": knownvalue.Null(), 181 | "subject": knownvalue.Null(), 182 | "config": knownvalue.Null(), 183 | }), 184 | "config": knownvalue.Null(), 185 | "images": knownvalue.MapExact(map[string]knownvalue.Check{ 186 | "linux/amd64": knownvalue.ObjectExact(map[string]knownvalue.Check{ 187 | "digest": knownvalue.StringRegexp(digestRE), 188 | "image_ref": knownvalue.NotNull(), 189 | }), 190 | "windows/arm64/v3:1-rc365": knownvalue.ObjectExact(map[string]knownvalue.Check{ 191 | "digest": knownvalue.StringRegexp(digestRE), 192 | "image_ref": knownvalue.NotNull(), 193 | }), 194 | }), 195 | })), 196 | }, 197 | }}, 198 | }) 199 | } 200 | -------------------------------------------------------------------------------- /pkg/structure/structure.go: -------------------------------------------------------------------------------- 1 | package structure 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "os" 9 | "regexp" 10 | "strings" 11 | 12 | v1 "github.com/google/go-containerregistry/pkg/v1" 13 | "github.com/google/go-containerregistry/pkg/v1/mutate" 14 | "github.com/jonjohnsonjr/targz/tarfs" 15 | ) 16 | 17 | // mask out file type bits for permission comparisons (e.g., ignore directory and symlink bits). 18 | const permissionMask = 0o777 | os.ModeSetuid | os.ModeSetgid | os.ModeSticky 19 | 20 | type Condition interface { 21 | Check(v1.Image) error 22 | } 23 | 24 | type Conditions []Condition 25 | 26 | func (c Conditions) Check(i v1.Image) error { 27 | var errs []error 28 | for _, cond := range c { 29 | errs = append(errs, cond.Check(i)) 30 | } 31 | return errors.Join(errs...) 32 | } 33 | 34 | type EnvCondition struct { 35 | Want map[string]string 36 | } 37 | 38 | func (e EnvCondition) Check(i v1.Image) error { 39 | cf, err := i.ConfigFile() 40 | if err != nil { 41 | return err 42 | } 43 | var errs []error 44 | split := splitEnvs(cf.Config.Env) 45 | for k, v := range e.Want { 46 | if split[k] != v { 47 | errs = append(errs, fmt.Errorf("env %q does not match %q (got %q)", k, v, split[k])) 48 | } 49 | if separator, exists := verifyEnv[k]; exists { 50 | for p := range strings.SplitSeq(v, separator) { 51 | if !strings.HasPrefix(p, "/") || p == fmt.Sprintf("$%s", k) { 52 | errs = append(errs, fmt.Errorf("env %q value %q references relative path or literal $ string %q", k, v, p)) 53 | } 54 | } 55 | } 56 | } 57 | return errors.Join(errs...) 58 | } 59 | 60 | func splitEnvs(in []string) map[string]string { 61 | out := make(map[string]string, len(in)) 62 | for _, i := range in { 63 | k, v, _ := strings.Cut(i, "=") 64 | out[k] = v 65 | } 66 | return out 67 | } 68 | 69 | type FilesCondition struct { 70 | Want map[string]File 71 | } 72 | 73 | type File struct { 74 | Optional bool 75 | Mode *os.FileMode 76 | Regex string 77 | } 78 | 79 | func (f FilesCondition) Check(i v1.Image) error { 80 | ls, err := i.Layers() 81 | if err != nil { 82 | return err 83 | } 84 | var rc io.ReadCloser 85 | // If there's only one layer, we don't need to extract it. 86 | if len(ls) == 1 { 87 | rc, err = ls[0].Uncompressed() 88 | if err != nil { 89 | return err 90 | } 91 | } else { 92 | rc = mutate.Extract(i) 93 | } 94 | 95 | tmp, err := os.CreateTemp("", "structure-test") 96 | if err != nil { 97 | return err 98 | } 99 | defer os.Remove(tmp.Name()) 100 | 101 | defer rc.Close() 102 | 103 | size, err := io.Copy(tmp, rc) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | fsys, err := tarfs.New(tmp, size) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | var errs []error 114 | 115 | for path, f := range f.Want { 116 | // https://pkg.go.dev/io/fs#ValidPath 117 | name := strings.TrimPrefix(path, "/") 118 | 119 | tf, err := fsys.Open(name) 120 | if err != nil { 121 | // Optional files may only exist across a subset 122 | // of structure test runs but we want to avoid erroring so that 123 | // we can specify these conditions without having to add per-image conditions 124 | if errors.Is(err, fs.ErrNotExist) && f.Optional { 125 | continue 126 | } else if errors.Is(err, fs.ErrNotExist) { 127 | // Avoid breaking backward compatibility. 128 | errs = append(errs, fmt.Errorf("file %q not found", path)) 129 | } else { 130 | // Any other error is unexpected, so we want to retain it. 131 | errs = append(errs, fmt.Errorf("opening %q: %w", path, err)) 132 | } 133 | continue 134 | } 135 | if f.Regex != "" { 136 | // We care about the contents, so read and buffer them and regexp. 137 | got, err := io.ReadAll(tf) 138 | if err != nil { 139 | errs = append(errs, fmt.Errorf("reading %q: %w", path, err)) 140 | continue 141 | } 142 | 143 | if !regexp.MustCompile(f.Regex).Match(got) { 144 | errs = append(errs, fmt.Errorf("file %q does not match regexp %q, got:\n%s", path, f.Regex, got)) 145 | } 146 | } 147 | if f.Mode != nil { 148 | stat, err := tf.Stat() 149 | if err != nil { 150 | errs = append(errs, fmt.Errorf("statting %q: %w", path, err)) 151 | continue 152 | } 153 | 154 | got := stat.Mode() & permissionMask 155 | want := *f.Mode & permissionMask 156 | 157 | if got != want { 158 | errs = append(errs, fmt.Errorf("file %q mode does not match %o (got %o)", path, want, got)) 159 | } 160 | } 161 | } 162 | 163 | return errors.Join(errs...) 164 | } 165 | 166 | type DirsCondition struct { 167 | Want map[string]Dir 168 | } 169 | 170 | type Dir struct { 171 | FilesOnly bool // only check file permissions within the directory [structure] 172 | Mode *os.FileMode 173 | Recursive bool 174 | } 175 | 176 | func (d DirsCondition) Check(i v1.Image) error { 177 | ls, err := i.Layers() 178 | if err != nil { 179 | return err 180 | } 181 | var rc io.ReadCloser 182 | // If there's only one layer, we don't need to extract it. 183 | if len(ls) == 1 { 184 | rc, err = ls[0].Uncompressed() 185 | if err != nil { 186 | return err 187 | } 188 | } else { 189 | rc = mutate.Extract(i) 190 | } 191 | 192 | tmp, err := os.CreateTemp("", "structure-test") 193 | if err != nil { 194 | return err 195 | } 196 | defer os.Remove(tmp.Name()) 197 | 198 | defer rc.Close() 199 | 200 | size, err := io.Copy(tmp, rc) 201 | if err != nil { 202 | return err 203 | } 204 | 205 | fsys, err := tarfs.New(tmp, size) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | var errs []error 211 | 212 | for path, dir := range d.Want { 213 | // https://pkg.go.dev/io/fs#ValidPath 214 | name := strings.TrimPrefix(path, "/") 215 | 216 | if !dir.Recursive { 217 | fi, err := fsys.Stat(name) 218 | if err != nil { 219 | errs = append(errs, err) 220 | } 221 | got := fi.Mode() & permissionMask 222 | want := *dir.Mode & permissionMask 223 | 224 | // We only care about the single, top-level directory 225 | if fi.IsDir() && got != want { 226 | errs = append(errs, fmt.Errorf("directory %q mode does not match %o (got %o)", path, want, got)) 227 | } 228 | } else { 229 | err := fs.WalkDir(fsys, name, func(path string, d fs.DirEntry, err error) error { 230 | if err != nil { 231 | return err 232 | } 233 | 234 | // ignore symlinks which will register as 777 235 | if d.Type()&fs.ModeSymlink == fs.ModeSymlink { 236 | return nil 237 | } 238 | 239 | if dir.FilesOnly && d.IsDir() { 240 | return nil 241 | } 242 | 243 | fi, err := d.Info() 244 | if err != nil { 245 | errs = append(errs, err) 246 | } 247 | 248 | got := fi.Mode() & permissionMask 249 | want := *dir.Mode & permissionMask 250 | 251 | if got != want { 252 | errs = append(errs, fmt.Errorf("file %q mode does not match %o (got %o)", path, want, got)) 253 | } 254 | 255 | return nil 256 | }) 257 | if err != nil { 258 | errs = append(errs, err) 259 | } 260 | } 261 | } 262 | 263 | return errors.Join(errs...) 264 | } 265 | 266 | type PermissionsCondition struct { 267 | Want map[string]Permission 268 | } 269 | 270 | type Permission struct { 271 | Block *os.FileMode 272 | Override []string 273 | } 274 | 275 | func (p PermissionsCondition) Check(i v1.Image) error { 276 | ls, err := i.Layers() 277 | if err != nil { 278 | return err 279 | } 280 | var rc io.ReadCloser 281 | // If there's only one layer, we don't need to extract it. 282 | if len(ls) == 1 { 283 | rc, err = ls[0].Uncompressed() 284 | if err != nil { 285 | return err 286 | } 287 | } else { 288 | rc = mutate.Extract(i) 289 | } 290 | 291 | tmp, err := os.CreateTemp("", "structure-test") 292 | if err != nil { 293 | return err 294 | } 295 | defer os.Remove(tmp.Name()) 296 | 297 | defer rc.Close() 298 | 299 | size, err := io.Copy(tmp, rc) 300 | if err != nil { 301 | return err 302 | } 303 | 304 | fsys, err := tarfs.New(tmp, size) 305 | if err != nil { 306 | return err 307 | } 308 | 309 | var errs []error 310 | 311 | for path, perm := range p.Want { 312 | // https://pkg.go.dev/io/fs#ValidPath 313 | name := strings.TrimPrefix(path, "/") 314 | 315 | err := fs.WalkDir(fsys, name, func(path string, d fs.DirEntry, err error) error { 316 | if err != nil { 317 | return err 318 | } 319 | 320 | // ignore symlinks which will register as 777 321 | if d.Type()&fs.ModeSymlink == fs.ModeSymlink { 322 | return nil 323 | } 324 | 325 | fi, err := d.Info() 326 | if err != nil { 327 | errs = append(errs, err) 328 | } 329 | 330 | got := fi.Mode() & permissionMask 331 | block := *perm.Block & permissionMask 332 | 333 | if got == block && !hasOverride(path, perm.Override) { 334 | errs = append(errs, fmt.Errorf("file %q mode matches blocked permission %o (got %o)", path, block, got)) 335 | } 336 | 337 | return nil 338 | }) 339 | if err != nil { 340 | errs = append(errs, err) 341 | } 342 | } 343 | 344 | return errors.Join(errs...) 345 | } 346 | 347 | func hasOverride(filePath string, overrides []string) bool { 348 | for _, override := range overrides { 349 | // https://pkg.go.dev/io/fs#ValidPath 350 | name := strings.TrimPrefix(override, "/") 351 | if strings.HasPrefix(filePath, name) { 352 | if filePath == name || strings.HasPrefix(filePath, name+"/") { 353 | return true 354 | } 355 | } 356 | } 357 | return false 358 | } 359 | -------------------------------------------------------------------------------- /internal/provider/get_function.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/google/go-containerregistry/pkg/authn" 8 | "github.com/google/go-containerregistry/pkg/name" 9 | "github.com/google/go-containerregistry/pkg/v1/remote" 10 | "github.com/hashicorp/terraform-plugin-framework/attr" 11 | "github.com/hashicorp/terraform-plugin-framework/function" 12 | "github.com/hashicorp/terraform-plugin-framework/provider/schema" 13 | "github.com/hashicorp/terraform-plugin-framework/types/basetypes" 14 | ) 15 | 16 | // Ensure provider defined types fully satisfy framework interfaces. 17 | var _ function.Function = &GetFunction{} 18 | 19 | const getFuncMarkdownDesc = `Fetches an OCI image or index from a registry and returns detailed information about it, including the manifest, configuration, and platform-specific images. 20 | 21 | The function accepts either a tag or digest reference and returns an object with the following properties: 22 | 23 | - ` + "`full_ref`" + ` - The complete reference with digest (e.g., ` + "`cgr.dev/chainguard/wolfi-base@sha256:abcd1234...`" + `) 24 | - ` + "`digest`" + ` - The digest of the image or index (e.g., ` + "`sha256:abcd1234...`" + `) 25 | - ` + "`tag`" + ` - The tag if one was provided in the input (e.g., ` + "`latest`" + `) 26 | - ` + "`manifest`" + ` - The OCI manifest object containing: 27 | - ` + "`schema_version`" + ` - The manifest schema version 28 | - ` + "`media_type`" + ` - The media type of the manifest 29 | - ` + "`config`" + ` - The config descriptor (for images) 30 | - ` + "`layers`" + ` - List of layer descriptors (for images) 31 | - ` + "`annotations`" + ` - Key-value annotations 32 | - ` + "`manifests`" + ` - List of manifest descriptors (for indexes) 33 | - ` + "`subject`" + ` - Subject descriptor (for referrers) 34 | - ` + "`images`" + ` - Map of platform strings to image objects (populated for multi-arch indexes). Each image contains: 35 | - ` + "`digest`" + ` - The platform-specific image digest 36 | - ` + "`image_ref`" + ` - The full reference for the platform-specific image 37 | - ` + "`config`" + ` - The image configuration (populated for single-arch images only). Contains: 38 | - ` + "`env`" + ` - List of environment variables 39 | - ` + "`user`" + ` - The user to run as 40 | - ` + "`working_dir`" + ` - The working directory 41 | - ` + "`entrypoint`" + ` - The entrypoint command 42 | - ` + "`cmd`" + ` - The default command arguments 43 | - ` + "`created_at`" + ` - Timestamp when the image was created 44 | 45 | **Note:** This function makes a network request to the registry to fetch the image metadata. Authentication is handled via the default keychain (Docker config, credential helpers, etc.). 46 | 47 | ## Example: Single-arch image 48 | 49 | ` + "```" + `terraform 50 | output "image_info" { 51 | value = provider::oci::get("cgr.dev/chainguard/wolfi-base:latest") 52 | } 53 | ` + "```" + ` 54 | 55 | ## Example: Multi-arch index 56 | 57 | ` + "```" + `terraform 58 | locals { 59 | multi_arch = provider::oci::get("cgr.dev/chainguard/static:latest") 60 | } 61 | 62 | output "linux_amd64" { 63 | value = local.multi_arch.images["linux/amd64"] 64 | } 65 | ` + "```" + ` 66 | 67 | ## Example: Using the config 68 | 69 | ` + "```" + `terraform 70 | locals { 71 | image = provider::oci::get("cgr.dev/chainguard/wolfi-base:latest") 72 | } 73 | 74 | output "entrypoint" { 75 | value = local.image.config.entrypoint 76 | } 77 | 78 | output "environment" { 79 | value = local.image.config.env 80 | } 81 | ` + "```" 82 | 83 | func NewGetFunction() function.Function { 84 | return &GetFunction{} 85 | } 86 | 87 | // GetFunction defines the function implementation. 88 | type GetFunction struct{} 89 | 90 | // Metadata should return the name of the function, such as parse_xyz. 91 | func (s *GetFunction) Metadata(_ context.Context, _ function.MetadataRequest, resp *function.MetadataResponse) { 92 | resp.Name = "get" 93 | } 94 | 95 | // Definition should return the definition for the function. 96 | func (s *GetFunction) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) { 97 | resp.Definition = function.Definition{ 98 | Summary: "Fetches an OCI image or index from a registry and returns detailed metadata.", 99 | MarkdownDescription: getFuncMarkdownDesc, 100 | Parameters: []function.Parameter{ 101 | function.StringParameter{ 102 | Name: "input", 103 | Description: "The OCI reference string to get.", 104 | }, 105 | }, 106 | Return: function.ObjectReturn{ 107 | AttributeTypes: map[string]attr.Type{ 108 | "full_ref": basetypes.StringType{}, 109 | "digest": basetypes.StringType{}, 110 | "tag": basetypes.StringType{}, 111 | "manifest": basetypes.ObjectType{AttrTypes: manifestAttribute.AttributeTypes}, 112 | "images": basetypes.MapType{ElemType: imageType}, 113 | "config": basetypes.ObjectType{AttrTypes: configAttribute.AttributeTypes}, 114 | }, 115 | }, 116 | } 117 | } 118 | 119 | // Run should return the result of the function logic. It is called when 120 | // Terraform reaches a function call in the configuration. Argument data 121 | // values should be read from the [RunRequest] and the result value set in 122 | // the [RunResponse]. 123 | func (s *GetFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { 124 | var input string 125 | if ferr := req.Arguments.GetArgument(ctx, 0, &input); ferr != nil { 126 | resp.Error = ferr 127 | return 128 | } 129 | 130 | // Parse the input string into its constituent parts. 131 | ref, err := name.ParseReference(input) 132 | if err != nil { 133 | resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse OCI reference: %v", err)) 134 | return 135 | } 136 | 137 | result := struct { 138 | FullRef string `tfsdk:"full_ref"` 139 | Digest string `tfsdk:"digest"` 140 | Tag string `tfsdk:"tag"` 141 | Manifest *Manifest `tfsdk:"manifest"` 142 | Images map[string]Image `tfsdk:"images"` 143 | Config *Config `tfsdk:"config"` 144 | }{} 145 | 146 | if t, ok := ref.(name.Tag); ok { 147 | result.Tag = t.TagStr() 148 | } 149 | 150 | desc, err := remote.Get(ref, 151 | remote.WithAuthFromKeychain(authn.DefaultKeychain), 152 | remote.WithUserAgent("terraform-provider-oci"), 153 | remote.WithContext(ctx)) 154 | if err != nil { 155 | resp.Error = function.NewFuncError(fmt.Sprintf("Failed to get image: %v", err)) 156 | return 157 | } 158 | 159 | result.Digest = desc.Digest.String() 160 | result.FullRef = ref.Context().Digest(desc.Digest.String()).String() 161 | 162 | mf := &Manifest{} 163 | if err := mf.FromDescriptor(desc); err != nil { 164 | resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse manifest: %v", err)) 165 | return 166 | } 167 | result.Manifest = mf 168 | 169 | if desc.MediaType.IsIndex() { 170 | idx, err := desc.ImageIndex() 171 | if err != nil { 172 | resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse index: %v", err)) 173 | return 174 | } 175 | imf, err := idx.IndexManifest() 176 | if err != nil { 177 | resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse index manifest: %v", err)) 178 | return 179 | } 180 | result.Images = make(map[string]Image, len(imf.Manifests)) 181 | for _, m := range imf.Manifests { 182 | if m.Platform == nil { 183 | continue 184 | } 185 | result.Images[m.Platform.String()] = Image{ 186 | Digest: m.Digest.String(), 187 | ImageRef: ref.Context().Digest(m.Digest.String()).String(), 188 | } 189 | } 190 | } else if desc.MediaType.IsImage() { 191 | img, err := desc.Image() 192 | if err != nil { 193 | resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse image: %v", err)) 194 | return 195 | } 196 | cf, err := img.ConfigFile() 197 | if err != nil { 198 | resp.Error = function.NewFuncError(fmt.Sprintf("Failed to parse config: %v", err)) 199 | return 200 | } 201 | cfg := &Config{} 202 | cfg.FromConfigFile(cf) 203 | result.Config = cfg 204 | } 205 | 206 | resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) 207 | } 208 | 209 | var imageType = basetypes.ObjectType{ 210 | AttrTypes: map[string]attr.Type{ 211 | "digest": basetypes.StringType{}, 212 | "image_ref": basetypes.StringType{}, 213 | }, 214 | } 215 | 216 | var manifestAttribute = schema.ObjectAttribute{ 217 | MarkdownDescription: "Manifest of the image or index.", 218 | AttributeTypes: map[string]attr.Type{ 219 | "schema_version": basetypes.NumberType{}, 220 | "media_type": basetypes.StringType{}, 221 | "config": descriptorType, 222 | "layers": basetypes.ListType{ 223 | ElemType: descriptorType, 224 | }, 225 | "annotations": basetypes.MapType{ 226 | ElemType: basetypes.StringType{}, 227 | }, 228 | "manifests": basetypes.ListType{ 229 | ElemType: descriptorType, 230 | }, 231 | "subject": descriptorType, 232 | }, 233 | } 234 | 235 | var descriptorType = basetypes.ObjectType{ 236 | AttrTypes: map[string]attr.Type{ 237 | "media_type": basetypes.StringType{}, 238 | "size": basetypes.NumberType{}, 239 | "digest": basetypes.StringType{}, 240 | "platform": basetypes.ObjectType{ 241 | AttrTypes: map[string]attr.Type{ 242 | "architecture": basetypes.StringType{}, 243 | "os": basetypes.StringType{}, 244 | "variant": basetypes.StringType{}, 245 | "os_version": basetypes.StringType{}, 246 | }, 247 | }, 248 | }, 249 | } 250 | 251 | var configAttribute = schema.ObjectAttribute{ 252 | MarkdownDescription: "Config of an image.", 253 | AttributeTypes: map[string]attr.Type{ 254 | "env": basetypes.ListType{ 255 | ElemType: basetypes.StringType{}, 256 | }, 257 | "user": basetypes.StringType{}, 258 | "working_dir": basetypes.StringType{}, 259 | "entrypoint": basetypes.ListType{ 260 | ElemType: basetypes.StringType{}, 261 | }, 262 | "cmd": basetypes.ListType{ 263 | ElemType: basetypes.StringType{}, 264 | }, 265 | "created_at": basetypes.StringType{}, 266 | }, 267 | } 268 | -------------------------------------------------------------------------------- /internal/provider/exec_test_data_source.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "net" 10 | "os" 11 | "os/exec" 12 | "sync" 13 | "time" 14 | 15 | "github.com/chainguard-dev/terraform-provider-oci/pkg/validators" 16 | "github.com/google/go-containerregistry/pkg/name" 17 | "github.com/google/go-containerregistry/pkg/v1/remote" 18 | "github.com/hashicorp/terraform-plugin-framework/attr" 19 | "github.com/hashicorp/terraform-plugin-framework/datasource" 20 | "github.com/hashicorp/terraform-plugin-framework/datasource/schema" 21 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 22 | "github.com/hashicorp/terraform-plugin-framework/types" 23 | "github.com/hashicorp/terraform-plugin-framework/types/basetypes" 24 | "github.com/hashicorp/terraform-plugin-log/tflog" 25 | ) 26 | 27 | // Ensure provider defined types fully satisfy framework interfaces. 28 | var _ datasource.DataSource = &ExecTestDataSource{} 29 | 30 | func NewExecTestDataSource() datasource.DataSource { 31 | return &ExecTestDataSource{} 32 | } 33 | 34 | // ExecTestDataSource defines the data source implementation. 35 | type ExecTestDataSource struct { 36 | popts ProviderOpts 37 | } 38 | 39 | // ExecTestDataSourceModel describes the data source data model. 40 | type ExecTestDataSourceModel struct { 41 | Digest types.String `tfsdk:"digest"` 42 | Script types.String `tfsdk:"script"` 43 | TimeoutSeconds types.Int64 `tfsdk:"timeout_seconds"` 44 | WorkingDir types.String `tfsdk:"working_dir"` 45 | Env []EnvVar `tfsdk:"env"` 46 | 47 | ExitCode types.Int64 `tfsdk:"exit_code"` 48 | Output types.String `tfsdk:"output"` 49 | Id types.String `tfsdk:"id"` 50 | TestedRef types.String `tfsdk:"tested_ref"` 51 | } 52 | 53 | type EnvVar struct { 54 | Name string `tfsdk:"name"` 55 | Value string `tfsdk:"value"` 56 | } 57 | 58 | func (d *ExecTestDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { 59 | resp.TypeName = req.ProviderTypeName + "_exec_test" 60 | } 61 | 62 | func (d *ExecTestDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { 63 | resp.Schema = schema.Schema{ 64 | MarkdownDescription: "Exec test data source", 65 | 66 | Attributes: map[string]schema.Attribute{ 67 | "digest": schema.StringAttribute{ 68 | MarkdownDescription: "Image digest to test", 69 | Optional: false, 70 | Required: true, 71 | Validators: []validator.String{validators.DigestValidator{}}, 72 | }, 73 | "script": schema.StringAttribute{ 74 | MarkdownDescription: "Script to run against the image", 75 | Required: true, 76 | }, 77 | "timeout_seconds": schema.Int64Attribute{ 78 | MarkdownDescription: "Timeout for the test in seconds (default is 5 minutes)", 79 | Optional: true, 80 | Validators: []validator.Int64{positiveIntValidator{}}, 81 | }, 82 | "working_dir": schema.StringAttribute{ 83 | MarkdownDescription: "Working directory for the test", 84 | Optional: true, 85 | }, 86 | "env": schema.ListAttribute{ 87 | ElementType: basetypes.ObjectType{ 88 | AttrTypes: map[string]attr.Type{ 89 | "name": basetypes.StringType{}, 90 | "value": basetypes.StringType{}, 91 | }, 92 | }, 93 | MarkdownDescription: "Environment variables for the test", 94 | Optional: true, 95 | }, 96 | 97 | // TODO: platform? 98 | 99 | "exit_code": schema.Int64Attribute{ 100 | MarkdownDescription: "Exit code of the test", 101 | Computed: true, 102 | }, 103 | "output": schema.StringAttribute{ 104 | MarkdownDescription: "Output of the test", 105 | Computed: true, 106 | DeprecationMessage: "Not populated", 107 | }, 108 | "id": schema.StringAttribute{ 109 | MarkdownDescription: "Fully qualified image digest of the image.", 110 | Computed: true, 111 | }, 112 | "tested_ref": schema.StringAttribute{ 113 | MarkdownDescription: "Tested image ref by digest.", 114 | Computed: true, 115 | }, 116 | }, 117 | } 118 | } 119 | 120 | func (d *ExecTestDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { 121 | // Prevent panic if the provider has not been configured. 122 | if req.ProviderData == nil { 123 | return 124 | } 125 | 126 | popts, ok := req.ProviderData.(*ProviderOpts) 127 | if !ok || popts == nil { 128 | resp.Diagnostics.AddError("Client Error", "invalid provider data") 129 | return 130 | } 131 | d.popts = *popts 132 | } 133 | 134 | func (d *ExecTestDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { 135 | var data ExecTestDataSourceModel 136 | resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) 137 | if resp.Diagnostics.HasError() { 138 | return 139 | } 140 | 141 | if d.popts.skipExecTests { 142 | resp.Diagnostics.AddWarning("Skipping exec tests", "Skipping exec tests as per provider configuration") 143 | return 144 | } 145 | if os.Getenv("OCI_SKIP_EXEC_TESTS") == "true" { 146 | resp.Diagnostics.AddWarning("Skipping exec tests", "Skipping exec tests as per environment variable OCI_SKIP_EXEC_TESTS") 147 | return 148 | } 149 | 150 | ref, err := name.NewDigest(data.Digest.ValueString()) 151 | if err != nil { 152 | resp.Diagnostics.AddError("Invalid ref", fmt.Sprintf("Unable to parse ref %s, got error: %s", data.Digest.ValueString(), err)) 153 | return 154 | } 155 | 156 | // Check we can get the image before running the test. 157 | if _, err := remote.Get(ref, d.popts.withContext(ctx)...); err != nil { 158 | resp.Diagnostics.AddError("Unable to fetch image", fmt.Sprintf("Unable to fetch image for ref %s, got error: %s", data.Digest.ValueString(), err)) 159 | return 160 | } 161 | 162 | timeout := data.TimeoutSeconds.ValueInt64() 163 | if timeout == 0 { 164 | if d.popts.defaultExecTimeoutSeconds != 0 { 165 | timeout = d.popts.defaultExecTimeoutSeconds 166 | } else { 167 | timeout = 300 168 | } 169 | } 170 | ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) 171 | defer cancel() 172 | 173 | // Prepopulate some environment variables: 174 | // - any environment variables defined on the host 175 | // - IMAGE_NAME: the fully qualified image name 176 | // - IMAGE_REPOSITORY: the repository part of the image name 177 | // - IMAGE_REGISTRY: the registry part of the image name 178 | // - FREE_PORT: a free port on the host 179 | // - any environment variables defined in the data source 180 | repo := ref.Context().RepositoryStr() 181 | registry := ref.Context().RegistryStr() 182 | env := append(os.Environ(), 183 | "IMAGE_NAME="+data.Digest.ValueString(), 184 | "IMAGE_REPOSITORY="+repo, 185 | "IMAGE_REGISTRY="+registry, 186 | ) 187 | fp, err := freePort() 188 | if err != nil { 189 | resp.Diagnostics.AddError("Unable to find free port", fmt.Sprintf("Unable to find free port for ref %s, got error: %s", data.Digest.ValueString(), err)) 190 | return 191 | } 192 | defer discardPort(fp) 193 | env = append(env, fmt.Sprintf("FREE_PORT=%d", fp)) 194 | for _, e := range data.Env { 195 | env = append(env, fmt.Sprintf("%s=%s", e.Name, e.Value)) 196 | } 197 | 198 | cmd := exec.CommandContext(ctx, "sh", "-c", data.Script.ValueString()) 199 | cmd.Env = env 200 | cmd.Dir = data.WorkingDir.ValueString() 201 | 202 | fullout, err := cmd.CombinedOutput() 203 | data.Output = types.StringValue("") // always empty. 204 | 205 | data.TestedRef = data.Digest 206 | data.Id = types.StringValue(md5str(data.Script.ValueString()) + data.Digest.ValueString()) 207 | data.ExitCode = types.Int64Value(int64(cmd.ProcessState.ExitCode())) 208 | 209 | if errors.Is(ctx.Err(), context.DeadlineExceeded) { 210 | resp.Diagnostics.AddError("Test timed out", fmt.Sprintf("Test for ref %s timed out after %d seconds:\n%s", data.Digest.ValueString(), timeout, string(fullout))) 211 | return 212 | } else if err != nil { 213 | resp.Diagnostics.AddError("Test failed", fmt.Sprintf("Test failed for ref %s, got error: %s\n%s", data.Digest.ValueString(), err, string(fullout))) 214 | return 215 | } 216 | 217 | // Write logs using the tflog package 218 | // Documentation: https://terraform.io/plugin/log 219 | tflog.Trace(ctx, "read a data source") 220 | 221 | // Save data into Terraform state 222 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 223 | } 224 | 225 | func md5str(s string) string { 226 | h := md5.New() 227 | h.Write([]byte(s)) 228 | return hex.EncodeToString(h.Sum(nil)) 229 | } 230 | 231 | type positiveIntValidator struct{} 232 | 233 | func (positiveIntValidator) MarkdownDescription(context.Context) string { return "positive integer" } 234 | func (positiveIntValidator) Description(context.Context) string { return "positive integer" } 235 | func (positiveIntValidator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { 236 | if i := req.ConfigValue.ValueInt64(); i < 0 { 237 | resp.Diagnostics.AddAttributeError(req.Path, fmt.Sprintf("value %d must be a positive integer", i), "") 238 | } 239 | } 240 | 241 | var ( 242 | mu sync.Mutex 243 | freePorts = map[int]bool{} 244 | ) 245 | 246 | func freePort() (int, error) { 247 | mu.Lock() 248 | defer mu.Unlock() 249 | 250 | for { 251 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0") 252 | if err != nil { 253 | return 0, err 254 | } 255 | 256 | l, err := net.ListenTCP("tcp", addr) 257 | if err != nil { 258 | return 0, err 259 | } 260 | defer l.Close() 261 | ta, ok := l.Addr().(*net.TCPAddr) 262 | if !ok { 263 | return 0, fmt.Errorf("failed to get port") 264 | } 265 | if freePorts[ta.Port] { 266 | tflog.Debug(context.Background(), "port already in use, trying again", map[string]interface{}{"port": ta.Port}) 267 | continue 268 | } 269 | return ta.Port, nil 270 | } 271 | } 272 | 273 | func discardPort(port int) { 274 | mu.Lock() 275 | defer mu.Unlock() 276 | delete(freePorts, port) 277 | } 278 | -------------------------------------------------------------------------------- /internal/provider/structure_test_data_source.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/chainguard-dev/terraform-provider-oci/pkg/structure" 10 | "github.com/chainguard-dev/terraform-provider-oci/pkg/validators" 11 | "github.com/google/go-containerregistry/pkg/name" 12 | v1 "github.com/google/go-containerregistry/pkg/v1" 13 | "github.com/google/go-containerregistry/pkg/v1/remote" 14 | "github.com/hashicorp/terraform-plugin-framework/attr" 15 | "github.com/hashicorp/terraform-plugin-framework/datasource" 16 | "github.com/hashicorp/terraform-plugin-framework/datasource/schema" 17 | "github.com/hashicorp/terraform-plugin-framework/schema/validator" 18 | "github.com/hashicorp/terraform-plugin-framework/types" 19 | "github.com/hashicorp/terraform-plugin-framework/types/basetypes" 20 | "github.com/hashicorp/terraform-plugin-log/tflog" 21 | ) 22 | 23 | // Ensure provider defined types fully satisfy framework interfaces. 24 | var _ datasource.DataSource = &StructureTestDataSource{} 25 | 26 | func NewStructureTestDataSource() datasource.DataSource { 27 | return &StructureTestDataSource{} 28 | } 29 | 30 | // StructureTestDataSource defines the data source implementation. 31 | type StructureTestDataSource struct { 32 | popts ProviderOpts 33 | } 34 | 35 | // StructureTestDataSourceModel describes the data source data model. 36 | type StructureTestDataSourceModel struct { 37 | Digest types.String `tfsdk:"digest"` 38 | Conditions []struct { 39 | Dirs []struct { 40 | FilesOnly types.Bool `tfsdk:"files_only"` 41 | Mode types.String `tfsdk:"mode"` // Expected to be a string representation of os.FileMode 42 | Path types.String `tfsdk:"path"` 43 | Recursive types.Bool `tfsdk:"recursive"` 44 | } `tfsdk:"dirs"` 45 | Env []struct { 46 | Key types.String `tfsdk:"key"` 47 | Value types.String `tfsdk:"value"` 48 | } `tfsdk:"env"` 49 | Files []struct { 50 | Mode types.String `tfsdk:"mode"` // Expected to be a string representation of os.FileMode 51 | Optional types.Bool `tfsdk:"optional"` 52 | Path types.String `tfsdk:"path"` 53 | Regex types.String `tfsdk:"regex"` 54 | } `tfsdk:"files"` 55 | Permissions []struct { 56 | Block types.String `tfsdk:"block"` // Expected to be a string representation of os.FileMode 57 | Override types.List `tfsdk:"override"` 58 | Path types.String `tfsdk:"path"` 59 | } `tfsdk:"permissions"` 60 | } `tfsdk:"conditions"` 61 | 62 | Id types.String `tfsdk:"id"` 63 | TestedRef types.String `tfsdk:"tested_ref"` 64 | } 65 | 66 | func (d *StructureTestDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { 67 | resp.TypeName = req.ProviderTypeName + "_structure_test" 68 | } 69 | 70 | func (d *StructureTestDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { 71 | resp.Schema = schema.Schema{ 72 | MarkdownDescription: "Structure test data source", 73 | 74 | Attributes: map[string]schema.Attribute{ 75 | "digest": schema.StringAttribute{ 76 | MarkdownDescription: "Image digest to test", 77 | Optional: false, 78 | Required: true, 79 | Validators: []validator.String{validators.DigestValidator{}}, 80 | }, 81 | "conditions": schema.ListAttribute{ 82 | MarkdownDescription: "List of conditions to test", 83 | Required: true, 84 | ElementType: basetypes.ObjectType{ 85 | AttrTypes: map[string]attr.Type{ 86 | "dirs": basetypes.ListType{ 87 | ElemType: basetypes.ObjectType{ 88 | AttrTypes: map[string]attr.Type{ 89 | "files_only": basetypes.BoolType{}, 90 | "mode": basetypes.StringType{}, // Expected to be a string representation of os.FileMode 91 | "path": basetypes.StringType{}, 92 | "recursive": basetypes.BoolType{}, 93 | }, 94 | }, 95 | }, 96 | "env": basetypes.ListType{ 97 | ElemType: basetypes.ObjectType{ 98 | AttrTypes: map[string]attr.Type{ 99 | "key": basetypes.StringType{}, 100 | "value": basetypes.StringType{}, 101 | }, 102 | }, 103 | }, 104 | "files": basetypes.ListType{ 105 | ElemType: basetypes.ObjectType{ 106 | AttrTypes: map[string]attr.Type{ 107 | "mode": basetypes.StringType{}, // Expected to be a string representation of os.FileMode 108 | "optional": basetypes.BoolType{}, 109 | "path": basetypes.StringType{}, 110 | "regex": basetypes.StringType{}, 111 | }, 112 | }, 113 | }, 114 | "permissions": basetypes.ListType{ 115 | ElemType: basetypes.ObjectType{ 116 | AttrTypes: map[string]attr.Type{ 117 | "block": basetypes.StringType{}, // Expected to be a string representation of os.FileMode 118 | "override": basetypes.ListType{ 119 | ElemType: basetypes.StringType{}, 120 | }, 121 | "path": basetypes.StringType{}, 122 | }, 123 | }, 124 | }, 125 | }, 126 | }, 127 | }, 128 | 129 | // TODO: platform? 130 | 131 | "id": schema.StringAttribute{ 132 | MarkdownDescription: "Fully qualified image digest of the image.", 133 | Computed: true, 134 | }, 135 | "tested_ref": schema.StringAttribute{ 136 | MarkdownDescription: "Tested image ref by digest.", 137 | Computed: true, 138 | }, 139 | }, 140 | } 141 | } 142 | 143 | func (d *StructureTestDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { 144 | // Prevent panic if the provider has not been configured. 145 | if req.ProviderData == nil { 146 | return 147 | } 148 | 149 | popts, ok := req.ProviderData.(*ProviderOpts) 150 | if !ok || popts == nil { 151 | resp.Diagnostics.AddError("Client Error", "invalid provider data") 152 | return 153 | } 154 | d.popts = *popts 155 | } 156 | 157 | func parseFileMode(modeStr string) (*os.FileMode, error) { 158 | if modeStr == "" { 159 | return nil, nil 160 | } 161 | mode, err := strconv.ParseUint(modeStr, 8, 32) 162 | if err != nil { 163 | return nil, fmt.Errorf("parsing file mode %q: %w", modeStr, err) 164 | } 165 | 166 | m := os.FileMode(uint32(mode) & 0o777) 167 | 168 | if mode&0o4000 != 0 { 169 | m |= os.ModeSetuid 170 | } 171 | if mode&0o2000 != 0 { 172 | m |= os.ModeSetgid 173 | } 174 | if mode&0o1000 != 0 { 175 | m |= os.ModeSticky 176 | } 177 | 178 | return &m, nil 179 | } 180 | 181 | func (d *StructureTestDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { 182 | var data StructureTestDataSourceModel 183 | resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) 184 | if resp.Diagnostics.HasError() { 185 | return 186 | } 187 | 188 | ref, err := name.NewDigest(data.Digest.ValueString()) 189 | if err != nil { 190 | resp.Diagnostics.AddError("Invalid ref", fmt.Sprintf("Unable to parse ref %s, got error: %s", data.Digest.ValueString(), err)) 191 | return 192 | } 193 | 194 | desc, err := remote.Get(ref, d.popts.withContext(ctx)...) 195 | if err != nil { 196 | resp.Diagnostics.AddError("Unable to fetch image", fmt.Sprintf("Unable to fetch image for ref %s, got error: %s", data.Digest.ValueString(), err)) 197 | return 198 | } 199 | 200 | var conds structure.Conditions 201 | for _, c := range data.Conditions { 202 | 203 | for _, d := range c.Dirs { 204 | if d.FilesOnly.ValueBool() && !d.Recursive.ValueBool() { 205 | resp.Diagnostics.AddError("Invalid input", "Can only use files_only with recursive") 206 | return 207 | } 208 | 209 | m, err := parseFileMode(d.Mode.ValueString()) 210 | if err != nil { 211 | resp.Diagnostics.AddError("Invalid file mode", fmt.Sprintf("Unable to parse file mode %q, got error: %s", d.Mode.ValueString(), err)) 212 | return 213 | } 214 | conds = append(conds, structure.DirsCondition{Want: map[string]structure.Dir{ 215 | d.Path.ValueString(): { 216 | FilesOnly: d.FilesOnly.ValueBool(), 217 | Mode: m, 218 | Recursive: d.Recursive.ValueBool(), 219 | }, 220 | }}) 221 | } 222 | 223 | for _, e := range c.Env { 224 | conds = append(conds, structure.EnvCondition{Want: map[string]string{ 225 | e.Key.ValueString(): e.Value.ValueString(), 226 | }}) 227 | } 228 | 229 | for _, f := range c.Files { 230 | m, err := parseFileMode(f.Mode.ValueString()) 231 | if err != nil { 232 | resp.Diagnostics.AddError("Invalid file mode", fmt.Sprintf("Unable to parse file mode %q, got error: %s", f.Mode.ValueString(), err)) 233 | return 234 | } 235 | conds = append(conds, structure.FilesCondition{Want: map[string]structure.File{ 236 | f.Path.ValueString(): { 237 | Mode: m, 238 | Optional: f.Optional.ValueBool(), 239 | Regex: f.Regex.ValueString(), 240 | }, 241 | }}) 242 | } 243 | 244 | for _, p := range c.Permissions { 245 | m, err := parseFileMode(p.Block.ValueString()) 246 | if err != nil { 247 | resp.Diagnostics.AddError("Invalid file mode", fmt.Sprintf("Unable to parse file mode %q, got error: %s", p.Block.ValueString(), err)) 248 | return 249 | } 250 | var overrideStrings []string 251 | diags := p.Override.ElementsAs(ctx, &overrideStrings, false) 252 | if diags.HasError() { 253 | resp.Diagnostics.Append(diags...) 254 | return 255 | } 256 | 257 | var path string 258 | if p.Path.IsNull() || p.Path.IsUnknown() { 259 | path = "." 260 | } else { 261 | path = p.Path.ValueString() 262 | } 263 | conds = append(conds, structure.PermissionsCondition{Want: map[string]structure.Permission{ 264 | path: { 265 | Block: m, 266 | Override: overrideStrings, 267 | }, 268 | }}) 269 | } 270 | } 271 | 272 | var img v1.Image 273 | switch { 274 | case desc.MediaType.IsImage(): 275 | img, err = desc.Image() 276 | if err != nil { 277 | resp.Diagnostics.AddError("Unable to fetch image", fmt.Sprintf("Unable to fetch image for ref %s, got error: %s", data.Digest.ValueString(), err)) 278 | return 279 | } 280 | case desc.MediaType.IsIndex(): 281 | index, err := desc.ImageIndex() 282 | if err != nil { 283 | resp.Diagnostics.AddError("Unable to read image index", fmt.Sprintf("Unable to read image index for ref %s, got error: %s", data.Digest.ValueString(), err)) 284 | return 285 | } 286 | 287 | indexManifest, err := index.IndexManifest() 288 | if err != nil { 289 | resp.Diagnostics.AddError("Unable to read image index manifest", fmt.Sprintf("Unable to read image index manifest for ref %s, got error: %s", data.Digest.ValueString(), err)) 290 | return 291 | } 292 | 293 | if len(indexManifest.Manifests) == 0 { 294 | resp.Diagnostics.AddError("Unable to read image from index manifest", fmt.Sprintf("Unable to read image from index manifest for ref %s: index is empty", data.Digest.ValueString())) 295 | } 296 | 297 | firstDescriptor := indexManifest.Manifests[0] 298 | img, err = index.Image(firstDescriptor.Digest) 299 | if err != nil { 300 | resp.Diagnostics.AddError("Unable to load image", fmt.Sprintf("Unable to load image for ref %s, got error: %s", data.Digest.ValueString(), err)) 301 | return 302 | } 303 | } 304 | 305 | if err := conds.Check(img); err != nil { 306 | data.TestedRef = basetypes.NewStringValue("") 307 | data.Id = basetypes.NewStringValue("") 308 | resp.Diagnostics.AddError("Image does not match rules", fmt.Sprintf("Image %s does not match rules:\n%s", data.Digest.ValueString(), err)) 309 | return 310 | } 311 | 312 | data.TestedRef = data.Digest 313 | data.Id = data.Digest 314 | 315 | // Write logs using the tflog package 316 | // Documentation: https://terraform.io/plugin/log 317 | tflog.Trace(ctx, "read a data source") 318 | 319 | // Save data into Terraform state 320 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 321 | } 322 | -------------------------------------------------------------------------------- /internal/provider/structure_test_data_source_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "os" 9 | "regexp" 10 | "testing" 11 | 12 | ocitesting "github.com/chainguard-dev/terraform-provider-oci/testing" 13 | v1 "github.com/google/go-containerregistry/pkg/v1" 14 | "github.com/google/go-containerregistry/pkg/v1/empty" 15 | "github.com/google/go-containerregistry/pkg/v1/mutate" 16 | "github.com/google/go-containerregistry/pkg/v1/remote" 17 | "github.com/google/go-containerregistry/pkg/v1/tarball" 18 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 19 | ) 20 | 21 | func TestAccStructureTestDataSource(t *testing.T) { 22 | repo, cleanup := ocitesting.SetupRepository(t, "test") 23 | defer cleanup() 24 | 25 | // Push an image to the local registry. 26 | var buf bytes.Buffer 27 | tw := tar.NewWriter(&buf) 28 | // File tests 29 | _ = tw.WriteHeader(&tar.Header{ 30 | Name: "foo", 31 | Mode: 0o644, 32 | Size: 3, 33 | }) 34 | _, _ = tw.Write([]byte("bar")) 35 | _ = tw.WriteHeader(&tar.Header{ 36 | Name: "path/to/bar", 37 | Mode: 0o666, 38 | Size: 19, 39 | }) 40 | _, _ = tw.Write([]byte("world-writable file")) 41 | _ = tw.WriteHeader(&tar.Header{ 42 | Name: "path/to/barexec", 43 | Mode: 0o777, 44 | Size: 25, 45 | }) 46 | _, _ = tw.Write([]byte("world-writable executable")) 47 | _ = tw.WriteHeader(&tar.Header{ 48 | Name: "path/to/baz", 49 | Mode: 0o755, 50 | Size: 6, 51 | }) 52 | _, _ = tw.Write([]byte("blah!!")) 53 | _ = tw.WriteHeader(&tar.Header{ 54 | Name: "path/to/stickybit", 55 | Mode: int64(0o755 | 0o1000), 56 | Size: 20, 57 | }) 58 | _, _ = tw.Write([]byte("file with sticky bit")) 59 | _ = tw.WriteHeader(&tar.Header{ 60 | Name: "path/to/setgid", 61 | Mode: int64(0o755 | 0o2000), 62 | Size: 16, 63 | }) 64 | _, _ = tw.Write([]byte("file with setgid")) 65 | _ = tw.WriteHeader(&tar.Header{ 66 | Name: "path/to/setuid", 67 | Mode: int64(0o755 | 0o4000), 68 | Size: 16, 69 | }) 70 | _, _ = tw.Write([]byte("file with setuid")) 71 | _ = tw.WriteHeader(&tar.Header{ 72 | Name: "path/to/setuidgid", 73 | Mode: int64(0o755 | 0o4000 | 0o2000), 74 | Size: 27, 75 | }) 76 | _, _ = tw.Write([]byte("file with setuid and setgid")) 77 | _ = tw.WriteHeader(&tar.Header{ 78 | Name: "path/to/setuidgidstickybit", 79 | Mode: int64(0o755 | 0o4000 | 0o2000 | 0o1000), 80 | Size: 40, 81 | }) 82 | _, _ = tw.Write([]byte("file with setuid, setgid, and sticky bit")) 83 | // Directory tests 84 | _ = tw.WriteHeader(&tar.Header{ 85 | Name: "new_path", 86 | Typeflag: tar.TypeDir, 87 | Mode: 0o755, 88 | }) 89 | _ = tw.WriteHeader(&tar.Header{ 90 | Name: "new_path_permissive", 91 | Typeflag: tar.TypeDir, 92 | Mode: 0o777, 93 | }) 94 | _ = tw.WriteHeader(&tar.Header{ 95 | Name: "new_path_permissive/foo", 96 | Typeflag: tar.TypeDir, 97 | Mode: 0o777, 98 | }) 99 | _ = tw.WriteHeader(&tar.Header{ 100 | Name: "new_path_permissive/foo/bar", 101 | Mode: 0o777, 102 | Size: 6, 103 | }) 104 | _, _ = tw.Write([]byte("blah!!")) 105 | _ = tw.WriteHeader(&tar.Header{ 106 | Name: "new_path_permissive/bar", 107 | Typeflag: tar.TypeDir, 108 | Mode: 0o777, 109 | }) 110 | _ = tw.WriteHeader(&tar.Header{ 111 | Name: "new_path_permissive/bar/baz", 112 | Mode: 0o777, 113 | Size: 6, 114 | }) 115 | _, _ = tw.Write([]byte("blah!!")) 116 | _ = tw.WriteHeader(&tar.Header{ 117 | Name: "files_only/foo", 118 | Typeflag: tar.TypeDir, 119 | Mode: 0o777, 120 | }) 121 | _ = tw.WriteHeader(&tar.Header{ 122 | Name: "files_only/foo/bar", 123 | Typeflag: tar.TypeDir, 124 | Mode: 0o777, 125 | }) 126 | _ = tw.WriteHeader(&tar.Header{ 127 | Name: "files_only/foo/baz", 128 | Mode: 0o644, 129 | Size: 6, 130 | }) 131 | _, _ = tw.Write([]byte("blah!!")) 132 | 133 | // Test that /lib -> /usr/lib works. 134 | _ = tw.WriteHeader(&tar.Header{ 135 | Name: "symlink", 136 | Typeflag: tar.TypeSymlink, 137 | Mode: 0o755, 138 | Linkname: "path", 139 | }) 140 | 141 | tw.Close() 142 | 143 | l, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { 144 | return io.NopCloser(bytes.NewBuffer(buf.Bytes())), nil 145 | }) 146 | if err != nil { 147 | t.Fatalf("failed to create layer: %v", err) 148 | } 149 | 150 | img, err := mutate.AppendLayers(empty.Image, l) 151 | if err != nil { 152 | t.Fatalf("failed to append layer: %v", err) 153 | } 154 | img, err = mutate.Config(img, v1.Config{ 155 | Env: []string{"FOO=bar", "BAR=baz"}, 156 | }) 157 | if err != nil { 158 | t.Fatalf("failed to mutate image: %v", err) 159 | } 160 | idx := mutate.AppendManifests(empty.Index, mutate.IndexAddendum{Add: img}) 161 | d, err := idx.Digest() 162 | if err != nil { 163 | t.Fatalf("failed to get index digest: %v", err) 164 | } 165 | ref := repo.Digest(d.String()) 166 | if err := remote.WriteIndex(ref, idx); err != nil { 167 | t.Fatalf("failed to write index: %v", err) 168 | } 169 | 170 | resource.Test(t, resource.TestCase{ 171 | PreCheck: func() { testAccPreCheck(t) }, 172 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 173 | Steps: []resource.TestStep{{ 174 | Config: fmt.Sprintf(`data "oci_structure_test" "test" { 175 | digest = %q 176 | 177 | conditions { 178 | env { 179 | key = "FOO" 180 | value = "bar" 181 | } 182 | env { 183 | key = "BAR" 184 | value = "baz" 185 | } 186 | files { 187 | path = "/foo" 188 | regex = "bar" 189 | mode = "0644" 190 | } 191 | files { 192 | path = "/foo" # Just test existence. 193 | } 194 | files { 195 | path = "/foo" 196 | regex = "b[ar]+" # Test regexp. 197 | } 198 | files { 199 | path = "/path/to/baz" 200 | regex = "blah!!" 201 | mode = "0755" 202 | } 203 | files { 204 | path = "/path/to/bar" 205 | regex = "world-writable file" 206 | mode = "0666" 207 | } 208 | files { 209 | path = "/path/to/barexec" 210 | regex = "world-writable executable" 211 | mode = "0777" 212 | } 213 | files { 214 | path = "/path/to/stickybit" 215 | regex = "file with sticky bit" 216 | mode = "1755" 217 | } 218 | files { 219 | path = "/path/to/setgid" 220 | regex = "file with setgid" 221 | mode = "2755" 222 | } 223 | files { 224 | path = "/path/to/setuid" 225 | regex = "file with setuid" 226 | mode = "4755" 227 | } 228 | files { 229 | path = "/path/to/setuidgid" 230 | regex = "file with setuid and setgid" 231 | mode = "6755" 232 | } 233 | files { 234 | path = "/path/to/setuidgidstickybit" 235 | regex = "file with setuid, setgid, and sticky bit" 236 | mode = "7755" 237 | } 238 | files { 239 | path = "/path/to/mayormaynotexist" 240 | regex = "file that may exist for one image and not another" 241 | mode = "0644" 242 | optional = true 243 | } 244 | dirs { 245 | path = "/new_path" 246 | mode = "0755" 247 | } 248 | dirs { 249 | path = "/new_path_permissive" 250 | mode = "0777" 251 | } 252 | dirs { 253 | path = "/new_path_permissive" 254 | mode = "0777" 255 | recursive = true 256 | } 257 | dirs { 258 | path = "/files_only/foo" 259 | mode = "0644" 260 | recursive = true 261 | files_only = true 262 | } 263 | files { 264 | path = "/symlink/to/baz" 265 | regex = "blah!!" 266 | } 267 | permissions { 268 | block = "0777" 269 | override = ["/new_path_permissive", "/path/to/barexec"] 270 | } 271 | permissions { 272 | block = "0666" 273 | } 274 | } 275 | }`, ref), 276 | Check: resource.ComposeTestCheckFunc( 277 | resource.TestCheckResourceAttr("data.oci_structure_test.test", "digest", ref.String()), 278 | resource.TestCheckResourceAttr("data.oci_structure_test.test", "id", ref.String()), 279 | ), 280 | }}, 281 | }) 282 | 283 | resource.Test(t, resource.TestCase{ 284 | PreCheck: func() { testAccPreCheck(t) }, 285 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 286 | Steps: []resource.TestStep{{ 287 | Config: fmt.Sprintf(`data "oci_structure_test" "test" { 288 | digest = %q 289 | 290 | conditions { 291 | env { 292 | key = "NOT_SET" 293 | value = "uh oh" 294 | } 295 | files { 296 | path = "/path/not/set" 297 | } 298 | } 299 | }`, ref), 300 | ExpectError: regexp.MustCompile(`env "NOT_SET" does not match "uh oh" \(got ""\)\n.*file "/path/not/set" not found`), 301 | }}, 302 | }) 303 | } 304 | 305 | func TestInvalidPathEnv(t *testing.T) { 306 | repo, cleanup := ocitesting.SetupRepository(t, "test") 307 | defer cleanup() 308 | 309 | // Push an image to the local registry. 310 | var buf bytes.Buffer 311 | tw := tar.NewWriter(&buf) 312 | _ = tw.WriteHeader(&tar.Header{ 313 | Name: "foo", 314 | Mode: 0o644, 315 | Size: 3, 316 | }) 317 | _, _ = tw.Write([]byte("bar")) 318 | _ = tw.WriteHeader(&tar.Header{ 319 | Name: "path/to/baz", 320 | Mode: 0o755, 321 | Size: 6, 322 | }) 323 | _, _ = tw.Write([]byte("blah!!")) 324 | tw.Close() 325 | 326 | l, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { 327 | return io.NopCloser(bytes.NewBuffer(buf.Bytes())), nil 328 | }) 329 | if err != nil { 330 | t.Fatalf("failed to create layer: %v", err) 331 | } 332 | 333 | img, err := mutate.AppendLayers(empty.Image, l) 334 | if err != nil { 335 | t.Fatalf("failed to append layer: %v", err) 336 | } 337 | img, err = mutate.Config(img, v1.Config{ 338 | Env: []string{ 339 | "PATH=$PATH", 340 | "LUA_PATH=baz;/whatever;$LUA_PATH", 341 | }, 342 | }) 343 | if err != nil { 344 | t.Fatalf("failed to mutate image: %v", err) 345 | } 346 | idx := mutate.AppendManifests(empty.Index, mutate.IndexAddendum{Add: img}) 347 | d, err := idx.Digest() 348 | if err != nil { 349 | t.Fatalf("failed to get index digest: %v", err) 350 | } 351 | ref := repo.Digest(d.String()) 352 | if err := remote.WriteIndex(ref, idx); err != nil { 353 | t.Fatalf("failed to write index: %v", err) 354 | } 355 | 356 | resource.Test(t, resource.TestCase{ 357 | PreCheck: func() { testAccPreCheck(t) }, 358 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 359 | Steps: []resource.TestStep{{ 360 | Config: fmt.Sprintf(`data "oci_structure_test" "test" { 361 | digest = %q 362 | 363 | conditions { 364 | env { 365 | key = "PATH" 366 | value = "$PATH" 367 | } 368 | 369 | env { 370 | key = "LUA_PATH" 371 | value = "baz;/whatever;$LUA_PATH" 372 | } 373 | } 374 | }`, ref), 375 | ExpectError: regexp.MustCompile(`env "PATH" value "\$PATH" references relative path or literal \$ string "\$PATH"\nenv "LUA_PATH" value "baz;/whatever;\$LUA_PATH" references relative path or\nliteral \$ string "baz"\nenv "LUA_PATH" value "baz;/whatever;\$LUA_PATH" references relative path or\nliteral \$ string "\$LUA_PATH"`), 376 | }}, 377 | }) 378 | } 379 | 380 | func TestParseFileMode(t *testing.T) { 381 | tests := []struct { 382 | modeStr string 383 | want os.FileMode 384 | }{ 385 | {"1", 0o001}, 386 | {"75", 0o075}, 387 | {"777", 0o777}, 388 | {"644", 0o644}, 389 | {"666", 0o666}, 390 | {"0644", 0o644}, 391 | {"0755", 0o755}, 392 | {"0777", 0o777}, 393 | {"1755", 0o755 | os.ModeSticky}, 394 | {"2755", 0o755 | os.ModeSetgid}, 395 | {"4755", 0o755 | os.ModeSetuid}, 396 | {"6755", 0o755 | os.ModeSetuid | os.ModeSetgid}, 397 | {"7755", 0o755 | os.ModeSetuid | os.ModeSetgid | os.ModeSticky}, 398 | {"0000", 0o000}, 399 | } 400 | 401 | for _, tt := range tests { 402 | t.Run(tt.modeStr, func(t *testing.T) { 403 | got, err := parseFileMode(tt.modeStr) 404 | if err != nil { 405 | t.Fatalf("parseFileMode(%q) returned error: %v", tt.modeStr, err) 406 | } 407 | if got == nil || *got != tt.want { 408 | t.Errorf("parseFileMode(%q) = %v, want %v", tt.modeStr, got, tt.want) 409 | } 410 | }) 411 | } 412 | 413 | // Test unset -> nil 414 | t.Run("unset", func(t *testing.T) { 415 | got, err := parseFileMode("") 416 | if err != nil { 417 | t.Fatalf("parseFileMode(\"\") returned error: %v", err) 418 | } 419 | if got != nil { 420 | t.Errorf("parseFileMode(\"\") = %v, want nil", got) 421 | } 422 | }) 423 | 424 | t.Run("invalid mode", func(t *testing.T) { 425 | _, err := parseFileMode("invalid") 426 | if err == nil { 427 | t.Error("parseFileMode(\"invalid\") did not return an error") 428 | } 429 | }) 430 | 431 | t.Run("invalid numerical mode", func(t *testing.T) { 432 | _, err := parseFileMode("0999") 433 | if err == nil { 434 | t.Error("parseFileMode(\"0999\") did not return an error") 435 | } 436 | }) 437 | 438 | t.Run("invalid octal mode", func(t *testing.T) { 439 | _, err := parseFileMode("0o777") 440 | if err == nil { 441 | t.Error("parseFileMode(\"0o777\") did not return an error") 442 | } 443 | }) 444 | } 445 | -------------------------------------------------------------------------------- /internal/provider/append_resource_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "archive/tar" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "testing" 11 | 12 | ocitesting "github.com/chainguard-dev/terraform-provider-oci/testing" 13 | "github.com/google/go-containerregistry/pkg/crane" 14 | "github.com/google/go-containerregistry/pkg/name" 15 | v1 "github.com/google/go-containerregistry/pkg/v1" 16 | "github.com/google/go-containerregistry/pkg/v1/empty" 17 | "github.com/google/go-containerregistry/pkg/v1/mutate" 18 | "github.com/google/go-containerregistry/pkg/v1/random" 19 | "github.com/google/go-containerregistry/pkg/v1/remote" 20 | "github.com/google/go-containerregistry/pkg/v1/validate" 21 | "github.com/hashicorp/terraform-plugin-testing/helper/resource" 22 | "github.com/hashicorp/terraform-plugin-testing/terraform" 23 | ) 24 | 25 | func TestAccAppendResource(t *testing.T) { 26 | repo, cleanup := ocitesting.SetupRepository(t, "test") 27 | defer cleanup() 28 | 29 | // Push an image to the local registry. 30 | ref1 := repo.Tag("1") 31 | t.Logf("Using ref1: %s", ref1) 32 | img1, err := random.Image(1024, 1) 33 | if err != nil { 34 | t.Fatalf("failed to create image: %v", err) 35 | } 36 | if err := remote.Write(ref1, img1); err != nil { 37 | t.Fatalf("failed to write image: %v", err) 38 | } 39 | 40 | // Push an image to the local registry. 41 | ref2 := repo.Tag("2") 42 | t.Logf("Using ref2: %s", ref2) 43 | img2, err := random.Image(1024, 1) 44 | if err != nil { 45 | t.Fatalf("failed to create image: %v", err) 46 | } 47 | if err := remote.Write(ref2, img2); err != nil { 48 | t.Fatalf("failed to write image: %v", err) 49 | } 50 | 51 | tf := filepath.Join(t.TempDir(), "test_path.txt") 52 | if err := os.WriteFile(tf, []byte("hello world"), 0o644); err != nil { 53 | t.Fatalf("failed to write file: %v", err) 54 | } 55 | 56 | if err := os.Chmod(tf, 0o755); err != nil { 57 | t.Fatalf("failed to chmod file: %v", err) 58 | } 59 | 60 | resource.Test(t, resource.TestCase{ 61 | PreCheck: func() { testAccPreCheck(t) }, 62 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 63 | Steps: []resource.TestStep{ 64 | // Create and Read testing 65 | { 66 | Config: fmt.Sprintf(`resource "oci_append" "test" { 67 | base_image = %q 68 | layers = [{ 69 | files = { 70 | "/usr/local/test.txt" = { contents = "hello world" } 71 | "/usr/local/test_path.txt" = { path = "%s" } 72 | } 73 | }] 74 | }`, ref1, tf), 75 | Check: resource.ComposeAggregateTestCheckFunc( 76 | resource.TestCheckResourceAttr("oci_append.test", "base_image", ref1.String()), 77 | resource.TestMatchResourceAttr("oci_append.test", "image_ref", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)), 78 | resource.TestMatchResourceAttr("oci_append.test", "id", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)), 79 | resource.TestCheckFunc(func(s *terraform.State) error { 80 | rs := s.RootModule().Resources["oci_append.test"] 81 | img, err := crane.Pull(rs.Primary.Attributes["image_ref"]) 82 | if err != nil { 83 | return fmt.Errorf("failed to pull image: %v", err) 84 | } 85 | if err := validate.Image(img); err != nil { 86 | return fmt.Errorf("failed to validate image: %v", err) 87 | } 88 | // test that the contents match what we expect 89 | ls, err := img.Layers() 90 | if err != nil { 91 | return fmt.Errorf("failed to get layers: %v", err) 92 | } 93 | if len(ls) != 2 { 94 | return fmt.Errorf("expected 2 layer, got %d", len(ls)) 95 | } 96 | 97 | flrc, err := ls[1].Uncompressed() 98 | if err != nil { 99 | return fmt.Errorf("failed to get layer contents: %v", err) 100 | } 101 | defer flrc.Close() 102 | 103 | // the layer should be a tar file with two files 104 | tw := tar.NewReader(flrc) 105 | 106 | hdr, err := tw.Next() 107 | if err != nil { 108 | return fmt.Errorf("failed to read next header: %v", err) 109 | } 110 | if hdr.Size != int64(len("hello world")) { 111 | return fmt.Errorf("expected file size %d, got %d", len("hello world"), hdr.Size) 112 | } 113 | 114 | hdr, err = tw.Next() 115 | if err != nil { 116 | return fmt.Errorf("failed to read next header: %v", err) 117 | } 118 | if hdr.Size != int64(len("hello world")) { 119 | return fmt.Errorf("expected file size %d, got %d", len("hello world"), hdr.Size) 120 | } 121 | 122 | return nil 123 | }), 124 | ), 125 | }, 126 | // Update and Read testing 127 | { 128 | Config: fmt.Sprintf(`resource "oci_append" "test" { 129 | base_image = %q 130 | layers = [{ 131 | files = { 132 | "/usr/local/test.txt" = { contents = "hello world" } 133 | "/usr/bin/test.sh" = { contents = "echo hello world" } 134 | } 135 | }] 136 | }`, ref2), 137 | Check: resource.ComposeAggregateTestCheckFunc( 138 | resource.TestCheckResourceAttr("oci_append.test", "base_image", ref2.String()), 139 | resource.TestMatchResourceAttr("oci_append.test", "id", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)), 140 | ), 141 | }, 142 | // Delete testing automatically occurs in TestCase 143 | }, 144 | }) 145 | 146 | resource.Test(t, resource.TestCase{ 147 | PreCheck: func() { testAccPreCheck(t) }, 148 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 149 | Steps: []resource.TestStep{ 150 | // Create and Read testing 151 | { 152 | Config: fmt.Sprintf(`resource "oci_append" "test" { 153 | base_image = %q 154 | layers = [{ 155 | files = { 156 | "/usr/local/test.txt" = { path = "%s" } 157 | } 158 | }] 159 | }`, ref1, tf), 160 | Check: resource.ComposeAggregateTestCheckFunc( 161 | resource.TestCheckResourceAttr("oci_append.test", "base_image", ref1.String()), 162 | resource.TestMatchResourceAttr("oci_append.test", "image_ref", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)), 163 | resource.TestMatchResourceAttr("oci_append.test", "id", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)), 164 | resource.TestCheckFunc(func(s *terraform.State) error { 165 | rs := s.RootModule().Resources["oci_append.test"] 166 | img, err := crane.Pull(rs.Primary.Attributes["image_ref"]) 167 | if err != nil { 168 | return fmt.Errorf("failed to pull image: %v", err) 169 | } 170 | if err := validate.Image(img); err != nil { 171 | return fmt.Errorf("failed to validate image: %v", err) 172 | } 173 | // test that the contents match what we expect 174 | ls, err := img.Layers() 175 | if err != nil { 176 | return fmt.Errorf("failed to get layers: %v", err) 177 | } 178 | if len(ls) != 2 { 179 | return fmt.Errorf("expected 2 layer, got %d", len(ls)) 180 | } 181 | 182 | flrc, err := ls[1].Uncompressed() 183 | if err != nil { 184 | return fmt.Errorf("failed to get layer contents: %v", err) 185 | } 186 | defer flrc.Close() 187 | 188 | // the layer should be a tar file with one file 189 | tr := tar.NewReader(flrc) 190 | 191 | hdr, err := tr.Next() 192 | if err != nil { 193 | return fmt.Errorf("failed to read next header: %v", err) 194 | } 195 | if hdr.Name != "/usr/local/test.txt" { 196 | return fmt.Errorf("expected file usr/local/test.txt, got %s", hdr.Name) 197 | } 198 | if hdr.Size != int64(len("hello world")) { 199 | return fmt.Errorf("expected file size %d, got %d", len("hello world"), hdr.Size) 200 | } 201 | // ensure the mode is preserved 202 | if hdr.Mode != 0o755 { 203 | return fmt.Errorf("expected mode %d, got %d", 0o755, hdr.Mode) 204 | } 205 | 206 | // check the actual file contents are what we expect 207 | content := make([]byte, hdr.Size) 208 | if _, err := io.ReadFull(tr, content); err != nil { 209 | return fmt.Errorf("failed to read file contents: %v", err) 210 | } 211 | 212 | if string(content) != "hello world" { 213 | return fmt.Errorf("expected file contents %q, got %q", "hello world", string(content)) 214 | } 215 | 216 | return nil 217 | }), 218 | ), 219 | }, 220 | // Update and Read testing 221 | { 222 | Config: fmt.Sprintf(`resource "oci_append" "test" { 223 | base_image = %q 224 | layers = [{ 225 | files = { 226 | "/usr/local/test.txt" = { contents = "hello world" } 227 | "/usr/bin/test.sh" = { contents = "echo hello world" } 228 | } 229 | }] 230 | }`, ref2), 231 | Check: resource.ComposeAggregateTestCheckFunc( 232 | resource.TestCheckResourceAttr("oci_append.test", "base_image", ref2.String()), 233 | resource.TestMatchResourceAttr("oci_append.test", "id", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)), 234 | ), 235 | }, 236 | // Delete testing automatically occurs in TestCase 237 | }, 238 | }) 239 | 240 | // Push an index to the local registry. 241 | ref3 := repo.Tag("3") 242 | idx1, err := random.Index(3, 1, 3) 243 | if err != nil { 244 | t.Fatalf("failed to create index: %v", err) 245 | } 246 | if err := remote.WriteIndex(ref3, idx1); err != nil { 247 | t.Fatalf("failed to write index: %v", err) 248 | } 249 | 250 | resource.Test(t, resource.TestCase{ 251 | PreCheck: func() { testAccPreCheck(t) }, 252 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 253 | Steps: []resource.TestStep{ 254 | // Create and Read testing 255 | { 256 | Config: fmt.Sprintf(`resource "oci_append" "test" { 257 | base_image = %q 258 | layers = [{ 259 | files = { 260 | "/usr/local/test.txt" = { contents = "hello world" } 261 | } 262 | }] 263 | }`, ref3), 264 | Check: resource.ComposeAggregateTestCheckFunc( 265 | resource.TestCheckResourceAttr("oci_append.test", "base_image", ref3.String()), 266 | resource.TestMatchResourceAttr("oci_append.test", "id", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)), 267 | resource.TestCheckFunc(func(s *terraform.State) error { 268 | rs := s.RootModule().Resources["oci_append.test"] 269 | ref, err := name.ParseReference(rs.Primary.Attributes["image_ref"]) 270 | if err != nil { 271 | return fmt.Errorf("failed to parse reference: %v", err) 272 | } 273 | idx, err := remote.Index(ref) 274 | if err != nil { 275 | return fmt.Errorf("failed to pull image: %v", err) 276 | } 277 | if err := validate.Index(idx); err != nil { 278 | return fmt.Errorf("failed to validate image: %v", err) 279 | } 280 | 281 | iidx, err := idx.IndexManifest() 282 | if err != nil { 283 | return fmt.Errorf("failed to get image index: %v", err) 284 | } 285 | 286 | for _, m := range iidx.Manifests { 287 | img, err := idx.Image(m.Digest) 288 | if err != nil { 289 | return fmt.Errorf("failed to get image: %v", err) 290 | } 291 | 292 | ls, err := img.Layers() 293 | if err != nil { 294 | return fmt.Errorf("failed to get layers: %v", err) 295 | } 296 | if len(ls) != 2 { 297 | return fmt.Errorf("expected 2 layer, got %d", len(ls)) 298 | } 299 | 300 | flrc, err := ls[1].Uncompressed() 301 | if err != nil { 302 | return fmt.Errorf("failed to get layer contents: %v", err) 303 | } 304 | defer flrc.Close() 305 | 306 | // the layer should be a tar file with one file 307 | tr := tar.NewReader(flrc) 308 | 309 | hdr, err := tr.Next() 310 | if err != nil { 311 | return fmt.Errorf("failed to read next header: %v", err) 312 | } 313 | if hdr.Name != "/usr/local/test.txt" { 314 | return fmt.Errorf("expected file usr/local/test.txt, got %s", hdr.Name) 315 | } 316 | if hdr.Size != int64(len("hello world")) { 317 | return fmt.Errorf("expected file size %d, got %d", len("hello world"), hdr.Size) 318 | } 319 | 320 | // check the actual file contents are what we expect 321 | content := make([]byte, hdr.Size) 322 | if _, err := io.ReadFull(tr, content); err != nil { 323 | return fmt.Errorf("failed to read file contents: %v", err) 324 | } 325 | 326 | if string(content) != "hello world" { 327 | return fmt.Errorf("expected file contents %q, got %q", "hello world", string(content)) 328 | } 329 | } 330 | 331 | return nil 332 | }), 333 | ), 334 | }, 335 | }, 336 | }) 337 | 338 | ref4 := repo.Tag("4") 339 | var idx2 v1.ImageIndex = empty.Index 340 | 341 | idx2 = mutate.AppendManifests(idx2, mutate.IndexAddendum{Add: img1}) 342 | idx2 = mutate.AppendManifests(idx2, mutate.IndexAddendum{Add: img1}) 343 | 344 | if err := remote.WriteIndex(ref4, idx2); err != nil { 345 | t.Fatalf("failed to write index: %v", err) 346 | } 347 | 348 | resource.Test(t, resource.TestCase{ 349 | PreCheck: func() { testAccPreCheck(t) }, 350 | ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, 351 | Steps: []resource.TestStep{ 352 | // Create and Read testing 353 | { 354 | Config: fmt.Sprintf(` 355 | resource "oci_append" "test" { 356 | base_image = %q 357 | layers = [{ 358 | files = { 359 | "/usr/local/test.txt" = { contents = "hello world" } 360 | } 361 | }] 362 | } 363 | `, ref4), 364 | Check: resource.ComposeAggregateTestCheckFunc( 365 | resource.TestCheckResourceAttr("oci_append.test", "base_image", ref4.String()), 366 | resource.TestMatchResourceAttr("oci_append.test", "id", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)), 367 | resource.TestCheckFunc(func(s *terraform.State) error { 368 | rs := s.RootModule().Resources["oci_append.test"] 369 | ref, err := name.ParseReference(rs.Primary.Attributes["image_ref"]) 370 | if err != nil { 371 | return fmt.Errorf("failed to parse reference: %v", err) 372 | } 373 | idx, err := remote.Index(ref) 374 | if err != nil { 375 | return fmt.Errorf("failed to pull index: %v", err) 376 | } 377 | if err := validate.Index(idx); err != nil { 378 | return fmt.Errorf("failed to validate index: %v", err) 379 | } 380 | 381 | return nil 382 | }), 383 | ), 384 | }, 385 | }, 386 | }) 387 | } 388 | -------------------------------------------------------------------------------- /internal/provider/append_resource.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "compress/gzip" 7 | "context" 8 | "fmt" 9 | "io" 10 | "os" 11 | "strings" 12 | 13 | "github.com/google/go-containerregistry/pkg/name" 14 | v1 "github.com/google/go-containerregistry/pkg/v1" 15 | "github.com/google/go-containerregistry/pkg/v1/empty" 16 | "github.com/google/go-containerregistry/pkg/v1/mutate" 17 | "github.com/google/go-containerregistry/pkg/v1/remote" 18 | "github.com/google/go-containerregistry/pkg/v1/tarball" 19 | ggcrtypes "github.com/google/go-containerregistry/pkg/v1/types" 20 | "github.com/hashicorp/terraform-plugin-framework/diag" 21 | "github.com/hashicorp/terraform-plugin-framework/path" 22 | "github.com/hashicorp/terraform-plugin-framework/resource" 23 | "github.com/hashicorp/terraform-plugin-framework/resource/schema" 24 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" 25 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" 26 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" 27 | "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" 28 | "github.com/hashicorp/terraform-plugin-framework/types" 29 | "github.com/hashicorp/terraform-plugin-log/tflog" 30 | ) 31 | 32 | var ( 33 | _ resource.Resource = &AppendResource{} 34 | _ resource.ResourceWithImportState = &AppendResource{} 35 | ) 36 | 37 | func NewAppendResource() resource.Resource { 38 | return &AppendResource{} 39 | } 40 | 41 | // AppendResource defines the resource implementation. 42 | type AppendResource struct { 43 | popts ProviderOpts 44 | } 45 | 46 | // AppendResourceModel describes the resource data model. 47 | type AppendResourceModel struct { 48 | Id types.String `tfsdk:"id"` 49 | ImageRef types.String `tfsdk:"image_ref"` 50 | 51 | BaseImage types.String `tfsdk:"base_image"` 52 | Layers types.List `tfsdk:"layers"` 53 | } 54 | 55 | func (r *AppendResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { 56 | resp.TypeName = req.ProviderTypeName + "_append" 57 | } 58 | 59 | func (r *AppendResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { 60 | resp.Schema = schema.Schema{ 61 | MarkdownDescription: "Append layers to an existing image.", 62 | Attributes: map[string]schema.Attribute{ 63 | "base_image": schema.StringAttribute{ 64 | MarkdownDescription: "Base image to append layers to.", 65 | Optional: true, 66 | Computed: true, 67 | Default: stringdefault.StaticString("cgr.dev/chainguard/static:latest"), 68 | PlanModifiers: []planmodifier.String{ 69 | stringplanmodifier.RequiresReplace(), 70 | }, 71 | }, 72 | "layers": schema.ListNestedAttribute{ 73 | MarkdownDescription: "Layers to append to the base image.", 74 | Optional: false, 75 | Required: true, 76 | PlanModifiers: []planmodifier.List{ 77 | listplanmodifier.RequiresReplace(), 78 | }, 79 | NestedObject: schema.NestedAttributeObject{ 80 | Attributes: map[string]schema.Attribute{ 81 | "files": schema.MapNestedAttribute{ 82 | MarkdownDescription: "Files to add to the layer.", 83 | Required: true, 84 | NestedObject: schema.NestedAttributeObject{ 85 | Attributes: map[string]schema.Attribute{ 86 | "contents": schema.StringAttribute{ 87 | MarkdownDescription: "Content of the file.", 88 | Optional: true, 89 | }, 90 | "path": schema.StringAttribute{ 91 | MarkdownDescription: "Path to a file.", 92 | Optional: true, 93 | }, 94 | // TODO: Add support for file mode. 95 | // TODO: Add support for symlinks. 96 | // TODO: Add support for deletion / whiteouts. 97 | }, 98 | }, 99 | }, 100 | }, 101 | }, 102 | }, 103 | "image_ref": schema.StringAttribute{ 104 | Computed: true, 105 | MarkdownDescription: "The resulting fully-qualified digest (e.g. {repo}@sha256:deadbeef).", 106 | }, 107 | "id": schema.StringAttribute{ 108 | Computed: true, 109 | MarkdownDescription: "The resulting fully-qualified digest (e.g. {repo}@sha256:deadbeef).", 110 | PlanModifiers: []planmodifier.String{ 111 | stringplanmodifier.UseStateForUnknown(), 112 | }, 113 | }, 114 | }, 115 | } 116 | } 117 | 118 | func (r *AppendResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { 119 | // Prevent panic if the provider has not been configured. 120 | if req.ProviderData == nil { 121 | return 122 | } 123 | 124 | popts, ok := req.ProviderData.(*ProviderOpts) 125 | if !ok || popts == nil { 126 | resp.Diagnostics.AddError("Client Error", "invalid provider data") 127 | return 128 | } 129 | r.popts = *popts 130 | } 131 | 132 | func (r *AppendResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { 133 | var data *AppendResourceModel 134 | resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) 135 | if resp.Diagnostics.HasError() { 136 | return 137 | } 138 | 139 | digest, diag := r.doAppend(ctx, data) 140 | if diag.HasError() { 141 | resp.Diagnostics.Append(diag...) 142 | return 143 | } 144 | data.Id = types.StringValue(digest.String()) 145 | data.ImageRef = types.StringValue(digest.String()) 146 | 147 | // Save data into Terraform state 148 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 149 | } 150 | 151 | func (r *AppendResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { 152 | var data *AppendResourceModel 153 | resp.Diagnostics.Append(req.State.Get(ctx, &data)...) 154 | if resp.Diagnostics.HasError() { 155 | return 156 | } 157 | 158 | digest, diag := r.doAppend(ctx, data) 159 | if diag.HasError() { 160 | resp.Diagnostics.Append(diag...) 161 | return 162 | } 163 | 164 | data.Id = types.StringValue(digest.String()) 165 | data.ImageRef = types.StringValue(digest.String()) 166 | 167 | // Save updated data into Terraform state 168 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 169 | } 170 | 171 | func (r *AppendResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { 172 | var data *AppendResourceModel 173 | resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) 174 | if resp.Diagnostics.HasError() { 175 | return 176 | } 177 | 178 | digest, diag := r.doAppend(ctx, data) 179 | if diag.HasError() { 180 | resp.Diagnostics.Append(diag...) 181 | return 182 | } 183 | 184 | data.Id = types.StringValue(digest.String()) 185 | data.ImageRef = types.StringValue(digest.String()) 186 | 187 | resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) 188 | } 189 | 190 | func (r *AppendResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { 191 | var data *AppendResourceModel 192 | resp.Diagnostics.Append(req.State.Get(ctx, &data)...) 193 | if resp.Diagnostics.HasError() { 194 | return 195 | } 196 | 197 | // TODO: optionally delete the previous image when the resource is deleted. 198 | } 199 | 200 | func (r *AppendResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { 201 | resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) 202 | } 203 | 204 | func (r *AppendResource) doAppend(ctx context.Context, data *AppendResourceModel) (*name.Digest, diag.Diagnostics) { 205 | baseref, err := name.ParseReference(data.BaseImage.ValueString()) 206 | if err != nil { 207 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to parse base image", fmt.Sprintf("Unable to parse base image %q, got error: %s", data.BaseImage.ValueString(), err))} 208 | } 209 | 210 | ropts := r.popts.withContext(ctx) 211 | 212 | desc, err := remote.Get(baseref, ropts...) 213 | if err != nil { 214 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to fetch base image", fmt.Sprintf("Unable to fetch base image %q, got error: %s", data.BaseImage.ValueString(), err))} 215 | } 216 | 217 | var ls []struct { 218 | Files map[string]struct { 219 | Contents types.String `tfsdk:"contents"` 220 | Path types.String `tfsdk:"path"` 221 | } `tfsdk:"files"` 222 | } 223 | if diag := data.Layers.ElementsAs(ctx, &ls, false); diag.HasError() { 224 | return nil, diag.Errors() 225 | } 226 | 227 | adds := []mutate.Addendum{} 228 | for _, l := range ls { 229 | var b bytes.Buffer 230 | zw := gzip.NewWriter(&b) 231 | tw := tar.NewWriter(zw) 232 | for name, f := range l.Files { 233 | var ( 234 | size int64 235 | mode int64 236 | datarc io.ReadCloser 237 | ) 238 | 239 | write := func(rc io.ReadCloser) error { 240 | defer rc.Close() 241 | if err := tw.WriteHeader(&tar.Header{ 242 | Name: name, 243 | Size: size, 244 | Mode: mode, 245 | }); err != nil { 246 | return fmt.Errorf("unable to write tar header: %w", err) 247 | } 248 | 249 | if _, err := io.CopyN(tw, rc, size); err != nil { 250 | return fmt.Errorf("unable to write tar contents: %w", err) 251 | } 252 | return nil 253 | } 254 | 255 | if f.Contents.ValueString() != "" { 256 | size = int64(len(f.Contents.ValueString())) 257 | mode = 0o644 258 | datarc = io.NopCloser(strings.NewReader(f.Contents.ValueString())) 259 | 260 | } else if f.Path.ValueString() != "" { 261 | fi, err := os.Stat(f.Path.ValueString()) 262 | if err != nil { 263 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to stat file", fmt.Sprintf("Unable to stat file %q, got error: %s", f.Path.ValueString(), err))} 264 | } 265 | 266 | // skip any directories or symlinks 267 | if fi.IsDir() || fi.Mode()&os.ModeSymlink != 0 { 268 | continue 269 | } 270 | 271 | size = fi.Size() 272 | mode = int64(fi.Mode()) 273 | 274 | fr, err := os.Open(f.Path.ValueString()) 275 | if err != nil { 276 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to open file", fmt.Sprintf("Unable to open file %q, got error: %s", f.Path.ValueString(), err))} 277 | } 278 | datarc = fr 279 | 280 | } else { 281 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("No file contents or path specified", fmt.Sprintf("No file contents or path specified for %q", name))} 282 | } 283 | 284 | if err := write(datarc); err != nil { 285 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to write tar contents", fmt.Sprintf("Unable to write tar contents for %q, got error: %s", name, err))} 286 | } 287 | } 288 | if err := tw.Close(); err != nil { 289 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to close tar writer", fmt.Sprintf("Unable to close tar writer, got error: %s", err))} 290 | } 291 | if err := zw.Close(); err != nil { 292 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to close gzip writer", fmt.Sprintf("Unable to close gzip writer, got error: %s", err))} 293 | } 294 | 295 | l, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { 296 | return io.NopCloser(bytes.NewBuffer(b.Bytes())), nil 297 | }) 298 | if err != nil { 299 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to create layer", fmt.Sprintf("Unable to create layer, got error: %s", err))} 300 | } 301 | 302 | adds = append(adds, mutate.Addendum{ 303 | Layer: l, 304 | History: v1.History{CreatedBy: "terraform-provider-oci: oci_append"}, 305 | MediaType: ggcrtypes.OCILayer, 306 | }) 307 | } 308 | 309 | var d name.Digest 310 | 311 | if desc.MediaType.IsIndex() { 312 | baseidx, err := desc.ImageIndex() 313 | if err != nil { 314 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to read image index", fmt.Sprintf("Unable to read image index for ref %q, got error: %s", data.BaseImage.ValueString(), err))} 315 | } 316 | 317 | baseimf, err := baseidx.IndexManifest() 318 | if err != nil { 319 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to read image index manifest", fmt.Sprintf("Unable to read image index manifest for ref %q, got error: %s", data.BaseImage.ValueString(), err))} 320 | } 321 | 322 | var idx v1.ImageIndex = empty.Index 323 | 324 | // append to each manifest in the index 325 | for _, manifest := range baseimf.Manifests { 326 | baseimg, err := baseidx.Image(manifest.Digest) 327 | if err != nil { 328 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to load image", fmt.Sprintf("Unable to load image for ref %q, got error: %s", data.BaseImage.ValueString(), err))} 329 | } 330 | 331 | img, err := mutate.Append(baseimg, adds...) 332 | if err != nil { 333 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to append layers", fmt.Sprintf("Unable to append layers, got error: %s", err))} 334 | } 335 | 336 | imgdig, err := img.Digest() 337 | if err != nil { 338 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to get image digest", fmt.Sprintf("Unable to get image digest, got error: %s", err))} 339 | } 340 | 341 | if err := remote.Write(baseref.Context().Digest(imgdig.String()), img, ropts...); err != nil { 342 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to push image", fmt.Sprintf("Unable to push image, got error: %s", err))} 343 | } 344 | 345 | // Update the index with the new image 346 | idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ 347 | Add: img, 348 | Descriptor: v1.Descriptor{ 349 | MediaType: manifest.MediaType, 350 | URLs: manifest.URLs, 351 | Annotations: manifest.Annotations, 352 | Platform: manifest.Platform, 353 | ArtifactType: manifest.ArtifactType, 354 | }, 355 | }) 356 | } 357 | 358 | dig, err := idx.Digest() 359 | if err != nil { 360 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to get index digest", fmt.Sprintf("Unable to get index digest, got error: %s", err))} 361 | } 362 | 363 | d = baseref.Context().Digest(dig.String()) 364 | if err := remote.WriteIndex(d, idx, ropts...); err != nil { 365 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to push index", fmt.Sprintf("Unable to push index, got error: %s", err))} 366 | } 367 | 368 | } else if desc.MediaType.IsImage() { 369 | baseimg, err := remote.Image(baseref, ropts...) 370 | if err != nil { 371 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to fetch base image", fmt.Sprintf("Unable to fetch base image %q, got error: %s", data.BaseImage.ValueString(), err))} 372 | } 373 | 374 | img, err := mutate.Append(baseimg, adds...) 375 | if err != nil { 376 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to append layers", fmt.Sprintf("Unable to append layers, got error: %s", err))} 377 | } 378 | 379 | dig, err := img.Digest() 380 | if err != nil { 381 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to get image digest", fmt.Sprintf("Unable to get image digest, got error: %s", err))} 382 | } 383 | 384 | d = baseref.Context().Digest(dig.String()) 385 | if err := remote.Write(d, img, r.popts.withContext(ctx)...); err != nil { 386 | return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to push image", fmt.Sprintf("Unable to push image, got error: %s", err))} 387 | } 388 | } 389 | 390 | // Write logs using the tflog package 391 | // Documentation: https://terraform.io/plugin/log 392 | tflog.Trace(ctx, "created a resource") 393 | 394 | return &d, []diag.Diagnostic{} 395 | } 396 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 HashiCorp, Inc. 2 | 3 | Mozilla Public License Version 2.0 4 | ================================== 5 | 6 | 1. Definitions 7 | -------------- 8 | 9 | 1.1. "Contributor" 10 | means each individual or legal entity that creates, contributes to 11 | the creation of, or owns Covered Software. 12 | 13 | 1.2. "Contributor Version" 14 | means the combination of the Contributions of others (if any) used 15 | by a Contributor and that particular Contributor's Contribution. 16 | 17 | 1.3. "Contribution" 18 | means Covered Software of a particular Contributor. 19 | 20 | 1.4. "Covered Software" 21 | means Source Code Form to which the initial Contributor has attached 22 | the notice in Exhibit A, the Executable Form of such Source Code 23 | Form, and Modifications of such Source Code Form, in each case 24 | including portions thereof. 25 | 26 | 1.5. "Incompatible With Secondary Licenses" 27 | means 28 | 29 | (a) that the initial Contributor has attached the notice described 30 | in Exhibit B to the Covered Software; or 31 | 32 | (b) that the Covered Software was made available under the terms of 33 | version 1.1 or earlier of the License, but not also under the 34 | terms of a Secondary License. 35 | 36 | 1.6. "Executable Form" 37 | means any form of the work other than Source Code Form. 38 | 39 | 1.7. "Larger Work" 40 | means a work that combines Covered Software with other material, in 41 | a separate file or files, that is not Covered Software. 42 | 43 | 1.8. "License" 44 | means this document. 45 | 46 | 1.9. "Licensable" 47 | means having the right to grant, to the maximum extent possible, 48 | whether at the time of the initial grant or subsequently, any and 49 | all of the rights conveyed by this License. 50 | 51 | 1.10. "Modifications" 52 | means any of the following: 53 | 54 | (a) any file in Source Code Form that results from an addition to, 55 | deletion from, or modification of the contents of Covered 56 | Software; or 57 | 58 | (b) any new file in Source Code Form that contains any Covered 59 | Software. 60 | 61 | 1.11. "Patent Claims" of a Contributor 62 | means any patent claim(s), including without limitation, method, 63 | process, and apparatus claims, in any patent Licensable by such 64 | Contributor that would be infringed, but for the grant of the 65 | License, by the making, using, selling, offering for sale, having 66 | made, import, or transfer of either its Contributions or its 67 | Contributor Version. 68 | 69 | 1.12. "Secondary License" 70 | means either the GNU General Public License, Version 2.0, the GNU 71 | Lesser General Public License, Version 2.1, the GNU Affero General 72 | Public License, Version 3.0, or any later versions of those 73 | licenses. 74 | 75 | 1.13. "Source Code Form" 76 | means the form of the work preferred for making modifications. 77 | 78 | 1.14. "You" (or "Your") 79 | means an individual or a legal entity exercising rights under this 80 | License. For legal entities, "You" includes any entity that 81 | controls, is controlled by, or is under common control with You. For 82 | purposes of this definition, "control" means (a) the power, direct 83 | or indirect, to cause the direction or management of such entity, 84 | whether by contract or otherwise, or (b) ownership of more than 85 | fifty percent (50%) of the outstanding shares or beneficial 86 | ownership of such entity. 87 | 88 | 2. License Grants and Conditions 89 | -------------------------------- 90 | 91 | 2.1. Grants 92 | 93 | Each Contributor hereby grants You a world-wide, royalty-free, 94 | non-exclusive license: 95 | 96 | (a) under intellectual property rights (other than patent or trademark) 97 | Licensable by such Contributor to use, reproduce, make available, 98 | modify, display, perform, distribute, and otherwise exploit its 99 | Contributions, either on an unmodified basis, with Modifications, or 100 | as part of a Larger Work; and 101 | 102 | (b) under Patent Claims of such Contributor to make, use, sell, offer 103 | for sale, have made, import, and otherwise transfer either its 104 | Contributions or its Contributor Version. 105 | 106 | 2.2. Effective Date 107 | 108 | The licenses granted in Section 2.1 with respect to any Contribution 109 | become effective for each Contribution on the date the Contributor first 110 | distributes such Contribution. 111 | 112 | 2.3. Limitations on Grant Scope 113 | 114 | The licenses granted in this Section 2 are the only rights granted under 115 | this License. No additional rights or licenses will be implied from the 116 | distribution or licensing of Covered Software under this License. 117 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 118 | Contributor: 119 | 120 | (a) for any code that a Contributor has removed from Covered Software; 121 | or 122 | 123 | (b) for infringements caused by: (i) Your and any other third party's 124 | modifications of Covered Software, or (ii) the combination of its 125 | Contributions with other software (except as part of its Contributor 126 | Version); or 127 | 128 | (c) under Patent Claims infringed by Covered Software in the absence of 129 | its Contributions. 130 | 131 | This License does not grant any rights in the trademarks, service marks, 132 | or logos of any Contributor (except as may be necessary to comply with 133 | the notice requirements in Section 3.4). 134 | 135 | 2.4. Subsequent Licenses 136 | 137 | No Contributor makes additional grants as a result of Your choice to 138 | distribute the Covered Software under a subsequent version of this 139 | License (see Section 10.2) or under the terms of a Secondary License (if 140 | permitted under the terms of Section 3.3). 141 | 142 | 2.5. Representation 143 | 144 | Each Contributor represents that the Contributor believes its 145 | Contributions are its original creation(s) or it has sufficient rights 146 | to grant the rights to its Contributions conveyed by this License. 147 | 148 | 2.6. Fair Use 149 | 150 | This License is not intended to limit any rights You have under 151 | applicable copyright doctrines of fair use, fair dealing, or other 152 | equivalents. 153 | 154 | 2.7. Conditions 155 | 156 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 157 | in Section 2.1. 158 | 159 | 3. Responsibilities 160 | ------------------- 161 | 162 | 3.1. Distribution of Source Form 163 | 164 | All distribution of Covered Software in Source Code Form, including any 165 | Modifications that You create or to which You contribute, must be under 166 | the terms of this License. You must inform recipients that the Source 167 | Code Form of the Covered Software is governed by the terms of this 168 | License, and how they can obtain a copy of this License. You may not 169 | attempt to alter or restrict the recipients' rights in the Source Code 170 | Form. 171 | 172 | 3.2. Distribution of Executable Form 173 | 174 | If You distribute Covered Software in Executable Form then: 175 | 176 | (a) such Covered Software must also be made available in Source Code 177 | Form, as described in Section 3.1, and You must inform recipients of 178 | the Executable Form how they can obtain a copy of such Source Code 179 | Form by reasonable means in a timely manner, at a charge no more 180 | than the cost of distribution to the recipient; and 181 | 182 | (b) You may distribute such Executable Form under the terms of this 183 | License, or sublicense it under different terms, provided that the 184 | license for the Executable Form does not attempt to limit or alter 185 | the recipients' rights in the Source Code Form under this License. 186 | 187 | 3.3. Distribution of a Larger Work 188 | 189 | You may create and distribute a Larger Work under terms of Your choice, 190 | provided that You also comply with the requirements of this License for 191 | the Covered Software. If the Larger Work is a combination of Covered 192 | Software with a work governed by one or more Secondary Licenses, and the 193 | Covered Software is not Incompatible With Secondary Licenses, this 194 | License permits You to additionally distribute such Covered Software 195 | under the terms of such Secondary License(s), so that the recipient of 196 | the Larger Work may, at their option, further distribute the Covered 197 | Software under the terms of either this License or such Secondary 198 | License(s). 199 | 200 | 3.4. Notices 201 | 202 | You may not remove or alter the substance of any license notices 203 | (including copyright notices, patent notices, disclaimers of warranty, 204 | or limitations of liability) contained within the Source Code Form of 205 | the Covered Software, except that You may alter any license notices to 206 | the extent required to remedy known factual inaccuracies. 207 | 208 | 3.5. Application of Additional Terms 209 | 210 | You may choose to offer, and to charge a fee for, warranty, support, 211 | indemnity or liability obligations to one or more recipients of Covered 212 | Software. However, You may do so only on Your own behalf, and not on 213 | behalf of any Contributor. You must make it absolutely clear that any 214 | such warranty, support, indemnity, or liability obligation is offered by 215 | You alone, and You hereby agree to indemnify every Contributor for any 216 | liability incurred by such Contributor as a result of warranty, support, 217 | indemnity or liability terms You offer. You may include additional 218 | disclaimers of warranty and limitations of liability specific to any 219 | jurisdiction. 220 | 221 | 4. Inability to Comply Due to Statute or Regulation 222 | --------------------------------------------------- 223 | 224 | If it is impossible for You to comply with any of the terms of this 225 | License with respect to some or all of the Covered Software due to 226 | statute, judicial order, or regulation then You must: (a) comply with 227 | the terms of this License to the maximum extent possible; and (b) 228 | describe the limitations and the code they affect. Such description must 229 | be placed in a text file included with all distributions of the Covered 230 | Software under this License. Except to the extent prohibited by statute 231 | or regulation, such description must be sufficiently detailed for a 232 | recipient of ordinary skill to be able to understand it. 233 | 234 | 5. Termination 235 | -------------- 236 | 237 | 5.1. The rights granted under this License will terminate automatically 238 | if You fail to comply with any of its terms. However, if You become 239 | compliant, then the rights granted under this License from a particular 240 | Contributor are reinstated (a) provisionally, unless and until such 241 | Contributor explicitly and finally terminates Your grants, and (b) on an 242 | ongoing basis, if such Contributor fails to notify You of the 243 | non-compliance by some reasonable means prior to 60 days after You have 244 | come back into compliance. Moreover, Your grants from a particular 245 | Contributor are reinstated on an ongoing basis if such Contributor 246 | notifies You of the non-compliance by some reasonable means, this is the 247 | first time You have received notice of non-compliance with this License 248 | from such Contributor, and You become compliant prior to 30 days after 249 | Your receipt of the notice. 250 | 251 | 5.2. If You initiate litigation against any entity by asserting a patent 252 | infringement claim (excluding declaratory judgment actions, 253 | counter-claims, and cross-claims) alleging that a Contributor Version 254 | directly or indirectly infringes any patent, then the rights granted to 255 | You by any and all Contributors for the Covered Software under Section 256 | 2.1 of this License shall terminate. 257 | 258 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 259 | end user license agreements (excluding distributors and resellers) which 260 | have been validly granted by You or Your distributors under this License 261 | prior to termination shall survive termination. 262 | 263 | ************************************************************************ 264 | * * 265 | * 6. Disclaimer of Warranty * 266 | * ------------------------- * 267 | * * 268 | * Covered Software is provided under this License on an "as is" * 269 | * basis, without warranty of any kind, either expressed, implied, or * 270 | * statutory, including, without limitation, warranties that the * 271 | * Covered Software is free of defects, merchantable, fit for a * 272 | * particular purpose or non-infringing. The entire risk as to the * 273 | * quality and performance of the Covered Software is with You. * 274 | * Should any Covered Software prove defective in any respect, You * 275 | * (not any Contributor) assume the cost of any necessary servicing, * 276 | * repair, or correction. This disclaimer of warranty constitutes an * 277 | * essential part of this License. No use of any Covered Software is * 278 | * authorized under this License except under this disclaimer. * 279 | * * 280 | ************************************************************************ 281 | 282 | ************************************************************************ 283 | * * 284 | * 7. Limitation of Liability * 285 | * -------------------------- * 286 | * * 287 | * Under no circumstances and under no legal theory, whether tort * 288 | * (including negligence), contract, or otherwise, shall any * 289 | * Contributor, or anyone who distributes Covered Software as * 290 | * permitted above, be liable to You for any direct, indirect, * 291 | * special, incidental, or consequential damages of any character * 292 | * including, without limitation, damages for lost profits, loss of * 293 | * goodwill, work stoppage, computer failure or malfunction, or any * 294 | * and all other commercial damages or losses, even if such party * 295 | * shall have been informed of the possibility of such damages. This * 296 | * limitation of liability shall not apply to liability for death or * 297 | * personal injury resulting from such party's negligence to the * 298 | * extent applicable law prohibits such limitation. Some * 299 | * jurisdictions do not allow the exclusion or limitation of * 300 | * incidental or consequential damages, so this exclusion and * 301 | * limitation may not apply to You. * 302 | * * 303 | ************************************************************************ 304 | 305 | 8. Litigation 306 | ------------- 307 | 308 | Any litigation relating to this License may be brought only in the 309 | courts of a jurisdiction where the defendant maintains its principal 310 | place of business and such litigation shall be governed by laws of that 311 | jurisdiction, without reference to its conflict-of-law provisions. 312 | Nothing in this Section shall prevent a party's ability to bring 313 | cross-claims or counter-claims. 314 | 315 | 9. Miscellaneous 316 | ---------------- 317 | 318 | This License represents the complete agreement concerning the subject 319 | matter hereof. If any provision of this License is held to be 320 | unenforceable, such provision shall be reformed only to the extent 321 | necessary to make it enforceable. Any law or regulation which provides 322 | that the language of a contract shall be construed against the drafter 323 | shall not be used to construe this License against a Contributor. 324 | 325 | 10. Versions of the License 326 | --------------------------- 327 | 328 | 10.1. New Versions 329 | 330 | Mozilla Foundation is the license steward. Except as provided in Section 331 | 10.3, no one other than the license steward has the right to modify or 332 | publish new versions of this License. Each version will be given a 333 | distinguishing version number. 334 | 335 | 10.2. Effect of New Versions 336 | 337 | You may distribute the Covered Software under the terms of the version 338 | of the License under which You originally received the Covered Software, 339 | or under the terms of any subsequent version published by the license 340 | steward. 341 | 342 | 10.3. Modified Versions 343 | 344 | If you create software not governed by this License, and you want to 345 | create a new license for such software, you may create and use a 346 | modified version of this License if you rename the license and remove 347 | any references to the name of the license steward (except to note that 348 | such modified license differs from this License). 349 | 350 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 351 | Licenses 352 | 353 | If You choose to distribute Source Code Form that is Incompatible With 354 | Secondary Licenses under the terms of this version of the License, the 355 | notice described in Exhibit B of this License must be attached. 356 | 357 | Exhibit A - Source Code Form License Notice 358 | ------------------------------------------- 359 | 360 | This Source Code Form is subject to the terms of the Mozilla Public 361 | License, v. 2.0. If a copy of the MPL was not distributed with this 362 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 363 | 364 | If it is not possible or desirable to put the notice in a particular 365 | file, then You may include the notice in a location (such as a LICENSE 366 | file in a relevant directory) where a recipient would be likely to look 367 | for such a notice. 368 | 369 | You may add additional accurate notices of copyright ownership. 370 | 371 | Exhibit B - "Incompatible With Secondary Licenses" Notice 372 | --------------------------------------------------------- 373 | 374 | This Source Code Form is "Incompatible With Secondary Licenses", as 375 | defined by the Mozilla Public License, v. 2.0. 376 | --------------------------------------------------------------------------------