diff --git a/docs/resources/append.md b/docs/resources/append.md index 5b975b9..a1ea7c2 100644 --- a/docs/resources/append.md +++ b/docs/resources/append.md @@ -44,6 +44,7 @@ Required: ### Nested Schema for `layers.files` -Required: +Optional: - `contents` (String) Content of the file. +- `path` (String) Path to a file. diff --git a/internal/provider/append_resource.go b/internal/provider/append_resource.go index 6f964fd..a19014a 100644 --- a/internal/provider/append_resource.go +++ b/internal/provider/append_resource.go @@ -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" @@ -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. @@ -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() { @@ -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))} } } diff --git a/internal/provider/append_resource_test.go b/internal/provider/append_resource_test.go index f653551..f84c15b 100644 --- a/internal/provider/append_resource_test.go +++ b/internal/provider/append_resource_test.go @@ -1,7 +1,11 @@ package provider import ( + "archive/tar" "fmt" + "io" + "os" + "path/filepath" "regexp" "testing" @@ -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, @@ -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}$`)), @@ -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 }), ), @@ -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 }), ),