feat: Implemented (un)install,start/stop flow and fixed actions flow
This commit is contained in:
@@ -1,11 +1,23 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
func normalizeName(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
func serviceKey(c types.Container) string {
|
||||
if s := normalizeName(c.ServiceName); s != "" {
|
||||
return s
|
||||
}
|
||||
return normalizeName(c.Name)
|
||||
}
|
||||
|
||||
type ContainerCache struct {
|
||||
sync.RWMutex
|
||||
// Maps Container ID -> Container
|
||||
@@ -25,18 +37,30 @@ func (cache *ContainerCache) AddService(c types.Container) {
|
||||
if cache.services == nil {
|
||||
cache.services = make(map[string]types.Container)
|
||||
}
|
||||
cache.services[c.ID] = c
|
||||
cache.services[serviceKey(c)] = c
|
||||
cache.Unlock()
|
||||
|
||||
// Trigger update AFTER unlocking to prevent holding locks during channel sends
|
||||
cache.TriggerUpdate()
|
||||
}
|
||||
|
||||
func (cache *ContainerCache) UpdateService(c types.Container) {
|
||||
cache.Lock()
|
||||
if cache.services == nil {
|
||||
cache.services = make(map[string]types.Container)
|
||||
}
|
||||
cache.services[serviceKey(c)] = c
|
||||
cache.Unlock()
|
||||
|
||||
cache.TriggerUpdate()
|
||||
}
|
||||
|
||||
func (cache *ContainerCache) RemoveService(c types.Container) {
|
||||
cache.Lock()
|
||||
_, exists := cache.services[c.ID]
|
||||
key := serviceKey(c)
|
||||
_, exists := cache.services[key]
|
||||
if exists {
|
||||
delete(cache.services, c.ID)
|
||||
delete(cache.services, key)
|
||||
}
|
||||
cache.Unlock()
|
||||
|
||||
@@ -64,3 +88,11 @@ func (cache *ContainerCache) GetServices() []types.Container {
|
||||
}
|
||||
return services
|
||||
}
|
||||
|
||||
func (cache *ContainerCache) GetService(name string) (types.Container, bool) {
|
||||
cache.RLock()
|
||||
defer cache.RUnlock()
|
||||
|
||||
service, ok := cache.services[normalizeName(name)]
|
||||
return service, ok
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"github.com/compose-spec/compose-go/v2/cli"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/flags"
|
||||
"github.com/docker/compose/v2/pkg/api"
|
||||
"github.com/docker/compose/v2/pkg/compose"
|
||||
)
|
||||
|
||||
// newComposeService returns a compose API service backed by the provided Docker client.
|
||||
func newComposeService(dc *docker.DockerClient) (api.Compose, error) {
|
||||
dockerCli, err := command.NewDockerCli()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create docker cli: %w", err)
|
||||
}
|
||||
if err := dockerCli.Initialize(flags.NewClientOptions(), command.WithAPIClient(dc.Client)); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize docker cli: %w", err)
|
||||
}
|
||||
return compose.NewComposeService(dockerCli), nil
|
||||
}
|
||||
|
||||
// loadComposeProject loads the docker-compose.yaml for the given service from its data dir.
|
||||
func loadComposeProject(ctx context.Context, serviceName string) (*types.Project, error) {
|
||||
dir, err := getContainerDataDir(serviceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
composePath := filepath.Join(dir, "docker-compose.yaml")
|
||||
projectOptions, err := cli.NewProjectOptions(
|
||||
[]string{composePath},
|
||||
cli.WithName(serviceName),
|
||||
cli.WithWorkingDirectory(dir),
|
||||
cli.WithOsEnv,
|
||||
cli.WithDotEnv,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build compose project options: %w", err)
|
||||
}
|
||||
|
||||
project, err := cli.ProjectFromOptions(ctx, projectOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load compose project %s: %w", serviceName, err)
|
||||
}
|
||||
|
||||
// Replicate the docker compose CLI's project decoration: the compose API relies
|
||||
// on these labels to match containers back to the project (e.g. start() filters
|
||||
// on the project + oneoff labels). Without them, Up() creates containers but the
|
||||
// subsequent start() step fails to find them. Also normalize the dcntrlized
|
||||
// service marker to the lowercase project name so label lookups are consistent.
|
||||
for name, s := range project.Services {
|
||||
s.CustomLabels = map[string]string{
|
||||
api.ProjectLabel: project.Name,
|
||||
api.ServiceLabel: name,
|
||||
api.VersionLabel: api.ComposeVersion,
|
||||
api.WorkingDirLabel: project.WorkingDir,
|
||||
api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","),
|
||||
api.OneoffLabel: "False",
|
||||
"org.dcntrlized.service": project.Name,
|
||||
}
|
||||
project.Services[name] = s
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
@@ -1 +1,395 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
registrytypes "github.com/docker/docker/api/types/registry"
|
||||
)
|
||||
|
||||
// InstallContainer downloads the raw docker-compose.yaml and pulls the Docker image.
|
||||
func InstallContainer(ctx context.Context, item types.RegistryContainer) error {
|
||||
// 1. Determine storage path (XDG_DATA_HOME or ~/.dcntrlized/[name]/)
|
||||
targetDir, err := getContainerDataDir(item.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine data directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", targetDir, err)
|
||||
}
|
||||
|
||||
// 2. Fetch raw docker-compose.yaml from the Git URL
|
||||
rawURL := fmt.Sprintf("%s/raw/branch/master/docker-compose.yaml", item.URL)
|
||||
composePath := filepath.Join(targetDir, "docker-compose.yaml")
|
||||
|
||||
logger.Info("Fetching compose file from %s...", rawURL)
|
||||
if err := downloadFile(ctx, rawURL, composePath); err != nil {
|
||||
return fmt.Errorf("failed to download docker-compose.yaml: %w", err)
|
||||
}
|
||||
|
||||
// 2.5. Persist install metadata so installed-but-not-running state can be tracked without Docker lookups
|
||||
metadata := types.InstalledMetadata{
|
||||
RegistryContainer: item,
|
||||
InstalledAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
metadataPath := filepath.Join(targetDir, "metadata.json")
|
||||
if err := writeMetadata(metadataPath, metadata); err != nil {
|
||||
return fmt.Errorf("failed to write metadata: %w", err)
|
||||
}
|
||||
|
||||
// 3. Pull the Docker Image with Registry Authentication
|
||||
logger.Info("Pulling docker image %s...", item.Image)
|
||||
cli, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize docker client: %w", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
pullOpts := image.PullOptions{}
|
||||
|
||||
// Build auth payload if git credentials or registry auth env vars exist
|
||||
authHeader, err := buildRegistryAuthHeader()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry auth: %w", err)
|
||||
}
|
||||
if authHeader != "" {
|
||||
pullOpts.RegistryAuth = authHeader
|
||||
}
|
||||
|
||||
reader, err := cli.ImagePull(ctx, item.Image, pullOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull docker image %s: %w", item.Image, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Drain reader stream to ensure Docker finishes downloading layers
|
||||
_, _ = io.Copy(io.Discard, reader)
|
||||
|
||||
logger.Info("Successfully installed container service: %s", item.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRegistryAuthHeader builds the base64-encoded auth header required by Docker SDK
|
||||
func buildRegistryAuthHeader() (string, error) {
|
||||
username := os.Getenv("REGISTRY_USER")
|
||||
token := os.Getenv("GIT_TOKEN")
|
||||
if token == "" {
|
||||
token = os.Getenv("GITHUB_TOKEN")
|
||||
}
|
||||
|
||||
// For Gitea/Forgejo container registries, token auth works using "token" or your username as the user, and GIT_TOKEN as password.
|
||||
if username == "" {
|
||||
username = "oauth2" // Fallback username when using personal access tokens
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", nil // No token set; proceed unauthenticated
|
||||
}
|
||||
|
||||
authConfig := registrytypes.AuthConfig{
|
||||
Username: username,
|
||||
Password: token,
|
||||
}
|
||||
|
||||
encodedJSON, err := json.Marshal(authConfig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.URLEncoding.EncodeToString(encodedJSON), nil
|
||||
}
|
||||
|
||||
// getDataRoot resolves the directory that stores installed container metadata,
|
||||
// using the first variable that is set. DCNTRLIZED_INSTALL_DIR is used as-is;
|
||||
// the others are base dirs with ".dcntrlized" appended.
|
||||
func getDataRoot() (string, error) {
|
||||
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, ".dcntrlized"), nil
|
||||
}
|
||||
|
||||
if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
|
||||
return filepath.Join(xdgHome, ".dcntrlized"), nil
|
||||
}
|
||||
|
||||
if home := os.Getenv("HOME"); home != "" {
|
||||
return filepath.Join(home, ".dcntrlized"), nil
|
||||
}
|
||||
|
||||
return filepath.Join("/data", ".dcntrlized"), nil
|
||||
}
|
||||
|
||||
// EnsureDataRoot creates the data root directory if it does not exist yet.
|
||||
// This gives a clear, early error when the install directory is missing or not
|
||||
// writable (e.g. the container's bind mount source was removed) instead of a
|
||||
// confusing "no such file or directory" when installing a container.
|
||||
//
|
||||
// When started as root inside the container it hands the data root over to the
|
||||
// host user (discovered at runtime, or overridden via DCNTRLIZED_UID/GID) and
|
||||
// drops privileges, so every file created afterwards stays user-owned.
|
||||
func EnsureDataRoot() error {
|
||||
root, err := getDataRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(root, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data root %s (is the host directory present and writable?): %w", root, err)
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
identity, err := resolveRuntimeIdentity(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chown(root, identity.uid, identity.gid); err != nil {
|
||||
return fmt.Errorf("failed to chown data root %s to %d:%d: %w", root, identity.uid, identity.gid, err)
|
||||
}
|
||||
if err := dropPrivileges(identity, root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runtimeIdentity is the user the container should run as, plus any
|
||||
// supplementary groups needed to keep working (e.g. the docker socket group).
|
||||
type runtimeIdentity struct {
|
||||
username string
|
||||
hostDir string
|
||||
uid, gid int
|
||||
groups []int
|
||||
}
|
||||
|
||||
// resolveRuntimeIdentity determines the user to hand the data root to.
|
||||
// An explicit DCNTRLIZED_UID/GID wins; otherwise the host user is discovered
|
||||
// from the bind-mounted home directory, falling back to the data root's owner
|
||||
// (self-heals upgrades) and finally to uid 1000.
|
||||
func resolveRuntimeIdentity(dataRoot string) (runtimeIdentity, error) {
|
||||
username := filepath.Base(hostPath(mountSource("/host-home")))
|
||||
if username == "" || username == "." || username == "/" {
|
||||
username = "user"
|
||||
}
|
||||
hostDir := hostPath(mountSource(dataRoot))
|
||||
if hostDir == "" {
|
||||
hostDir = "(unknown)"
|
||||
}
|
||||
|
||||
var id runtimeIdentity
|
||||
if uidStr := os.Getenv("DCNTRLIZED_UID"); uidStr != "" {
|
||||
uid, err := strconv.Atoi(uidStr)
|
||||
if err != nil {
|
||||
return runtimeIdentity{}, fmt.Errorf("invalid DCNTRLIZED_UID %q: %w", uidStr, err)
|
||||
}
|
||||
gid, err := strconv.Atoi(os.Getenv("DCNTRLIZED_GID"))
|
||||
if err != nil {
|
||||
gid = uid
|
||||
}
|
||||
id = runtimeIdentity{uid: uid, gid: gid, groups: dockerSocketGroups(uid)}
|
||||
} else {
|
||||
uid, gid, err := discoverHostUser(dataRoot)
|
||||
if err != nil {
|
||||
return runtimeIdentity{}, err
|
||||
}
|
||||
id = runtimeIdentity{uid: uid, gid: gid, groups: dockerSocketGroups(uid)}
|
||||
}
|
||||
|
||||
id.username, id.hostDir = username, hostDir
|
||||
logger.Info("User: %s (%d, %d)", id.username, id.uid, id.gid)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// discoverHostUser finds the host user's uid/gid, preferring the read-only
|
||||
// bind mount of the host home directory, then the data root's owner.
|
||||
func discoverHostUser(dataRoot string) (uid, gid int, err error) {
|
||||
if uid, gid, ok := statOwner("/host-home"); ok && uid != 0 {
|
||||
logger.Debug("Host user resolved from /host-home")
|
||||
return uid, gid, nil
|
||||
}
|
||||
if uid, gid, ok := statOwner(dataRoot); ok && uid != 0 {
|
||||
logger.Debug("Host user resolved from data root owner")
|
||||
return uid, gid, nil
|
||||
}
|
||||
logger.Warn("Could not determine host user (no /host-home or owned data root); defaulting to uid 1000")
|
||||
return 1000, 1000, nil
|
||||
}
|
||||
|
||||
// statOwner returns the numeric uid/gid owning the given path.
|
||||
func statOwner(path string) (uid, gid int, ok bool) {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
sys, ok := st.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return int(sys.Uid), int(sys.Gid), true
|
||||
}
|
||||
|
||||
// mountSource returns the host source path of the bind mount at mountPoint by
|
||||
// parsing /proc/self/mountinfo, or "" when the mount does not exist.
|
||||
func mountSource(mountPoint string) string {
|
||||
data, err := os.ReadFile("/proc/self/mountinfo")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
// Format: <id> <parent> <major:minor> <root> <mountpoint> <opts> ...
|
||||
// For bind mounts, <root> is the source path on the host.
|
||||
if fields[4] == mountPoint {
|
||||
return unescapeMountPath(fields[3])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// unescapeMountPath decodes the octal escapes used in mountinfo paths (e.g. \040).
|
||||
func unescapeMountPath(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+3 < len(s) {
|
||||
if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
|
||||
b.WriteByte(byte(v))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// hostPath converts a mount source into a host-visible path. Btrfs encodes
|
||||
// subvolumes as "@name/..." (e.g. "@home/franciscofadigas" mounted at
|
||||
// "/home/franciscofadigas"); ordinary sources already start with "/".
|
||||
func hostPath(source string) string {
|
||||
if source == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(source, "/@") {
|
||||
return "/" + strings.TrimPrefix(source, "/@")
|
||||
}
|
||||
if strings.HasPrefix(source, "@") {
|
||||
return "/" + strings.TrimPrefix(source, "@")
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
// dockerSocketPath is where the host's docker socket is mounted into the container.
|
||||
const dockerSocketPath = "/var/run/docker.sock"
|
||||
|
||||
// dockerSocketGroups returns the group of the mounted docker socket when it is
|
||||
// group-accessible, so the container can keep talking to it after dropping
|
||||
// privileges without hardcoding the host's docker group id.
|
||||
func dockerSocketGroups(runAsUID int) []int {
|
||||
st, err := os.Stat(dockerSocketPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if st.Mode()&0o020 == 0 {
|
||||
return nil
|
||||
}
|
||||
sys, ok := st.Sys().(*syscall.Stat_t)
|
||||
if !ok || sys.Gid == 0 || int(sys.Gid) == runAsUID {
|
||||
return nil
|
||||
}
|
||||
return []int{int(sys.Gid)}
|
||||
}
|
||||
|
||||
// dropPrivileges switches the process to the given uid/gid, keeping the
|
||||
// supplementary groups needed for the docker socket.
|
||||
func dropPrivileges(id runtimeIdentity, guestDir string) error {
|
||||
if err := syscall.Setgroups(id.groups); err != nil {
|
||||
return fmt.Errorf("failed to set supplementary groups %v: %w", id.groups, err)
|
||||
}
|
||||
if err := syscall.Setgid(id.gid); err != nil {
|
||||
return fmt.Errorf("failed to set gid to %d: %w", id.gid, err)
|
||||
}
|
||||
if err := syscall.Setuid(id.uid); err != nil {
|
||||
return fmt.Errorf("failed to set uid to %d: %w", id.uid, err)
|
||||
}
|
||||
if os.Geteuid() != id.uid {
|
||||
return fmt.Errorf("failed to drop privileges: still running as euid %d (want %d)", os.Geteuid(), id.uid)
|
||||
}
|
||||
logger.Info("Handoff %s (%s) %d:%d", guestDir, id.hostDir, id.uid, id.gid)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getContainerDataDir(serviceName string) (string, error) {
|
||||
root, err := getDataRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(root, serviceName), nil
|
||||
}
|
||||
|
||||
func downloadFile(ctx context.Context, url string, destPath string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token := os.Getenv("GIT_TOKEN")
|
||||
if token == "" {
|
||||
token = os.Getenv("GITHUB_TOKEN")
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("authentication failed (HTTP %d): ensure GIT_TOKEN is set and has access to the repository", resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected HTTP status fetching compose file: %s", resp.Status)
|
||||
}
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeMetadata(path string, metadata types.InstalledMetadata) error {
|
||||
data, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
// LoadInstalledContainers scans the data root and registers every installed (but not
|
||||
// necessarily running) container in the cache under its install-dir name.
|
||||
func (cache *ContainerCache) LoadInstalledContainers() error {
|
||||
root, err := getDataRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
metadata, err := readInstalledMetadata(filepath.Join(root, name))
|
||||
if err != nil {
|
||||
logger.Warn("Skipping install dir %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
cache.AddService(types.Container{
|
||||
ServiceName: name,
|
||||
Name: metadata.Name,
|
||||
Version: metadata.Version,
|
||||
Image: metadata.Image,
|
||||
Status: "installed",
|
||||
CreatedAt: metadata.CreatedAt,
|
||||
Title: metadata.Title,
|
||||
Vendor: metadata.Vendor,
|
||||
Author: metadata.Author,
|
||||
Description: metadata.Description,
|
||||
Source: metadata.Source,
|
||||
})
|
||||
logger.Info("Loaded installed container: %s", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readInstalledMetadata reads and parses the metadata.json of an install dir.
|
||||
func readInstalledMetadata(dir string) (*types.InstalledMetadata, error) {
|
||||
data, err := os.ReadFile(filepath.Join(dir, "metadata.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var metadata types.InstalledMetadata
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// IsInstalled reports whether the named service has an install record on disk.
|
||||
func IsInstalled(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
dir, err := getContainerDataDir(name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(filepath.Join(dir, "metadata.json"))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
// resolveServiceName maps a possibly display name (e.g. "TestService" from a
|
||||
// container manifest) back to the compose project name used for data dirs and
|
||||
// docker labels ("testservice"). It prefers the container's compose project
|
||||
// label (authoritative and lowercase), then the install data dir, then a
|
||||
// lowercased fallback.
|
||||
func resolveServiceName(ctx context.Context, dc *docker.DockerClient, displayName string) string {
|
||||
if dirExists(displayName) {
|
||||
return displayName
|
||||
}
|
||||
if project := lookupProjectName(ctx, dc, displayName); project != "" {
|
||||
return project
|
||||
}
|
||||
lower := strings.ToLower(displayName)
|
||||
if dirExists(lower) {
|
||||
return lower
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
// dirExists reports whether a service install directory exists for the name.
|
||||
func dirExists(serviceName string) bool {
|
||||
dir, err := getContainerDataDir(serviceName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// lookupProjectName finds the compose project name of a container matching the
|
||||
// display name. The com.docker.compose.project label is authoritative; the
|
||||
// org.dcntrlized.service label is a fallback for containers not started
|
||||
// through compose.
|
||||
func lookupProjectName(ctx context.Context, dc *docker.DockerClient, displayName string) string {
|
||||
containers, err := dc.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, c := range containers {
|
||||
if !matchesDisplayName(c, displayName) {
|
||||
continue
|
||||
}
|
||||
if project := c.Labels["com.docker.compose.project"]; project != "" {
|
||||
return project
|
||||
}
|
||||
if label := c.Labels["org.dcntrlized.service"]; label != "" {
|
||||
return label
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// matchesDisplayName reports whether the container belongs to the service that
|
||||
// presents the given (possibly display) name.
|
||||
func matchesDisplayName(c container.Summary, displayName string) bool {
|
||||
if label := c.Labels["org.dcntrlized.service"]; label != "" && strings.EqualFold(label, displayName) {
|
||||
return true
|
||||
}
|
||||
for _, n := range c.Names {
|
||||
if strings.EqualFold(filepath.Base(n), displayName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/docker/compose/v2/pkg/api"
|
||||
)
|
||||
|
||||
// StartContainer creates and starts the service containers defined in the
|
||||
// service's docker-compose.yaml.
|
||||
func StartContainer(ctx context.Context, item types.RegistryContainer) error {
|
||||
dc, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize docker client: %w", err)
|
||||
}
|
||||
defer dc.Close()
|
||||
|
||||
serviceName := resolveServiceName(ctx, dc, item.Name)
|
||||
project, err := loadComposeProject(ctx, serviceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The compose file may reference the image by a short name (e.g.
|
||||
// "testservice") while install pulled it under its full registry reference.
|
||||
// Make sure those short names exist so Up() can find them.
|
||||
installedImage := item.Image
|
||||
if installedImage == "" {
|
||||
if dir, dirErr := getContainerDataDir(serviceName); dirErr == nil {
|
||||
if metadata, metaErr := readInstalledMetadata(dir); metaErr == nil {
|
||||
installedImage = metadata.Image
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureServiceImages(ctx, dc, project, installedImage)
|
||||
|
||||
composeService, err := newComposeService(dc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("Starting service %s...", serviceName)
|
||||
err = composeService.Up(ctx, project, api.UpOptions{
|
||||
Create: api.CreateOptions{
|
||||
Recreate: api.RecreateDiverged,
|
||||
AssumeYes: true,
|
||||
},
|
||||
Start: api.StartOptions{},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start service %s: %w", serviceName, err)
|
||||
}
|
||||
|
||||
logger.Info("Successfully started service: %s", serviceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureServiceImages makes sure every image referenced by the compose project
|
||||
// exists locally. Registries install the image under its full reference (e.g.
|
||||
// git.../testservice:3.4) while the service's compose file often references it
|
||||
// by a short name (e.g. "testservice"); when the short name is missing locally
|
||||
// and its base name matches the installed image, it is tagged so start works.
|
||||
func ensureServiceImages(ctx context.Context, dc *docker.DockerClient, project *composetypes.Project, installedImage string) {
|
||||
if installedImage == "" {
|
||||
return
|
||||
}
|
||||
for name, s := range project.Services {
|
||||
ref := s.Image
|
||||
if ref == "" || ref == installedImage {
|
||||
continue
|
||||
}
|
||||
if imageExists(ctx, dc, ref) {
|
||||
continue
|
||||
}
|
||||
if baseImageName(ref) != baseImageName(installedImage) {
|
||||
continue
|
||||
}
|
||||
logger.Info("Tagging %s as %s for compose service %s", installedImage, ref, name)
|
||||
if err := dc.Client.ImageTag(ctx, installedImage, ref); err != nil {
|
||||
logger.Warn("Failed to tag %s as %s: %v", installedImage, ref, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// imageExists reports whether an image reference exists locally.
|
||||
func imageExists(ctx context.Context, dc *docker.DockerClient, ref string) bool {
|
||||
_, _, err := dc.Client.ImageInspectWithRaw(ctx, ref)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// baseImageName strips the registry/namespace and tag from an image reference,
|
||||
// returning the repository name (e.g. "testservice").
|
||||
func baseImageName(ref string) string {
|
||||
base := ref
|
||||
if i := strings.LastIndex(base, "/"); i >= 0 {
|
||||
base = base[i+1:]
|
||||
}
|
||||
if i := strings.LastIndex(base, ":"); i >= 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
"github.com/docker/compose/v2/pkg/api"
|
||||
)
|
||||
|
||||
// StopContainer stops the running service containers defined in the service's
|
||||
// docker-compose.yaml.
|
||||
func StopContainer(ctx context.Context, item types.RegistryContainer) error {
|
||||
dc, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize docker client: %w", err)
|
||||
}
|
||||
defer dc.Close()
|
||||
|
||||
serviceName := resolveServiceName(ctx, dc, item.Name)
|
||||
project, err := loadComposeProject(ctx, serviceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
composeService, err := newComposeService(dc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("Stopping service %s...", serviceName)
|
||||
err = composeService.Stop(ctx, project.Name, api.StopOptions{Project: project})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop service %s: %w", serviceName, err)
|
||||
}
|
||||
|
||||
logger.Info("Successfully stopped service: %s", serviceName)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
"github.com/docker/compose/v2/pkg/api"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
)
|
||||
|
||||
// UninstallContainer stops and removes the service container, removes its data
|
||||
// directory and removes the pulled image.
|
||||
func UninstallContainer(ctx context.Context, item types.RegistryContainer) error {
|
||||
dc, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize docker client: %w", err)
|
||||
}
|
||||
defer dc.Close()
|
||||
|
||||
serviceName := resolveServiceName(ctx, dc, item.Name)
|
||||
targetDir, err := getContainerDataDir(serviceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine data directory: %w", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(targetDir); err == nil {
|
||||
// Stop and remove the project containers/networks via compose down when
|
||||
// the compose file is available.
|
||||
if project, loadErr := loadComposeProject(ctx, serviceName); loadErr == nil {
|
||||
if composeService, newErr := newComposeService(dc); newErr == nil {
|
||||
if err := composeService.Down(ctx, project.Name, api.DownOptions{Project: project}); err != nil {
|
||||
logger.Warn("Compose down failed for %s: %v", serviceName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: force-remove any container still labeled with this service.
|
||||
stopAndRemoveByLabel(ctx, dc, serviceName)
|
||||
}
|
||||
|
||||
if item.Image == "" {
|
||||
if metadata, err := readInstalledMetadata(targetDir); err == nil {
|
||||
item.Image = metadata.Image
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(targetDir); err != nil {
|
||||
// Leftover files (e.g. permission issues) shouldn't abort the uninstall:
|
||||
// the container and image are already gone, and failing here would leave
|
||||
// the cache entry stuck so the UI keeps showing the service as installed.
|
||||
logger.Warn("Failed to remove data directory %s: %v", targetDir, err)
|
||||
}
|
||||
|
||||
if item.Image != "" {
|
||||
if _, err := dc.ImageRemove(ctx, item.Image, image.RemoveOptions{PruneChildren: true}); err != nil {
|
||||
// Image may still be in use by another container; uninstall still succeeds.
|
||||
logger.Warn("Failed to remove image %s: %v", item.Image, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Successfully uninstalled container service: %s", serviceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopAndRemoveByLabel force-stops and removes every container carrying the
|
||||
// org.dcntrlized.service label for the given service.
|
||||
func stopAndRemoveByLabel(ctx context.Context, dc *docker.DockerClient, serviceName string) {
|
||||
filter := filters.NewArgs()
|
||||
filter.Add("label", "org.dcntrlized.service")
|
||||
|
||||
containers, err := dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter})
|
||||
if err != nil {
|
||||
logger.Warn("Failed to list containers for service %s: %v", serviceName, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, c := range containers {
|
||||
// The label may carry the display name (e.g. "TestService"); compare
|
||||
// case-insensitively against the resolved project name.
|
||||
if label := c.Labels["org.dcntrlized.service"]; label != "" && !strings.EqualFold(label, serviceName) {
|
||||
continue
|
||||
}
|
||||
if err := dc.ContainerStop(ctx, c.ID, container.StopOptions{}); err != nil {
|
||||
logger.Warn("Failed to stop container %s: %v", c.ID, err)
|
||||
}
|
||||
if err := dc.ContainerRemove(ctx, c.ID, container.RemoveOptions{Force: true}); err != nil {
|
||||
logger.Warn("Failed to remove container %s: %v", c.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
-26
@@ -5,10 +5,12 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
)
|
||||
@@ -41,7 +43,7 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
|
||||
}
|
||||
continue
|
||||
}
|
||||
_ = serviceLabel
|
||||
key := normalizeName(serviceLabel)
|
||||
containerID := event.Actor.ID
|
||||
containerName := event.Actor.Attributes["name"]
|
||||
|
||||
@@ -61,52 +63,70 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
|
||||
netInfo, shouldCache, err := docker.ExtractNetworkInfo(ctx, dc, inspectData)
|
||||
if err != nil {
|
||||
log.Printf("Error extracting network details: %v", err)
|
||||
} else if !shouldCache {
|
||||
log.Printf("Bypassing special network mode: %s", inspectData.HostConfig.NetworkMode)
|
||||
} else {
|
||||
containerNetwork = netInfo
|
||||
}
|
||||
if !shouldCache {
|
||||
fmt.Printf("Bypassing special network mode: %s\n", inspectData.HostConfig.NetworkMode)
|
||||
cache.Unlock()
|
||||
return
|
||||
}
|
||||
containerNetwork = netInfo
|
||||
}
|
||||
|
||||
// Get Versions
|
||||
ver, err := docker.GetContainerVersion(ctx, dc, containerID)
|
||||
ver, _ := docker.GetContainerVersion(ctx, dc, containerID)
|
||||
|
||||
// Fetch Manifest
|
||||
// Fetch Manifest (best effort; fall back to basic info if it fails)
|
||||
host := containerID[:12]
|
||||
port := event.Actor.Attributes["org.dcntrlized.port"]
|
||||
manifest, err := docker.FetchManifestWithRetry(ctx, host, port, 20, 50*time.Millisecond)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching manifest for container %s: %v", containerID, err)
|
||||
cache.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Save to Cache
|
||||
log.Printf("Service started. Adding container %s - %s to cache", host, containerName)
|
||||
cache.services[containerID] = types.Container{
|
||||
ID: containerID,
|
||||
Name: manifest.Name,
|
||||
DockerName: containerName,
|
||||
Image: event.Actor.Attributes["image"],
|
||||
CreatedAt: fmt.Sprint(event.TimeNano),
|
||||
Network: containerNetwork,
|
||||
Status: "running",
|
||||
Version: ver,
|
||||
existing, _ := cache.services[key]
|
||||
container := types.Container{
|
||||
ID: containerID,
|
||||
ServiceName: serviceLabel,
|
||||
DockerName: containerName,
|
||||
Image: event.Actor.Attributes["image"],
|
||||
CreatedAt: fmt.Sprint(event.TimeNano),
|
||||
Network: containerNetwork,
|
||||
Status: "running",
|
||||
Version: ver,
|
||||
Title: existing.Title,
|
||||
Vendor: existing.Vendor,
|
||||
Author: existing.Author,
|
||||
Description: existing.Description,
|
||||
Source: existing.Source,
|
||||
}
|
||||
if manifest != nil {
|
||||
container.Name = manifest.Name
|
||||
} else {
|
||||
container.Name = containerName
|
||||
}
|
||||
cache.services[key] = container
|
||||
|
||||
case "die":
|
||||
if container, exists := cache.services[containerID]; exists {
|
||||
log.Printf("Service stopping: %s", containerName)
|
||||
container.Status = "stopping"
|
||||
cache.services[containerID] = container
|
||||
if container, exists := cache.services[key]; exists {
|
||||
log.Printf("Service stopped: %s", containerName)
|
||||
container.Status = "stopped"
|
||||
cache.services[key] = container
|
||||
}
|
||||
|
||||
case "destroy":
|
||||
if container, exists := cache.services[containerID]; exists {
|
||||
if container, exists := cache.services[key]; exists {
|
||||
log.Printf("Service destroyed: %s", containerName)
|
||||
cache.RemoveService(container)
|
||||
if IsInstalled(serviceLabel) || IsInstalled(container.ServiceName) || IsInstalled(container.Name) {
|
||||
container.Status = "installed"
|
||||
container.ID = ""
|
||||
container.DockerName = ""
|
||||
container.Network = types.Network{}
|
||||
cache.services[key] = container
|
||||
} else {
|
||||
// We already hold the lock; delete directly instead of
|
||||
// calling RemoveService (which re-locks and deadlocks).
|
||||
delete(cache.services, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,3 +135,77 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// imageTag returns the tag portion of an image reference, defaulting to latest.
|
||||
func imageTag(ref string) string {
|
||||
base := ref
|
||||
if i := strings.LastIndex(base, "/"); i >= 0 {
|
||||
base = base[i+1:]
|
||||
}
|
||||
if i := strings.LastIndex(base, ":"); i >= 0 {
|
||||
return base[i+1:]
|
||||
}
|
||||
return "latest"
|
||||
}
|
||||
|
||||
// normalizeDockerState maps raw Docker container states onto the smaller status
|
||||
// vocabulary used by the frontend (running / starting / stopped / installed).
|
||||
func normalizeDockerState(state string) string {
|
||||
switch state {
|
||||
case "running":
|
||||
return "running"
|
||||
case "restarting", "starting":
|
||||
return "starting"
|
||||
case "exited", "stopped", "paused", "dead", "removing":
|
||||
return "stopped"
|
||||
case "created":
|
||||
return "installed"
|
||||
default:
|
||||
return "installed"
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileContainers lists all dcntrlized-labeled containers (running or not) and
|
||||
// syncs their status into the cache, so state survives a Maestro restart.
|
||||
func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker.DockerClient) {
|
||||
filter := filters.NewArgs()
|
||||
filter.Add("label", "org.dcntrlized.service")
|
||||
options := container.ListOptions{
|
||||
All: true,
|
||||
Filters: filter,
|
||||
}
|
||||
|
||||
existing, err := dc.Client.ContainerList(ctx, options)
|
||||
if err != nil {
|
||||
log.Printf("Failed to list existing containers during reconciliation: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, c := range existing {
|
||||
serviceLabel := c.Labels["org.dcntrlized.service"]
|
||||
if serviceLabel == "" || len(c.Names) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := normalizeName(serviceLabel)
|
||||
cache.Lock()
|
||||
entry, has := cache.services[key]
|
||||
if !has {
|
||||
entry = types.Container{
|
||||
ServiceName: serviceLabel,
|
||||
Name: strings.TrimPrefix(c.Names[0], "/"),
|
||||
Image: c.Image,
|
||||
Version: imageTag(c.Image),
|
||||
Status: "installed",
|
||||
}
|
||||
}
|
||||
entry.Status = normalizeDockerState(c.State)
|
||||
entry.ID = c.ID
|
||||
entry.DockerName = strings.TrimPrefix(c.Names[0], "/")
|
||||
cache.services[key] = entry
|
||||
cache.Unlock()
|
||||
}
|
||||
|
||||
cache.TriggerUpdate()
|
||||
log.Printf("Reconciled %d existing containers with Docker", len(existing))
|
||||
}
|
||||
|
||||
@@ -25,14 +25,15 @@ type Network struct {
|
||||
}
|
||||
|
||||
type Container struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DockerName string `json:"docker_name"`
|
||||
Image string `json:"image"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Network Network `json:"network"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ServiceName string `json:"service_name,omitempty"`
|
||||
DockerName string `json:"docker_name"`
|
||||
Image string `json:"image"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Network Network `json:"network"`
|
||||
|
||||
Title string `json:"title,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package types
|
||||
|
||||
// RegistryContainer represents a clean API payload specifically for container registry items.
|
||||
type RegistryContainer struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Image string `json:"image"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Revision string `json:"revision,omitempty"`
|
||||
Licenses string `json:"licenses,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Documentation string `json:"documentation,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
type RawPackage struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Repository *struct {
|
||||
HTMLURL string `json:"html_url"`
|
||||
} `json:"repository"`
|
||||
}
|
||||
|
||||
// InstalledMetadata is the persisted install record written to the container data dir at install time.
|
||||
type InstalledMetadata struct {
|
||||
RegistryContainer
|
||||
InstalledAt string `json:"installed_at,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user