Skip to content

Commit

Permalink
add a "path" field to oci_append (#164)
Browse files Browse the repository at this point in the history
add another filed to `oci_append` that allows specifying a `path` to an
existing file on disk.

since this preserves the file mode, this is needed for appending things
like executables (shell scripts, etc).
  • Loading branch information
joshrwolf authored Aug 14, 2024
1 parent fa12abf commit e933124
Show file tree
Hide file tree
Showing 3 changed files with 256 additions and 12 deletions.
3 changes: 2 additions & 1 deletion docs/resources/append.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Required:
<a id="nestedatt--layers--files"></a>
### Nested Schema for `layers.files`

Required:
Optional:

- `contents` (String) Content of the file.
- `path` (String) Path to a file.
70 changes: 60 additions & 10 deletions internal/provider/append_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import (
"compress/gzip"
"context"
"fmt"
"io"
"os"
"strings"

"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
Expand Down Expand Up @@ -77,14 +80,16 @@ func (r *AppendResource) Schema(ctx context.Context, req resource.SchemaRequest,
Attributes: map[string]schema.Attribute{
"files": schema.MapNestedAttribute{
MarkdownDescription: "Files to add to the layer.",
Optional: false,
Required: true,
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"contents": schema.StringAttribute{
MarkdownDescription: "Content of the file.",
Optional: false,
Required: true,
Optional: true,
},
"path": schema.StringAttribute{
MarkdownDescription: "Path to a file.",
Optional: true,
},
// TODO: Add support for file mode.
// TODO: Add support for symlinks.
Expand Down Expand Up @@ -212,6 +217,7 @@ func (r *AppendResource) doAppend(ctx context.Context, data *AppendResourceModel
var ls []struct {
Files map[string]struct {
Contents types.String `tfsdk:"contents"`
Path types.String `tfsdk:"path"`
} `tfsdk:"files"`
}
if diag := data.Layers.ElementsAs(ctx, &ls, false); diag.HasError() {
Expand All @@ -224,14 +230,58 @@ func (r *AppendResource) doAppend(ctx context.Context, data *AppendResourceModel
zw := gzip.NewWriter(&b)
tw := tar.NewWriter(zw)
for name, f := range l.Files {
if err := tw.WriteHeader(&tar.Header{
Name: name,
Size: int64(len(f.Contents.ValueString())),
Mode: 0644,
}); err != nil {
return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to write tar header", fmt.Sprintf("Unable to write tar header for %q, got error: %s", name, err))}
var (
size int64
mode int64
datarc io.ReadCloser
)

write := func(rc io.ReadCloser) error {
defer rc.Close()
if err := tw.WriteHeader(&tar.Header{
Name: name,
Size: size,
Mode: mode,
}); err != nil {
return fmt.Errorf("unable to write tar header: %w", err)
}

if _, err := io.CopyN(tw, rc, size); err != nil {
return fmt.Errorf("unable to write tar contents: %w", err)
}
return nil
}

if f.Contents.ValueString() != "" {
size = int64(len(f.Contents.ValueString()))
mode = 0644
datarc = io.NopCloser(strings.NewReader(f.Contents.ValueString()))

} else if f.Path.ValueString() != "" {
fi, err := os.Stat(f.Path.ValueString())
if err != nil {
return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to stat file", fmt.Sprintf("Unable to stat file %q, got error: %s", f.Path.ValueString(), err))}
}

// skip any directories or symlinks
if fi.IsDir() || fi.Mode()&os.ModeSymlink != 0 {
continue
}

size = fi.Size()
mode = int64(fi.Mode())

fr, err := os.Open(f.Path.ValueString())
if err != nil {
return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Unable to open file", fmt.Sprintf("Unable to open file %q, got error: %s", f.Path.ValueString(), err))}
}
datarc = fr

} else {
return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("No file contents or path specified", fmt.Sprintf("No file contents or path specified for %q", name))}
}
if _, err := tw.Write([]byte(f.Contents.ValueString())); err != nil {

if err := write(datarc); err != nil {
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))}
}
}
Expand Down
195 changes: 194 additions & 1 deletion internal/provider/append_resource_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package provider

import (
"archive/tar"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"testing"

Expand Down Expand Up @@ -44,6 +48,15 @@ func TestAccAppendResource(t *testing.T) {
t.Fatalf("failed to write image: %v", err)
}

tf := filepath.Join(t.TempDir(), "test_path.txt")
if err := os.WriteFile(tf, []byte("hello world"), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}

if err := os.Chmod(tf, 0755); err != nil {
t.Fatalf("failed to chmod file: %v", err)
}

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Expand All @@ -55,9 +68,10 @@ func TestAccAppendResource(t *testing.T) {
layers = [{
files = {
"/usr/local/test.txt" = { contents = "hello world" }
"/usr/local/test_path.txt" = { path = "%s" }
}
}]
}`, ref1),
}`, ref1, tf),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("oci_append.test", "base_image", ref1.String()),
resource.TestMatchResourceAttr("oci_append.test", "image_ref", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)),
Expand All @@ -71,6 +85,134 @@ func TestAccAppendResource(t *testing.T) {
if err := validate.Image(img); err != nil {
return fmt.Errorf("failed to validate image: %v", err)
}
// test that the contents match what we expect
ls, err := img.Layers()
if err != nil {
return fmt.Errorf("failed to get layers: %v", err)
}
if len(ls) != 2 {
return fmt.Errorf("expected 2 layer, got %d", len(ls))
}

flrc, err := ls[1].Uncompressed()
if err != nil {
return fmt.Errorf("failed to get layer contents: %v", err)
}
defer flrc.Close()

// the layer should be a tar file with two files
tw := tar.NewReader(flrc)

hdr, err := tw.Next()
if err != nil {
return fmt.Errorf("failed to read next header: %v", err)
}
if hdr.Size != int64(len("hello world")) {
return fmt.Errorf("expected file size %d, got %d", len("hello world"), hdr.Size)
}

hdr, err = tw.Next()
if err != nil {
return fmt.Errorf("failed to read next header: %v", err)
}
if hdr.Size != int64(len("hello world")) {
return fmt.Errorf("expected file size %d, got %d", len("hello world"), hdr.Size)
}

return nil
}),
),
},
// Update and Read testing
{
Config: fmt.Sprintf(`resource "oci_append" "test" {
base_image = %q
layers = [{
files = {
"/usr/local/test.txt" = { contents = "hello world" }
"/usr/bin/test.sh" = { contents = "echo hello world" }
}
}]
}`, ref2),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("oci_append.test", "base_image", ref2.String()),
resource.TestMatchResourceAttr("oci_append.test", "id", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)),
),
},
// Delete testing automatically occurs in TestCase
},
})

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Create and Read testing
{
Config: fmt.Sprintf(`resource "oci_append" "test" {
base_image = %q
layers = [{
files = {
"/usr/local/test.txt" = { path = "%s" }
}
}]
}`, ref1, tf),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("oci_append.test", "base_image", ref1.String()),
resource.TestMatchResourceAttr("oci_append.test", "image_ref", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)),
resource.TestMatchResourceAttr("oci_append.test", "id", regexp.MustCompile(`/test@sha256:[0-9a-f]{64}$`)),
resource.TestCheckFunc(func(s *terraform.State) error {
rs := s.RootModule().Resources["oci_append.test"]
img, err := crane.Pull(rs.Primary.Attributes["image_ref"])
if err != nil {
return fmt.Errorf("failed to pull image: %v", err)
}
if err := validate.Image(img); err != nil {
return fmt.Errorf("failed to validate image: %v", err)
}
// test that the contents match what we expect
ls, err := img.Layers()
if err != nil {
return fmt.Errorf("failed to get layers: %v", err)
}
if len(ls) != 2 {
return fmt.Errorf("expected 2 layer, got %d", len(ls))
}

flrc, err := ls[1].Uncompressed()
if err != nil {
return fmt.Errorf("failed to get layer contents: %v", err)
}
defer flrc.Close()

// the layer should be a tar file with one file
tr := tar.NewReader(flrc)

hdr, err := tr.Next()
if err != nil {
return fmt.Errorf("failed to read next header: %v", err)
}
if hdr.Name != "/usr/local/test.txt" {
return fmt.Errorf("expected file usr/local/test.txt, got %s", hdr.Name)
}
if hdr.Size != int64(len("hello world")) {
return fmt.Errorf("expected file size %d, got %d", len("hello world"), hdr.Size)
}
// ensure the mode is preserved
if hdr.Mode != 0755 {
return fmt.Errorf("expected mode %d, got %d", 0755, hdr.Mode)
}

// check the actual file contents are what we expect
content := make([]byte, hdr.Size)
if _, err := io.ReadFull(tr, content); err != nil {
return fmt.Errorf("failed to read file contents: %v", err)
}

if string(content) != "hello world" {
return fmt.Errorf("expected file contents %q, got %q", "hello world", string(content))
}

return nil
}),
),
Expand Down Expand Up @@ -135,6 +277,57 @@ func TestAccAppendResource(t *testing.T) {
if err := validate.Index(idx); err != nil {
return fmt.Errorf("failed to validate image: %v", err)
}

iidx, err := idx.IndexManifest()
if err != nil {
return fmt.Errorf("failed to get image index: %v", err)
}

for _, m := range iidx.Manifests {
img, err := idx.Image(m.Digest)
if err != nil {
return fmt.Errorf("failed to get image: %v", err)
}

ls, err := img.Layers()
if err != nil {
return fmt.Errorf("failed to get layers: %v", err)
}
if len(ls) != 2 {
return fmt.Errorf("expected 2 layer, got %d", len(ls))
}

flrc, err := ls[1].Uncompressed()
if err != nil {
return fmt.Errorf("failed to get layer contents: %v", err)
}
defer flrc.Close()

// the layer should be a tar file with one file
tr := tar.NewReader(flrc)

hdr, err := tr.Next()
if err != nil {
return fmt.Errorf("failed to read next header: %v", err)
}
if hdr.Name != "/usr/local/test.txt" {
return fmt.Errorf("expected file usr/local/test.txt, got %s", hdr.Name)
}
if hdr.Size != int64(len("hello world")) {
return fmt.Errorf("expected file size %d, got %d", len("hello world"), hdr.Size)
}

// check the actual file contents are what we expect
content := make([]byte, hdr.Size)
if _, err := io.ReadFull(tr, content); err != nil {
return fmt.Errorf("failed to read file contents: %v", err)
}

if string(content) != "hello world" {
return fmt.Errorf("expected file contents %q, got %q", "hello world", string(content))
}
}

return nil
}),
),
Expand Down

0 comments on commit e933124

Please sign in to comment.