// Copyright 2024 Matthew Rich . All rights reserved. // Container resource package resource import ( "os" "context" "fmt" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/image" "github.com/docker/docker/client" "log/slog" "net/url" "strings" "encoding/json" "encoding/base64" "io" "gitea.rosskeen.house/rosskeen.house/machine" "decl/internal/codec" "decl/internal/data" "decl/internal/folio" "decl/internal/transport" "gopkg.in/yaml.v3" "decl/internal/tempdir" "archive/tar" _ "strconv" ) var ContextTempDir tempdir.Path = "jx_containerimage_context" const ( ContainerImageTypeName TypeName = "container-image" ) type ContainerProgressDetail struct { ProgressMessage string `json:"message,omitempty" yaml:"message,omitempty"` } type ContainerLogStatus struct { Status string `json:"status,omitempty" yaml:"status,omitempty"` ProgressDetail ContainerProgressDetail `json:"progressDetail,omitempty" yaml:"progressDetail,omitempty"` Id string `json:"id,omitempty" yaml:"id,omitempty"` } type ContainerLogStream struct { Stream string `json:"stream,omitempty" yaml:"stream,omitempty"` } type ContainerLog struct { *ContainerLogStream `json:",inline" yaml:",inline"` *ContainerLogStatus `json:",inline" yaml:",inline"` *ContainerError `json:",inline" yaml:",inline"` } type ContainerErrorDetail struct { ErrorMessage string `json:"message" yaml:"message"` } type ContainerError struct { Detail ContainerErrorDetail `json:"errorDetail" yaml:"errorDetail"` Error string `json:"error" yaml:"error"` } type ContainerImageClient interface { ImagePull(ctx context.Context, refStr string, options image.PullOptions) (io.ReadCloser, error) ImagePush(ctx context.Context, image string, options image.PushOptions) (io.ReadCloser, error) ImageInspectWithRaw(ctx context.Context, imageID string) (types.ImageInspect, []byte, error) ImageRemove(ctx context.Context, imageID string, options image.RemoveOptions) ([]image.DeleteResponse, error) ImageBuild(ctx context.Context, buildContext io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) Close() error } type ContainerImage struct { *Common `yaml:",inline" json:",inline"` stater machine.Stater `yaml:"-" json:"-"` Id string `json:"id,omitempty" yaml:"id,omitempty"` Name string `json:"name" yaml:"name"` Created string `json:"created,omitempty" yaml:"created,omitempty"` Architecture string `json:"architecture,omitempty" yaml:"architecture,omitempty"` Variant string `json:"variant,omitempty" yaml:"variant,omitempty"` OS string `json:"os" yaml:"os"` Size int64 `json:"size" yaml:"size"` Author string `json:"author,omitempty" yaml:"author,omitempty"` Comment string `json:"comment,omitempty" yaml:"comment,omitempty"` Dockerfile string `json:"dockerfile,omitempty" yaml:"dockerfile,omitempty"` DockerfileRef folio.ResourceReference `json:"dockerfileref,omitempty" yaml:"dockerfileref,omitempty"` ContextRef folio.ResourceReference `json:"contextref,omitempty" yaml:"contextref,omitempty"` InjectJX bool `json:"injectjx,omitempty" yaml:"injectjx,omitempty"` PushImage bool `json:"push,omitempty" yaml:"push,omitempty"` Output []ContainerLog `json:"output,omitempty" yaml:"output,omitempty"` outputWriter strings.Builder `json:"-" yaml:"-"` apiClient ContainerImageClient Resources data.ResourceMapper `json:"-" yaml:"-"` contextDocument data.Document `json:"-" yaml:"-"` ConverterTypes data.TypesRegistry[data.Converter] `json:"-" yaml:"-"` imageStat types.ImageInspect `json:"-" yaml:"-"` } func init() { folio.DocumentRegistry.ResourceTypes.Register([]string{"container-image"}, func(u *url.URL) (c data.Resource) { c = NewContainerImage(nil) if u != nil { if err := folio.CastParsedURI(u).ConstructResource(c); err != nil { panic(err) } } return }) } func NewContainerImage(containerClientApi ContainerImageClient) *ContainerImage { var apiClient ContainerImageClient = containerClientApi if apiClient == nil { var err error apiClient, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { panic(err) } } c := &ContainerImage{ Common: NewCommon(ContainerImageTypeName, true), apiClient: apiClient, InjectJX: true, PushImage: false, ConverterTypes: folio.DocumentRegistry.ConverterTypes, } c.Common.NormalizePath = c.NormalizePath return c } func (c *ContainerImage) Init(u data.URIParser) (err error) { if u == nil { u = folio.URI(c.URI()).Parse() } err = c.SetParsedURI(u) c.Name = ContainerImageNameFromURI(u.URL()) return } func (c *ContainerImage) RegistryAuthConfig() (authConfig registry.AuthConfig, err error) { if c.Common.config != nil { var configValue any if configValue, err = c.Common.config.GetValue("repo_username"); err != nil { return } else { authConfig.Username = configValue.(string) } if configValue, err = c.Common.config.GetValue("repo_password"); err != nil { return } else { authConfig.Password = configValue.(string) } if configValue, err = c.Common.config.GetValue("repo_server"); err != nil { return authConfig, nil } else { authConfig.ServerAddress = configValue.(string) } } return } /* func (c *ContainerImage) RegistryLogin(context context.Context) (token string, err error) { var authConfig registry.AuthConfig authConfig, err = c.RegistryAuthConfig() if authResponse, loginErr := c.apiClient.RegistryLogin(context, authConfig); loginErr == nil { return authResponse.IdentityToken, err } return } */ func (c *ContainerImage) RegistryAuth() (string, error) { if authConfig, err := c.RegistryAuthConfig(); err == nil { if encodedJSON, jsonErr := json.Marshal(authConfig); jsonErr == nil { slog.Info("ContainerImage.RegistryAuth()", "auth", authConfig, "encoded", encodedJSON, "error", jsonErr) return base64.URLEncoding.EncodeToString(encodedJSON), nil } else { return "", jsonErr } } else { slog.Info("ContainerImage.RegistryAuth()", "error", err) return "", err } } func (c *ContainerImage) NormalizePath() error { return nil } func (c *ContainerImage) SetResourceMapper(resources data.ResourceMapper) { c.Resources = resources } func (c *ContainerImage) Clone() data.Resource { return &ContainerImage { Common: c.Common, Id: c.Id, Name: c.Name, Created: c.Created, Architecture: c.Architecture, Variant: c.Variant, OS: c.OS, Size: c.Size, Author: c.Author, Comment: c.Comment, InjectJX: c.InjectJX, apiClient: c.apiClient, contextDocument: c.contextDocument, } } func (c *ContainerImage) StateMachine() machine.Stater { if c.stater == nil { c.stater = StorageMachine(c) } return c.stater } func URIFromContainerImageName(imageName string) string { var host, namespace, repo string elements := strings.Split(imageName, "/") switch len(elements) { case 1: repo = elements[0] case 2: namespace = elements[0] repo = elements[1] case 3: host = elements[0] namespace = elements[1] repo = elements[2] } if namespace == "" { return fmt.Sprintf("%s://%s/%s", ContainerImageTypeName, host, repo) } return fmt.Sprintf("%s://%s/%s", ContainerImageTypeName, host, strings.Join([]string{namespace, repo}, "/")) } // Reconstruct the image name from a given parsed URL func ContainerImageNameFromURI(u *url.URL) string { var host string = u.Hostname() // var host, namespace, repo string elements := strings.FieldsFunc(u.RequestURI(), func(c rune) bool { return c == '/' }) slog.Info("ContainerImageNameFromURI", "url", u, "elements", elements) /* switch len(elements) { case 1: repo = elements[0] case 2: namespace = elements[0] repo = elements[1] } */ if host == "" { return strings.Join(elements, "/") } return fmt.Sprintf("%s/%s", host, strings.Join(elements, "/")) } func (c *ContainerImage) URI() string { return URIFromContainerImageName(c.Name) } func (c *ContainerImage) JSON() ([]byte, error) { return json.Marshal(c) } func (c *ContainerImage) Validate() error { return fmt.Errorf("failed") } func (c *ContainerImage) Notify(m *machine.EventMessage) { slog.Info("ContainerImage.Notify()", "event", m, "state", c.State) ctx := context.Background() switch m.On { case machine.ENTERSTATEEVENT: switch m.Dest { case "start_stat": if statErr := c.ReadStat(ctx); statErr == nil { if triggerErr := c.StateMachine().Trigger("exists"); triggerErr == nil { return } } else { if triggerErr := c.StateMachine().Trigger("notexists"); triggerErr == nil { return } } case "start_read": if _,readErr := c.Read(ctx); readErr == nil { if triggerErr := c.stater.Trigger("state_read"); triggerErr == nil { return } else { c.State = "absent" panic(triggerErr) } } else { c.State = "absent" panic(readErr) } case "start_create": if createErr := c.Create(ctx); createErr == nil { if triggerErr := c.stater.Trigger("created"); triggerErr == nil { slog.Info("ContainerImage.Notify()", "created", c, "error", triggerErr) return } else { slog.Info("ContainerImage.Notify()", "created", c, "error", triggerErr) c.State = "absent" panic(triggerErr) } } else { c.State = "absent" panic(createErr) } case "start_update": if createErr := c.Update(ctx); createErr == nil { if triggerErr := c.stater.Trigger("updated"); triggerErr == nil { return } else { c.State = "absent" } } else { c.State = "absent" panic(createErr) } case "start_delete": if deleteErr := c.Delete(ctx); deleteErr == nil { if triggerErr := c.StateMachine().Trigger("deleted"); triggerErr == nil { return } else { c.State = "present" panic(triggerErr) } } else { c.State = "present" panic(deleteErr) } case "present", "created", "read": c.State = "present" case "absent": c.State = "absent" } case machine.EXITSTATEEVENT: } } func (c *ContainerImage) Apply() error { ctx := context.Background() switch c.State { case "absent": return c.Delete(ctx) case "present": return c.Create(ctx) } return nil } func (c *ContainerImage) Load(docData []byte, f codec.Format) (err error) { err = f.StringDecoder(string(docData)).Decode(c) return } func (c *ContainerImage) LoadReader(r io.ReadCloser, f codec.Format) (err error) { err = f.Decoder(r).Decode(c) return } func (c *ContainerImage) LoadString(docData string, f codec.Format) (err error) { err = f.StringDecoder(docData).Decode(c) return } func (c *ContainerImage) LoadDecl(yamlResourceDeclaration string) error { return c.LoadString(yamlResourceDeclaration, codec.FormatYaml) } func (c *ContainerImage) SetContextDocument(document data.Document) { c.contextDocument = document } func (c *ContainerImage) ContextDocument() (document data.Document, err error) { var sourceRef data.Resource if v, ok := folio.DocumentRegistry.GetDocument(folio.URI(c.ContextRef)); ok { return v, nil } slog.Info("ContainerImage.ContextDocument()", "contextref", c.ContextRef, "resources", c.Resources) if sourceRef = c.ContextRef.Dereference(c.Resources); sourceRef == nil { if sourceRef, err = folio.URI(c.ContextRef).NewResource(nil); err != nil { return } } if sourceRef != nil { slog.Info("ContainerImage.ContextDocument() - Dereference", "contextref", c.ContextRef, "ref", sourceRef, "uri", sourceRef.URI()) var extractor data.Converter if extractor, err = c.ConverterTypes.New(sourceRef.URI()); err == nil { if v, ok := extractor.(data.DirectoryConverter); ok { v.SetRelative(true) } slog.Info("ContainerImage.ContextDocument() - Converter", "extractor", extractor, "sourceref", sourceRef.URI(), "type", extractor.Type()) if document, err = extractor.Extract(sourceRef, nil); err != nil { return } } } else { err = ErrUnableToFindResource return } if err = ContextTempDir.Create(); err != nil { return } if ! ContextTempDir.ValidPath() { err = fmt.Errorf("Invalid temp dir path: %s", ContextTempDir) return } //defer ContextTempDir.Remove() var dockerfileResource data.Resource var tarDockerfile folio.URI = folio.URI("file://Dockerfile") if dockerfileDecl, ok := document.Get(string(tarDockerfile)); ok { dockerfileResource = dockerfileDecl.(data.Declaration).Resource() } else { if dockerfileResource, err = document.(*folio.Document).NewResourceFromURI(tarDockerfile); err != nil { return } } if len(c.Dockerfile) > 0 { dockerfileResource.(data.FileResource).SetContentSourceRef("") err = dockerfileResource.(data.FileResource).SetContent(strings.NewReader(c.Dockerfile)) slog.Info("ContainerImage.ContextDocument()", "dockerfile", dockerfileResource) } else if len(c.DockerfileRef) > 0 { dockerfileResource.(data.FileResource).SetContentSourceRef(string(c.DockerfileRef)) } if c.InjectJX { var jxResource data.Resource var jxURI folio.URI jxURI, err = JXPath() slog.Info("ContainerImage.ContextDocument()", "jx", jxURI, "error", err) if jxResource, err = document.(*folio.Document).NewResource("file://jx"); err != nil { return } jxResource.(data.FileResource).SetContentSourceRef(string(jxURI)) slog.Info("ContainerImage.ContextDocument()", "jxResource", jxResource) /* fi, fiErr := data.FileInfoGetter(jxReader).Stat() if fiErr != nil { err = fiErr return } jxResource.SetFileInfo(fi) */ } return } // creates tmp context archive file from source context archive reader func (c *ContainerImage) CreateContextArchive(reader io.ReadCloser) (contextTempFile folio.URI, err error) { if err = ContextTempDir.Create(); err != nil { return } if ! ContextTempDir.ValidPath() { err = fmt.Errorf("Invalid temp dir path: %s", ContextTempDir) return } //defer ContextTempDir.Remove() contextTempFile = folio.URI(fmt.Sprintf("tar://%s/%s", ContextTempDir, "context.tar")) writer, e := contextTempFile.ContentWriterStream() if e != nil { return contextTempFile, e } var header *tar.Header tarReader := tar.NewReader(reader) tarWriter := tar.NewWriter(writer) defer tarWriter.Close() for { header, err = tarReader.Next() if err == io.EOF { break } if err != nil { return } if err = tarWriter.WriteHeader(header); err != nil { return } if _, err = io.Copy(tarWriter, tarReader); err != nil { return } } header = &tar.Header{ Name: "Dockerfile", Mode: 0644, } if err = tarWriter.WriteHeader(header); err != nil { return } var dockerfileReader io.Reader if len(c.Dockerfile) > 0 { dockerfileReader = strings.NewReader(c.Dockerfile) } else { if dockerfileReader, err = c.DockerfileRef.ContentReaderStream(); err != nil { return } } if _, err = io.Copy(tarWriter, dockerfileReader); err != nil { return } if c.InjectJX { var jxURI folio.URI jxURI, err = JXPath() var jxReader *transport.Reader if jxReader, err = jxURI.ContentReaderStream(); err != nil { return } fi, fiErr := data.FileInfoGetter(jxReader).Stat() if fiErr != nil { err = fiErr return } slog.Info("ContainerImage.CreateContextArchive()", "jx", jxURI, "error", err) header = &tar.Header{ Name: "jx", Mode: 0755, Size: fi.Size(), } if err = tarWriter.WriteHeader(header); err != nil { return } if _, err = io.Copy(tarWriter, jxReader); err != nil { return } } return } func JXPath() (jxPath folio.URI, err error) { var path string path, err = os.Executable() if err == nil { jxPath = folio.URI(path) if jxPath.Exists() { return } else { err = os.ErrNotExist } } jxPath = "" return } func (c *ContainerImage) UnmarshalOutput() (err error) { var containerErr ContainerError var jsonBody string = c.outputWriter.String() slog.Info("ContainerImage.UnmarshalOutput()", "json", jsonBody, "error", err) for _, v := range strings.Split(jsonBody, "\r\n") { var containerLog ContainerLog outputDecoder := codec.NewJSONStringDecoder(v) if decodeErr := outputDecoder.Decode(&containerLog); decodeErr != nil { if decodeErr == io.EOF { break } slog.Info("ContainerImage.UnmarshalOutput()", "value", v, "error", decodeErr) err = decodeErr } if containerLog.ContainerLogStream != nil || containerLog.ContainerLogStatus != nil { c.Output = append(c.Output, containerLog) } if containerLog.ContainerError != nil { containerErr = *containerLog.ContainerError c.Output = append(c.Output, containerLog) } slog.Info("ContainerImage.UnmarshalOutput()", "value", v, "error", err) } if len(containerErr.Error) > 0 { return fmt.Errorf("%s", containerErr.Error) } return } // The contextref can be a tar file or a directory or maybe a loaded document func (c *ContainerImage) Create(ctx context.Context) (err error) { dockerfileURI := c.DockerfileRef.Parse() buildOptions := types.ImageBuildOptions{ Dockerfile: dockerfileURI.Path, Tags: []string{c.Name}, NetworkMode: "host", } var reader io.ReadCloser if c.ContextRef.Exists() { contentType := folio.URI(c.ContextRef).ContentType() switch contentType { case "tar", "tar.gz", "tgz": var ctxArchiveURI folio.URI r, refStreamErr := c.ContextRef.ContentReaderStream() if refStreamErr != nil { return refStreamErr } if ctxArchiveURI, err = c.CreateContextArchive(r); err != nil { return err } reader, _ = ctxArchiveURI.ContentReaderStream() default: doc, ctErr := c.ContextDocument() if ctErr != nil { return ctErr } emitTar, tarErr := c.ConverterTypes.New(fmt.Sprintf("tar://%s/%s", ContextTempDir, "context.tar")) if tarErr != nil { return tarErr } slog.Info("ContainerImage.Create()", "document", doc, "error", err) tarResource, emitErr := emitTar.Emit(doc, nil) if emitErr != nil { slog.Info("ContainerImage.Create() Emit", "document", doc, "error", emitErr) return emitErr } emitTar.Close() slog.Info("ContainerImage.Create()", "tar", tarResource, "error", err) reader, _ = tarResource.(*File).GetContent(nil) } buildResponse, buildErr := c.apiClient.ImageBuild(ctx, reader, buildOptions) slog.Info("ContainerImage.Create() - ImageBuild()", "buildResponse", buildResponse, "error", buildErr) if buildErr != nil { return buildErr } defer buildResponse.Body.Close() copyBuffer := make([]byte, 32 * 1024) if _, err = io.CopyBuffer(&c.outputWriter, buildResponse.Body, copyBuffer); err != nil { slog.Info("ContainerImage.Create() - ImageBuild()", "error", err) return fmt.Errorf("%w %s %s", err, c.Type(), c.Name) } else { if err = c.UnmarshalOutput(); err != nil { return } /* slog.Info("ContainerImage.Create() - ImageBuild()", "error", err) var containerErr ContainerError for _, jsonBody := range strings.Split(string(c.outputWriter.String()), "\r\n") { decoder := codec.NewJSONStringDecoder(jsonBody) decodeErr := decoder.Decode(&containerErr) slog.Info("ContainerImage.Create() - ImageBuild()", "output", jsonBody, "error", containerErr, "decodeErr", decodeErr) if len(containerErr.Error) > 0 { return fmt.Errorf("%s", containerErr.Error) } } */ } if c.PushImage { err = c.Push(ctx) slog.Info("ContainerImage.Create() - Push()", "error", err) } err = c.UnmarshalOutput() } return } func (c *ContainerImage) Update(ctx context.Context) error { return c.Create(ctx) } func (c *ContainerImage) Push(ctx context.Context) (err error) { var AuthToken string AuthToken, err = c.RegistryAuth() if err != nil { return } /* if err = c.apiClient.ImageTag(ctx, imageName, targetImage); err != nil { return } */ var response io.ReadCloser if response, err = c.apiClient.ImagePush(context.Background(), c.Name, image.PushOptions{ RegistryAuth: AuthToken, }); err != nil { return } defer response.Close() copyBuffer := make([]byte, 32 * 1024) _, err = io.CopyBuffer(&c.outputWriter, response, copyBuffer) //c.Output = c.outputWriter.String() return } func (c *ContainerImage) Pull(ctx context.Context) error { out, err := c.apiClient.ImagePull(ctx, c.Name, image.PullOptions{}) slog.Info("ContainerImage.Pull()", "name", c.Name, "error", err) if err == nil { if _, outputErr := io.ReadAll(out); outputErr != nil { return fmt.Errorf("%w: %s %s", outputErr, c.Type(), c.Name) } } else { return fmt.Errorf("%w: %s %s", err, c.Type(), c.Name) } return nil } func (c *ContainerImage) Inspect(ctx context.Context) (imageInspect types.ImageInspect) { var err error imageInspect, _, err = c.apiClient.ImageInspectWithRaw(ctx, c.Name) if err != nil { panic(err) } return } func (c *ContainerImage) ReadStat(ctx context.Context) (err error) { if c.imageStat, _, err = c.apiClient.ImageInspectWithRaw(ctx, c.Name); err != nil || c.imageStat.ID == "" { return fmt.Errorf("%w: %s %s", err, c.Type(), c.Name) } return } func (c *ContainerImage) Read(ctx context.Context) (resourceYaml []byte, err error) { defer func() { if r := recover(); r != nil { c.State = "absent" resourceYaml = nil err = fmt.Errorf("%w", r.(error)) } }() var imageInspect types.ImageInspect imageInspect, _, err = c.apiClient.ImageInspectWithRaw(ctx, c.Name) slog.Info("ContainerImage.Read()", "name", c.Name, "error", err) if err != nil { if client.IsErrNotFound(err) { if pullErr := c.Pull(ctx); pullErr != nil { panic(pullErr) } imageInspect = c.Inspect(ctx) } else { panic(err) } } c.State = "present" c.Id = imageInspect.ID /* if c.Name == "" { c.Name = imageInspect.Name } */ c.Created = imageInspect.Created c.Author = imageInspect.Author c.Architecture = imageInspect.Architecture c.Variant = imageInspect.Variant c.OS = imageInspect.Os c.Size = imageInspect.Size c.Comment = imageInspect.Comment slog.Info("ContainerImage.Read()", "type", c.Type(), "name", c.Name, "Id", c.Id, "state", c.State, "error", err) return yaml.Marshal(c) } func (c *ContainerImage) Delete(ctx context.Context) error { slog.Info("ContainerImage.Delete()", "image", c) options := image.RemoveOptions{ Force: false, PruneChildren: false, } _, err := c.apiClient.ImageRemove(ctx, c.Id, options) return err } func (c *ContainerImage) Type() string { return "container-image" } func (c *ContainerImage) ResolveId(ctx context.Context) string { imageInspect, _, err := c.apiClient.ImageInspectWithRaw(ctx, c.Name) if err != nil { triggerResult := c.StateMachine().Trigger("notexists") slog.Info("ContainerImage.ResolveId()", "name", c.Name, "machine.state", c.StateMachine().CurrentState(), "resource.state", c.State, "trigger.error", triggerResult) panic(fmt.Errorf("%w: %s %s", err, c.Type(), c.Name)) } slog.Info("ContainerImage.ResolveId()", "name", c.Name, "machine.state", c.StateMachine().CurrentState(), "resource.state", c.State) c.Id = imageInspect.ID if c.Id != "" { if triggerErr := c.StateMachine().Trigger("exists"); triggerErr != nil { panic(fmt.Errorf("%w: %s %s", triggerErr, c.Type(), c.Name)) } slog.Info("ContainerImage.ResolveId() trigger created", "machine", c.StateMachine(), "state", c.StateMachine().CurrentState()) } else { if triggerErr := c.StateMachine().Trigger("notexists"); triggerErr != nil { panic(fmt.Errorf("%w: %s %s", triggerErr, c.Type(), c.Name)) } slog.Info("ContainerImage.ResolveId()", "name", c.Name, "machine.state", c.StateMachine().CurrentState(), "resource.state", c.State) } return c.Id }