Skip to content

Commit 23a8c92

Browse files
authored
feat: Add docker import action (#900)
* feat: Add docker import action * docs: Add correct example * fix: docker image import unit test
1 parent 0c55dba commit 23a8c92

7 files changed

Lines changed: 463 additions & 0 deletions

File tree

docs/actions/image_import.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "docker_image_import Action - terraform-provider-docker"
4+
subcategory: ""
5+
description: |-
6+
Import a tar archive or URL as a Docker image, similar to docker image import.
7+
---
8+
9+
# docker_image_import (Action)
10+
11+
Import a tar archive or URL as a Docker image, similar to `docker image import`.
12+
13+
## Example Usage
14+
15+
```terraform
16+
## The following code performs an `docker image import` whenever the `import.tar` file changes
17+
18+
resource "terraform_data" "bootstrap" {
19+
triggers_replace = [
20+
filesha512("./import.tar")
21+
]
22+
23+
lifecycle {
24+
action_trigger {
25+
events = [after_update]
26+
actions = [action.docker_image_import.import_export]
27+
}
28+
}
29+
}
30+
31+
32+
action "docker_image_import" "import_export" {
33+
config {
34+
source = pathexpand("./import.tar")
35+
reference = "example-imported-image:latest"
36+
message = "imported from a tar archive"
37+
changes = ["CMD [\"sh\"]"]
38+
platform = "linux/amd64"
39+
}
40+
}
41+
```
42+
43+
<!-- action schema generated by tfplugindocs -->
44+
## Schema
45+
46+
### Required
47+
48+
- `reference` (String) Image name and optional tag to apply to the imported image, for example `my-image:latest`.
49+
- `source` (String) Path to a local tar archive or an http(s) URL containing the filesystem to import.
50+
51+
### Optional
52+
53+
- `changes` (List of String) Raw Dockerfile instructions to apply to the imported image.
54+
- `message` (String) Optional message to store with the imported image.
55+
- `platform` (String) Platform to assign to the imported image.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## The following code performs an `docker image import` whenever the `import.tar` file changes
2+
3+
resource "terraform_data" "bootstrap" {
4+
triggers_replace = [
5+
filesha512("./import.tar")
6+
]
7+
8+
lifecycle {
9+
action_trigger {
10+
events = [after_update]
11+
actions = [action.docker_image_import.import_export]
12+
}
13+
}
14+
}
15+
16+
17+
action "docker_image_import" "import_export" {
18+
config {
19+
source = pathexpand("./import.tar")
20+
reference = "example-imported-image:latest"
21+
message = "imported from a tar archive"
22+
changes = ["CMD [\"sh\"]"]
23+
platform = "linux/amd64"
24+
}
25+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package actiontests
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"testing"
9+
"time"
10+
11+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
12+
"github.com/hashicorp/terraform-plugin-testing/tfversion"
13+
)
14+
15+
func TestDockerImageImportAction_importsTarballIntoImage(t *testing.T) {
16+
preCheckDocker(t)
17+
18+
sourceContainerName := fmt.Sprintf("tf-acc-docker-import-src-%d", time.Now().UnixNano())
19+
targetContainerName := fmt.Sprintf("tf-acc-docker-import-tgt-%d", time.Now().UnixNano())
20+
imageRef := fmt.Sprintf("tf-acc-docker-imported-%d:latest", time.Now().UnixNano())
21+
defer func() {
22+
_ = exec.Command("docker", "image", "rm", "-f", imageRef).Run()
23+
}()
24+
25+
tempDir := t.TempDir()
26+
tarPath := filepath.Join(tempDir, "import.tar")
27+
28+
createCmd := exec.Command("docker", "run", "--name", sourceContainerName, "-d", "busybox:1.35.0", "sh", "-c", "sleep 300")
29+
if output, err := createCmd.CombinedOutput(); err != nil {
30+
t.Fatalf("failed to create container for export: %s: %s", err, string(output))
31+
}
32+
defer func() {
33+
_ = exec.Command("docker", "rm", "-f", sourceContainerName).Run()
34+
}()
35+
36+
createFileCmd := exec.Command("docker", "exec", sourceContainerName, "sh", "-c", "echo imported > /tmp/docker_import_action_file")
37+
if output, err := createFileCmd.CombinedOutput(); err != nil {
38+
t.Fatalf("failed to create file inside export container: %s: %s", err, string(output))
39+
}
40+
41+
exportFile, err := os.Create(tarPath)
42+
if err != nil {
43+
t.Fatalf("failed to create tar file: %s", err)
44+
}
45+
46+
exportCmd := exec.Command("docker", "export", sourceContainerName)
47+
exportCmd.Stdout = exportFile
48+
if err := exportCmd.Run(); err != nil {
49+
_ = exportFile.Close()
50+
t.Fatalf("failed to export container: %s", err)
51+
}
52+
if err := exportFile.Close(); err != nil {
53+
t.Fatalf("failed to close tar file: %s", err)
54+
}
55+
56+
resource.UnitTest(t, resource.TestCase{
57+
ProtoV6ProviderFactories: protoV6ProviderFactories(),
58+
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
59+
tfversion.SkipBelow(tfversion.Version1_14_0),
60+
},
61+
Steps: []resource.TestStep{
62+
{
63+
Config: fmt.Sprintf(`
64+
resource "docker_image" "busybox" {
65+
name = "busybox:1.35.0"
66+
keep_locally = true
67+
}
68+
69+
resource "docker_container" "trigger" {
70+
name = %q
71+
image = docker_image.busybox.image_id
72+
must_run = true
73+
command = ["sh", "-c", "sleep 300"]
74+
75+
lifecycle {
76+
action_trigger {
77+
events = [after_create]
78+
actions = [action.docker_image_import.import_export]
79+
}
80+
}
81+
}
82+
83+
action "docker_image_import" "import_export" {
84+
config {
85+
source = %q
86+
reference = %q
87+
message = "imported from docker export"
88+
changes = ["CMD [\"sh\"]"]
89+
platform = "linux/amd64"
90+
}
91+
}
92+
`, targetContainerName, tarPath, imageRef),
93+
94+
PostApplyFunc: func() {
95+
checkCmd := exec.Command("docker", "image", "inspect", imageRef)
96+
if output, err := checkCmd.CombinedOutput(); err != nil {
97+
t.Fatalf("expected imported image %q to exist: %s: %s", imageRef, err, string(output))
98+
}
99+
100+
runCmd := exec.Command("docker", "run", "--rm", imageRef, "sh", "-c", "test -f /tmp/docker_import_action_file")
101+
if output, err := runCmd.CombinedOutput(); err != nil {
102+
t.Fatalf("expected imported image %q to contain the exported file: %s: %s", imageRef, err, string(output))
103+
}
104+
},
105+
},
106+
},
107+
})
108+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"os"
10+
"strings"
11+
12+
"github.com/docker/docker/api/types/image"
13+
"github.com/hashicorp/terraform-plugin-framework/action"
14+
actionschema "github.com/hashicorp/terraform-plugin-framework/action/schema"
15+
"github.com/hashicorp/terraform-plugin-framework/types"
16+
)
17+
18+
type DockerImageImportAction struct {
19+
providerConfig *ProviderConfig
20+
}
21+
22+
type DockerImageImportActionModel struct {
23+
Source types.String `tfsdk:"source"`
24+
Reference types.String `tfsdk:"reference"`
25+
Message types.String `tfsdk:"message"`
26+
Changes types.List `tfsdk:"changes"`
27+
Platform types.String `tfsdk:"platform"`
28+
}
29+
30+
func (a *DockerImageImportAction) Metadata(ctx context.Context, req action.MetadataRequest, resp *action.MetadataResponse) {
31+
resp.TypeName = req.ProviderTypeName + "_image_import"
32+
}
33+
34+
func (a *DockerImageImportAction) Schema(ctx context.Context, req action.SchemaRequest, resp *action.SchemaResponse) {
35+
resp.Schema = actionschema.Schema{
36+
MarkdownDescription: "Import a tar archive or URL as a Docker image, similar to `docker image import`.",
37+
Attributes: map[string]actionschema.Attribute{
38+
"source": actionschema.StringAttribute{
39+
MarkdownDescription: "Path to a local tar archive or an http(s) URL containing the filesystem to import.",
40+
Required: true,
41+
},
42+
"reference": actionschema.StringAttribute{
43+
MarkdownDescription: "Image name and optional tag to apply to the imported image, for example `my-image:latest`.",
44+
Required: true,
45+
},
46+
"message": actionschema.StringAttribute{
47+
MarkdownDescription: "Optional message to store with the imported image.",
48+
Optional: true,
49+
},
50+
"changes": actionschema.ListAttribute{
51+
MarkdownDescription: "Raw Dockerfile instructions to apply to the imported image.",
52+
Optional: true,
53+
ElementType: types.StringType,
54+
},
55+
"platform": actionschema.StringAttribute{
56+
MarkdownDescription: "Platform to assign to the imported image.",
57+
Optional: true,
58+
},
59+
},
60+
}
61+
}
62+
63+
func (a *DockerImageImportAction) Configure(ctx context.Context, req action.ConfigureRequest, resp *action.ConfigureResponse) {
64+
if req.ProviderData == nil {
65+
return
66+
}
67+
68+
providerConfig, ok := req.ProviderData.(*ProviderConfig)
69+
if !ok {
70+
resp.Diagnostics.AddError(
71+
"Unexpected Provider Configure Type",
72+
fmt.Sprintf("Expected *ProviderConfig, got: %T. Please report this issue to the provider developers.", req.ProviderData),
73+
)
74+
return
75+
}
76+
77+
a.providerConfig = providerConfig
78+
}
79+
80+
func (a *DockerImageImportAction) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) {
81+
if a.providerConfig == nil {
82+
resp.Diagnostics.AddError("Provider not configured", "The provider configuration is unavailable for docker image import action invocation.")
83+
return
84+
}
85+
86+
var config DockerImageImportActionModel
87+
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
88+
if resp.Diagnostics.HasError() {
89+
return
90+
}
91+
92+
if config.Source.IsNull() || config.Source.IsUnknown() || strings.TrimSpace(config.Source.ValueString()) == "" {
93+
resp.Diagnostics.AddError("Invalid source", "Attribute `source` must be a non-empty path or URL.")
94+
return
95+
}
96+
97+
if config.Reference.IsNull() || config.Reference.IsUnknown() || strings.TrimSpace(config.Reference.ValueString()) == "" {
98+
resp.Diagnostics.AddError("Invalid reference", "Attribute `reference` must be a non-empty image name.")
99+
return
100+
}
101+
102+
sourceValue := strings.TrimSpace(config.Source.ValueString())
103+
referenceValue := strings.TrimSpace(config.Reference.ValueString())
104+
105+
var changes []string
106+
if !config.Changes.IsNull() && !config.Changes.IsUnknown() {
107+
resp.Diagnostics.Append(config.Changes.ElementsAs(ctx, &changes, false)...)
108+
if resp.Diagnostics.HasError() {
109+
return
110+
}
111+
}
112+
113+
sourceReader, err := openImageImportSource(ctx, sourceValue)
114+
if err != nil {
115+
resp.Diagnostics.AddError("Invalid import source", err.Error())
116+
return
117+
}
118+
defer sourceReader.Close() // nolint:errcheck
119+
120+
client, err := a.providerConfig.MakeClient(ctx, nil)
121+
if err != nil {
122+
resp.Diagnostics.AddError("Docker client error", fmt.Sprintf("Unable to create Docker client: %s", err))
123+
return
124+
}
125+
126+
options := image.ImportOptions{
127+
Message: config.Message.ValueString(),
128+
Changes: changes,
129+
Platform: config.Platform.ValueString(),
130+
}
131+
132+
responseBody, err := client.ImageImport(ctx, image.ImportSource{
133+
Source: sourceReader,
134+
SourceName: "-",
135+
}, referenceValue, options)
136+
if err != nil {
137+
resp.Diagnostics.AddError("Docker image import failed", err.Error())
138+
return
139+
}
140+
defer responseBody.Close() // nolint:errcheck
141+
142+
importOutput, err := io.ReadAll(responseBody)
143+
if err != nil {
144+
resp.Diagnostics.AddError("Docker image import output error", err.Error())
145+
return
146+
}
147+
148+
if resp.SendProgress != nil {
149+
for _, line := range strings.Split(strings.TrimSpace(string(importOutput)), "\n") {
150+
line = strings.TrimSpace(line)
151+
if line != "" {
152+
resp.SendProgress(action.InvokeProgressEvent{Message: line})
153+
}
154+
}
155+
}
156+
157+
inspectedImage, err := client.ImageInspect(ctx, referenceValue)
158+
if err != nil {
159+
resp.Diagnostics.AddError("Docker image inspect failed", err.Error())
160+
return
161+
}
162+
163+
if resp.SendProgress != nil {
164+
resp.SendProgress(action.InvokeProgressEvent{Message: fmt.Sprintf("imported_image_id=%s", inspectedImage.ID)})
165+
}
166+
}
167+
168+
func openImageImportSource(ctx context.Context, source string) (io.ReadCloser, error) {
169+
parsedURL, err := url.Parse(source)
170+
if err == nil && parsedURL.Scheme != "" {
171+
switch parsedURL.Scheme {
172+
case "http", "https":
173+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
174+
if err != nil {
175+
return nil, err
176+
}
177+
178+
resp, err := http.DefaultClient.Do(req)
179+
if err != nil {
180+
return nil, err
181+
}
182+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
183+
defer resp.Body.Close() // nolint:errcheck
184+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
185+
return nil, fmt.Errorf("unexpected HTTP status %s: %s", resp.Status, strings.TrimSpace(string(body)))
186+
}
187+
return resp.Body, nil
188+
case "file":
189+
path := parsedURL.Path
190+
if parsedURL.Host != "" {
191+
path = "//" + parsedURL.Host + path
192+
}
193+
return os.Open(path)
194+
}
195+
}
196+
197+
return os.Open(source)
198+
}

0 commit comments

Comments
 (0)