Skip to content

Commit b872da9

Browse files
authoredNov 28, 2024··
feat(worker): auto pull images if not found (#200)
This commit provides users with the ability to pull the docker images if they are not found locally.
1 parent 28e0095 commit b872da9

File tree

7 files changed

+988
-46
lines changed

7 files changed

+988
-46
lines changed
 

‎go.mod

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ require (
1010
github.com/getkin/kin-openapi v0.128.0
1111
github.com/go-chi/chi/v5 v5.1.0
1212
github.com/oapi-codegen/runtime v1.1.1
13+
github.com/opencontainers/image-spec v1.1.0
1314
github.com/stretchr/testify v1.9.0
1415
github.com/vincent-petithory/dataurl v1.0.0
1516
)
1617

1718
require (
19+
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
1820
github.com/Microsoft/go-winio v0.6.2 // indirect
1921
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
2022
github.com/containerd/log v0.1.0 // indirect
@@ -36,7 +38,6 @@ require (
3638
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
3739
github.com/morikuni/aec v1.0.0 // indirect
3840
github.com/opencontainers/go-digest v1.0.0 // indirect
39-
github.com/opencontainers/image-spec v1.1.0 // indirect
4041
github.com/perimeterx/marshmallow v1.1.5 // indirect
4142
github.com/pkg/errors v0.9.1 // indirect
4243
github.com/pmezard/go-difflib v1.0.0 // indirect

‎go.sum

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
2-
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
1+
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
2+
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
33
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
44
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
55
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
@@ -10,6 +10,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
1010
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
1111
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
1212
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
13+
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
14+
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
1315
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1416
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1517
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -134,6 +136,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
134136
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
135137
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
136138
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
139+
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
137140
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
138141
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
139142
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

‎worker/b64_test.go

+53-16
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,75 @@ package worker
33
import (
44
"bytes"
55
"encoding/base64"
6+
"image"
7+
"image/color"
8+
"image/png"
69
"os"
710
"testing"
811

912
"github.com/stretchr/testify/require"
1013
)
1114

1215
func TestReadImageB64DataUrl(t *testing.T) {
13-
// Create a sample PNG image and encode it as a data URL
14-
imgData := []byte{
15-
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG header
16-
// ... (rest of the PNG data)
16+
tests := []struct {
17+
name string
18+
dataURL string
19+
expectError bool
20+
}{
21+
{
22+
name: "Valid PNG Image",
23+
dataURL: func() string {
24+
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
25+
img.Set(0, 0, color.RGBA{255, 0, 0, 255}) // Set a single red pixel
26+
var imgBuf bytes.Buffer
27+
err := png.Encode(&imgBuf, img)
28+
require.NoError(t, err)
29+
30+
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBuf.Bytes())
31+
}(),
32+
expectError: false,
33+
},
34+
{
35+
name: "Unsupported Image Format",
36+
dataURL: "data:image/bmp;base64," + base64.StdEncoding.EncodeToString([]byte{
37+
0x42, 0x4D, // BMP header
38+
// ... (rest of the BMP data)
39+
}),
40+
expectError: true,
41+
},
42+
{
43+
name: "Invalid Data URL",
44+
dataURL: "invalid-data-url",
45+
expectError: true,
46+
},
1747
}
18-
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgData)
1948

20-
var buf bytes.Buffer
21-
err := ReadImageB64DataUrl(dataURL, &buf)
22-
require.NoError(t, err)
23-
require.NotEmpty(t, buf.Bytes())
49+
for _, tt := range tests {
50+
t.Run(tt.name, func(t *testing.T) {
51+
var buf bytes.Buffer
52+
err := ReadImageB64DataUrl(tt.dataURL, &buf)
53+
if tt.expectError {
54+
require.Error(t, err)
55+
} else {
56+
require.NoError(t, err)
57+
require.NotEmpty(t, buf.Bytes())
58+
}
59+
})
60+
}
2461
}
2562

2663
func TestSaveImageB64DataUrl(t *testing.T) {
27-
// Create a sample PNG image and encode it as a data URL
28-
imgData := []byte{
29-
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG header
30-
// ... (rest of the PNG data)
31-
}
32-
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgData)
64+
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
65+
img.Set(0, 0, color.RGBA{255, 0, 0, 255}) // Set a single red pixel
66+
var imgBuf bytes.Buffer
67+
err := png.Encode(&imgBuf, img)
68+
require.NoError(t, err)
69+
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBuf.Bytes())
3370

3471
outputPath := "test_output.png"
3572
defer os.Remove(outputPath)
3673

37-
err := SaveImageB64DataUrl(dataURL, outputPath)
74+
err = SaveImageB64DataUrl(dataURL, outputPath)
3875
require.NoError(t, err)
3976

4077
// Verify that the file was created and is not empty

‎worker/container.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ type RunnerContainerConfig struct {
4242
containerTimeout time.Duration
4343
}
4444

45+
// Create global references to functions to allow for mocking in tests.
46+
var runnerWaitUntilReadyFunc = runnerWaitUntilReady
47+
4548
func NewRunnerContainer(ctx context.Context, cfg RunnerContainerConfig, name string) (*RunnerContainer, error) {
4649
// Ensure that timeout is set to a non-zero value.
4750
timeout := cfg.containerTimeout
@@ -66,7 +69,7 @@ func NewRunnerContainer(ctx context.Context, cfg RunnerContainerConfig, name str
6669

6770
cctx, cancel := context.WithTimeout(ctx, cfg.containerTimeout)
6871
defer cancel()
69-
if err := runnerWaitUntilReady(cctx, client, pollingInterval); err != nil {
72+
if err := runnerWaitUntilReadyFunc(cctx, client, pollingInterval); err != nil {
7073
return nil, err
7174
}
7275

‎worker/docker.go

+126-25
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,27 @@ package worker
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
8+
"io"
79
"log/slog"
810
"strings"
911
"sync"
1012
"time"
1113

1214
"github.com/docker/cli/opts"
15+
"github.com/docker/docker/api/types"
1316
"github.com/docker/docker/api/types/container"
1417
"github.com/docker/docker/api/types/filters"
18+
"github.com/docker/docker/api/types/image"
1519
"github.com/docker/docker/api/types/mount"
20+
"github.com/docker/docker/api/types/network"
1621
docker "github.com/docker/docker/client"
1722
"github.com/docker/docker/errdefs"
23+
"github.com/docker/docker/pkg/jsonmessage"
1824
"github.com/docker/go-connections/nat"
25+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
1926
)
2027

2128
const containerModelDir = "/models"
@@ -27,7 +34,8 @@ const optFlagsContainerTimeout = 5 * time.Minute
2734
const containerRemoveTimeout = 30 * time.Second
2835
const containerCreatorLabel = "creator"
2936
const containerCreator = "ai-worker"
30-
const containerWatchInterval = 10 * time.Second
37+
38+
var containerWatchInterval = 10 * time.Second
3139

3240
// This only works right now on a single GPU because if there is another container
3341
// using the GPU we stop it so we don't have to worry about having enough ports
@@ -57,41 +65,76 @@ var livePipelineToImage = map[string]string{
5765
"noop": "livepeer/ai-runner:live-app-noop",
5866
}
5967

68+
// DockerClient is an interface for the Docker client, allowing for mocking in tests.
69+
// NOTE: ensure any docker.Client methods used in this package are added.
70+
type DockerClient interface {
71+
ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, containerName string) (container.CreateResponse, error)
72+
ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error)
73+
ContainerList(ctx context.Context, options container.ListOptions) ([]types.Container, error)
74+
ContainerRemove(ctx context.Context, containerID string, options container.RemoveOptions) error
75+
ContainerStart(ctx context.Context, containerID string, options container.StartOptions) error
76+
ContainerStop(ctx context.Context, containerID string, options container.StopOptions) error
77+
ImageInspectWithRaw(ctx context.Context, imageID string) (types.ImageInspect, []byte, error)
78+
ImagePull(ctx context.Context, ref string, options image.PullOptions) (io.ReadCloser, error)
79+
}
80+
81+
// Compile-time assertion to ensure docker.Client implements DockerClient.
82+
var _ DockerClient = (*docker.Client)(nil)
83+
84+
// Create global references to functions to allow for mocking in tests.
85+
var dockerWaitUntilRunningFunc = dockerWaitUntilRunning
86+
6087
type DockerManager struct {
6188
defaultImage string
6289
gpus []string
6390
modelDir string
6491

65-
dockerClient *docker.Client
92+
dockerClient DockerClient
6693
// gpu ID => container name
6794
gpuContainers map[string]string
6895
// container name => container
6996
containers map[string]*RunnerContainer
7097
mu *sync.Mutex
7198
}
7299

73-
func NewDockerManager(defaultImage string, gpus []string, modelDir string) (*DockerManager, error) {
74-
dockerClient, err := docker.NewClientWithOpts(docker.FromEnv, docker.WithAPIVersionNegotiation())
75-
if err != nil {
76-
return nil, err
77-
}
78-
100+
func NewDockerManager(defaultImage string, gpus []string, modelDir string, client DockerClient) (*DockerManager, error) {
79101
ctx, cancel := context.WithTimeout(context.Background(), containerTimeout)
80-
if err := removeExistingContainers(ctx, dockerClient); err != nil {
102+
if err := removeExistingContainers(ctx, client); err != nil {
81103
cancel()
82104
return nil, err
83105
}
84106
cancel()
85107

86-
return &DockerManager{
108+
manager := &DockerManager{
87109
defaultImage: defaultImage,
88110
gpus: gpus,
89111
modelDir: modelDir,
90-
dockerClient: dockerClient,
112+
dockerClient: client,
91113
gpuContainers: make(map[string]string),
92114
containers: make(map[string]*RunnerContainer),
93115
mu: &sync.Mutex{},
94-
}, nil
116+
}
117+
118+
return manager, nil
119+
}
120+
121+
// EnsureImageAvailable ensures the container image is available locally for the given pipeline and model ID.
122+
func (m *DockerManager) EnsureImageAvailable(ctx context.Context, pipeline string, modelID string) error {
123+
imageName, err := m.getContainerImageName(pipeline, modelID)
124+
if err != nil {
125+
return err
126+
}
127+
128+
// Pull the image if it is not available locally.
129+
if !m.isImageAvailable(ctx, pipeline, modelID) {
130+
slog.Info(fmt.Sprintf("Pulling image for pipeline %s and modelID %s: %s", pipeline, modelID, imageName))
131+
err = m.pullImage(ctx, imageName)
132+
if err != nil {
133+
return err
134+
}
135+
}
136+
137+
return nil
95138
}
96139

97140
func (m *DockerManager) Warm(ctx context.Context, pipeline string, modelID string, optimizationFlags OptimizationFlags) error {
@@ -157,6 +200,24 @@ func (m *DockerManager) returnContainer(rc *RunnerContainer) {
157200
m.containers[rc.Name] = rc
158201
}
159202

203+
// getContainerImageName returns the image name for the given pipeline and model ID.
204+
// Returns an error if the image is not found for "live-video-to-video".
205+
func (m *DockerManager) getContainerImageName(pipeline, modelID string) (string, error) {
206+
if pipeline == "live-video-to-video" {
207+
// We currently use the model ID as the live pipeline name for legacy reasons.
208+
if image, ok := livePipelineToImage[modelID]; ok {
209+
return image, nil
210+
}
211+
return "", fmt.Errorf("no container image found for live pipeline %s", modelID)
212+
}
213+
214+
if image, ok := pipelineToImage[pipeline]; ok {
215+
return image, nil
216+
}
217+
218+
return m.defaultImage, nil
219+
}
220+
160221
// HasCapacity checks if an unused managed container exists or if a GPU is available for a new container.
161222
func (m *DockerManager) HasCapacity(ctx context.Context, pipeline, modelID string) bool {
162223
m.mu.Lock()
@@ -169,11 +230,57 @@ func (m *DockerManager) HasCapacity(ctx context.Context, pipeline, modelID strin
169230
}
170231
}
171232

233+
// TODO: This can be removed if we optimize the selection algorithm.
234+
// Currently, using CreateContainer errors only can cause orchestrator reselection.
235+
if !m.isImageAvailable(ctx, pipeline, modelID) {
236+
return false
237+
}
238+
172239
// Check for available GPU to allocate for a new container for the requested model.
173240
_, err := m.allocGPU(ctx)
174241
return err == nil
175242
}
176243

244+
// isImageAvailable checks if the specified image is available locally.
245+
func (m *DockerManager) isImageAvailable(ctx context.Context, pipeline string, modelID string) bool {
246+
imageName, err := m.getContainerImageName(pipeline, modelID)
247+
if err != nil {
248+
slog.Error(err.Error())
249+
return false
250+
}
251+
252+
_, _, err = m.dockerClient.ImageInspectWithRaw(ctx, imageName)
253+
if err != nil {
254+
slog.Error(fmt.Sprintf("Image for pipeline %s and modelID %s is not available locally: %s", pipeline, modelID, imageName))
255+
}
256+
return err == nil
257+
}
258+
259+
// pullImage pulls the specified image from the registry.
260+
func (m *DockerManager) pullImage(ctx context.Context, imageName string) error {
261+
reader, err := m.dockerClient.ImagePull(ctx, imageName, image.PullOptions{})
262+
if err != nil {
263+
return fmt.Errorf("failed to pull image: %w", err)
264+
}
265+
defer reader.Close()
266+
267+
// Display progress messages from ImagePull reader.
268+
decoder := json.NewDecoder(reader)
269+
for {
270+
var progress jsonmessage.JSONMessage
271+
if err := decoder.Decode(&progress); err == io.EOF {
272+
break
273+
} else if err != nil {
274+
return fmt.Errorf("error decoding progress message: %w", err)
275+
}
276+
if progress.Status != "" && progress.Progress != nil {
277+
slog.Info(fmt.Sprintf("%s: %s", progress.Status, progress.Progress.String()))
278+
}
279+
}
280+
281+
return nil
282+
}
283+
177284
func (m *DockerManager) createContainer(ctx context.Context, pipeline string, modelID string, keepWarm bool, optimizationFlags OptimizationFlags) (*RunnerContainer, error) {
178285
gpu, err := m.allocGPU(ctx)
179286
if err != nil {
@@ -183,15 +290,9 @@ func (m *DockerManager) createContainer(ctx context.Context, pipeline string, mo
183290
// NOTE: We currently allow only one container per GPU for each pipeline.
184291
containerHostPort := containerHostPorts[pipeline][:3] + gpu
185292
containerName := dockerContainerName(pipeline, modelID, containerHostPort)
186-
containerImage := m.defaultImage
187-
if pipelineSpecificImage, ok := pipelineToImage[pipeline]; ok {
188-
containerImage = pipelineSpecificImage
189-
} else if pipeline == "live-video-to-video" {
190-
// We currently use the model ID as the live pipeline name for legacy reasons
191-
containerImage = livePipelineToImage[modelID]
192-
if containerImage == "" {
193-
return nil, fmt.Errorf("no container image found for live pipeline %s", modelID)
194-
}
293+
containerImage, err := m.getContainerImageName(pipeline, modelID)
294+
if err != nil {
295+
return nil, err
195296
}
196297

197298
slog.Info("Starting managed container", slog.String("gpu", gpu), slog.String("name", containerName), slog.String("modelID", modelID), slog.String("containerImage", containerImage))
@@ -258,7 +359,7 @@ func (m *DockerManager) createContainer(ctx context.Context, pipeline string, mo
258359
cancel()
259360

260361
cctx, cancel = context.WithTimeout(ctx, containerTimeout)
261-
if err := dockerWaitUntilRunning(cctx, m.dockerClient, resp.ID, pollingInterval); err != nil {
362+
if err := dockerWaitUntilRunningFunc(cctx, m.dockerClient, resp.ID, pollingInterval); err != nil {
262363
cancel()
263364
dockerRemoveContainer(m.dockerClient, resp.ID)
264365
return nil, err
@@ -390,7 +491,7 @@ func (m *DockerManager) watchContainer(rc *RunnerContainer, borrowCtx context.Co
390491
}
391492
}
392493

393-
func removeExistingContainers(ctx context.Context, client *docker.Client) error {
494+
func removeExistingContainers(ctx context.Context, client DockerClient) error {
394495
filters := filters.NewArgs(filters.Arg("label", containerCreatorLabel+"="+containerCreator))
395496
containers, err := client.ContainerList(ctx, container.ListOptions{All: true, Filters: filters})
396497
if err != nil {
@@ -416,7 +517,7 @@ func dockerContainerName(pipeline string, modelID string, suffix ...string) stri
416517
return fmt.Sprintf("%s_%s", pipeline, sanitizedModelID)
417518
}
418519

419-
func dockerRemoveContainer(client *docker.Client, containerID string) error {
520+
func dockerRemoveContainer(client DockerClient, containerID string) error {
420521
ctx, cancel := context.WithTimeout(context.Background(), containerRemoveTimeout)
421522
defer cancel()
422523

@@ -449,7 +550,7 @@ func dockerRemoveContainer(client *docker.Client, containerID string) error {
449550
}
450551
}
451552

452-
func dockerWaitUntilRunning(ctx context.Context, client *docker.Client, containerID string, pollingInterval time.Duration) error {
553+
func dockerWaitUntilRunning(ctx context.Context, client DockerClient, containerID string, pollingInterval time.Duration) error {
453554
ticker := time.NewTicker(pollingInterval)
454555
defer ticker.Stop()
455556

‎worker/docker_test.go

+786
Large diffs are not rendered by default.

‎worker/worker.go

+12-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"strconv"
1313
"strings"
1414
"sync"
15+
16+
docker "github.com/docker/docker/client"
1517
)
1618

1719
// EnvValue unmarshals JSON booleans as strings for compatibility with env variables.
@@ -50,7 +52,12 @@ type Worker struct {
5052
}
5153

5254
func NewWorker(defaultImage string, gpus []string, modelDir string) (*Worker, error) {
53-
manager, err := NewDockerManager(defaultImage, gpus, modelDir)
55+
dockerClient, err := docker.NewClientWithOpts(docker.FromEnv, docker.WithAPIVersionNegotiation())
56+
if err != nil {
57+
return nil, err
58+
}
59+
60+
manager, err := NewDockerManager(defaultImage, gpus, modelDir, dockerClient)
5461
if err != nil {
5562
return nil, err
5663
}
@@ -652,6 +659,10 @@ func (w *Worker) LiveVideoToVideo(ctx context.Context, req GenLiveVideoToVideoJS
652659
return resp.JSON200, nil
653660
}
654661

662+
func (w *Worker) EnsureImageAvailable(ctx context.Context, pipeline string, modelID string) error {
663+
return w.manager.EnsureImageAvailable(ctx, pipeline, modelID)
664+
}
665+
655666
func (w *Worker) Warm(ctx context.Context, pipeline string, modelID string, endpoint RunnerEndpoint, optimizationFlags OptimizationFlags) error {
656667
if endpoint.URL == "" {
657668
return w.manager.Warm(ctx, pipeline, modelID, optimizationFlags)

0 commit comments

Comments
 (0)
Please sign in to comment.