├── .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 | [](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 |
--------------------------------------------------------------------------------