From 6847c48c66e8386b588eb4c24b956be8adba7a1d Mon Sep 17 00:00:00 2001 From: Julian <70802809+Unbreathable@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:42:42 +0100 Subject: [PATCH 01/10] feat: Start work on database drivers --- mdatabase/database.go | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 mdatabase/database.go diff --git a/mdatabase/database.go b/mdatabase/database.go new file mode 100644 index 0000000..5548f3a --- /dev/null +++ b/mdatabase/database.go @@ -0,0 +1,6 @@ +package mdatabase + +// TODO: Extract postgres to external methods and create this interface based on it +type DatabaseDriver interface { + GetUniqueId() string +} From 876aabbf4e94d0a25e715966e68454a2472cbe25 Mon Sep 17 00:00:00 2001 From: Julian <70802809+Unbreathable@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:09:23 +0100 Subject: [PATCH 02/10] feat: Migrate some PostgreSQL driver logic --- mdatabase/database.go | 6 -- mrunner/mdatabase/database.go | 14 +++ mrunner/mdatabase/postgres.go | 163 ++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 6 deletions(-) delete mode 100644 mdatabase/database.go create mode 100644 mrunner/mdatabase/database.go create mode 100644 mrunner/mdatabase/postgres.go diff --git a/mdatabase/database.go b/mdatabase/database.go deleted file mode 100644 index 5548f3a..0000000 --- a/mdatabase/database.go +++ /dev/null @@ -1,6 +0,0 @@ -package mdatabase - -// TODO: Extract postgres to external methods and create this interface based on it -type DatabaseDriver interface { - GetUniqueId() string -} diff --git a/mrunner/mdatabase/database.go b/mrunner/mdatabase/database.go new file mode 100644 index 0000000..b33b837 --- /dev/null +++ b/mrunner/mdatabase/database.go @@ -0,0 +1,14 @@ +package mdatabase + +// TODO: Extract postgres to external methods and create this interface based on it +type DatabaseDriver interface { + GetUniqueId() string +} + +// TODO: Figure out proper environment variable handling + +// All things required to create a database container +type DatabaseContainerAllocation struct { + Name string + Port int +} diff --git a/mrunner/mdatabase/postgres.go b/mrunner/mdatabase/postgres.go new file mode 100644 index 0000000..c8c5c25 --- /dev/null +++ b/mrunner/mdatabase/postgres.go @@ -0,0 +1,163 @@ +package mdatabase + +import ( + "context" + "fmt" + "log" + "net/netip" + "os" + "strings" + "time" + + _ "github.com/lib/pq" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" +) + +const ( + DefaultPostgresUsername = "postgres" + DefaultPostgresPassword = "postgres" +) + +var pgLog *log.Logger = log.New(os.Stdout, "pg-manager ", log.Default().Flags()) + +type PostgresDriver struct{} + +// A unique identifier for the database container +func (pd *PostgresDriver) GetUniqueId() string { + return "postgres" +} + +// Should create a new container for the database or use the existing one (returns container id + error in case one happened) +func (pd *PostgresDriver) StartContainer(ctx context.Context, a DatabaseContainerAllocation, c *client.Client) (string, error) { + + // Check if the container already exists + f := make(client.Filters) + f.Add("name", a.Name) + summary, err := c.ContainerList(ctx, client.ContainerListOptions{ + Filters: f, + All: true, + }) + if err != nil { + return "", fmt.Errorf("couldn't list containers: %s", err) + } + containerId := "" + var mounts []mount.Mount = nil + for _, container := range summary.Items { + for _, n := range container.Names { + if n == a.Name { + pgLog.Println("Found existing container...") + containerId = container.ID + + // Inspect the container to get the mounts + resp, err := c.ContainerInspect(ctx, container.ID, client.ContainerInspectOptions{}) + if err != nil { + return "", fmt.Errorf("couldn't inspect container: %s", err) + } + mounts = resp.Container.HostConfig.Mounts + } + } + } + + // Delete the container if it exists + if containerId != "" { + if _, err := c.ContainerRemove(ctx, containerId, client.ContainerRemoveOptions{ + RemoveVolumes: false, + Force: true, + }); err != nil { + return "", fmt.Errorf("couldn't delete database container: %s", err) + } + } + + // Create the port on the postgres container (this is not the port for outside) + port, err := network.ParsePort("5432/tcp") + if err != nil { + return "", fmt.Errorf("couldn't create port for postgres container: %s", err) + } + exposedPorts := network.PortSet{port: struct{}{}} + + // If no existing mounts, create a new volume for PostgreSQL data + if mounts == nil { + volumeName := fmt.Sprintf("%s-data", a.Name) + mounts = []mount.Mount{ + { + Type: mount.TypeVolume, + Source: volumeName, + Target: "/var/lib/postgresql/data", + }, + } + } + + // Create the network config for the container (exposes the container to the host) + networkConf := &container.HostConfig{ + PortBindings: network.PortMap{ + port: []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: fmt.Sprintf("%d", a.Port)}}, + }, + Mounts: mounts, + } + + // Check if an environment variable is set for the postgres image + postgresImage := os.Getenv("MAGIC_POSTGRES_IMAGE") + if postgresImage == "" { + postgresImage = "postgres:latest" + } + + // Create the container + resp, err := c.ContainerCreate(ctx, client.ContainerCreateOptions{ + Config: &container.Config{ + Image: postgresImage, + Env: []string{ + fmt.Sprintf("POSTGRES_PASSWORD=%s", DefaultPostgresPassword), + fmt.Sprintf("POSTGRES_USER=%s", DefaultPostgresUsername), + "POSTGRES_DATABASE=postgres", + }, + ExposedPorts: exposedPorts, + }, + HostConfig: networkConf, + Name: a.Name, + }) + if err != nil { + return "", fmt.Errorf("couldn't create postgres container: %s", err) + } + + // Start the container + pgLog.Println("Trying to start container...") + if _, err := c.ContainerStart(ctx, containerId, client.ContainerStartOptions{}); err != nil { + return "", fmt.Errorf("couldn't start postgres container: %s", err) + } + + // Wait for the container to start (with pg_isready) + pgLog.Println("Waiting for PostgreSQL to be ready...") + readyCommand := "pg_isready -d postgres" + cmd := strings.Split(readyCommand, " ") + execConfig := client.ExecCreateOptions{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + } + for { + execIDResp, err := c.ExecCreate(ctx, containerId, execConfig) + if err != nil { + return "", fmt.Errorf("couldn't create command for readiness of container: %s", err) + } + execStartCheck := client.ExecStartOptions{Detach: false, TTY: false} + if _, err := c.ExecStart(ctx, execIDResp.ID, execStartCheck); err != nil { + return "", fmt.Errorf("couldn't start command for readiness of container: %s", err) + } + respInspect, err := c.ExecInspect(ctx, execIDResp.ID, client.ExecInspectOptions{}) + if err != nil { + return "", fmt.Errorf("couldn't inspect command for readiness of container: %s", err) + } + if respInspect.ExitCode == 0 { + break + } + + time.Sleep(200 * time.Millisecond) + } + time.Sleep(200 * time.Millisecond) // Some additional time, sometimes takes longer + + pgLog.Println("Database container started.") + return resp.ID, nil +} From 1bdde47643049a3824c70af165cf468d77016fa1 Mon Sep 17 00:00:00 2001 From: Julian <70802809+Unbreathable@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:08:13 +0100 Subject: [PATCH 03/10] feat: More progress towards a service layer --- mrunner/mdatabase/database.go | 14 --- mrunner/{mdatabase => mservices}/postgres.go | 110 +++++++++++++------ mrunner/mservices/service.go | 23 ++++ 3 files changed, 100 insertions(+), 47 deletions(-) delete mode 100644 mrunner/mdatabase/database.go rename mrunner/{mdatabase => mservices}/postgres.go (60%) create mode 100644 mrunner/mservices/service.go diff --git a/mrunner/mdatabase/database.go b/mrunner/mdatabase/database.go deleted file mode 100644 index b33b837..0000000 --- a/mrunner/mdatabase/database.go +++ /dev/null @@ -1,14 +0,0 @@ -package mdatabase - -// TODO: Extract postgres to external methods and create this interface based on it -type DatabaseDriver interface { - GetUniqueId() string -} - -// TODO: Figure out proper environment variable handling - -// All things required to create a database container -type DatabaseContainerAllocation struct { - Name string - Port int -} diff --git a/mrunner/mdatabase/postgres.go b/mrunner/mservices/postgres.go similarity index 60% rename from mrunner/mdatabase/postgres.go rename to mrunner/mservices/postgres.go index c8c5c25..26a8dfb 100644 --- a/mrunner/mdatabase/postgres.go +++ b/mrunner/mservices/postgres.go @@ -1,4 +1,4 @@ -package mdatabase +package mservices import ( "context" @@ -7,7 +7,6 @@ import ( "net/netip" "os" "strings" - "time" _ "github.com/lib/pq" "github.com/moby/moby/api/types/container" @@ -23,7 +22,36 @@ const ( var pgLog *log.Logger = log.New(os.Stdout, "pg-manager ", log.Default().Flags()) -type PostgresDriver struct{} +type PostgresDriver struct { + image string + + // Database credentials + username string + password string + + databases []string +} + +func NewPostgresDriver(image string) *PostgresDriver { + return &PostgresDriver{ + image: image, + } +} + +func (pd *PostgresDriver) WithUsername(name string) *PostgresDriver { + pd.username = name + return pd +} + +func (pd *PostgresDriver) WithPassword(password string) *PostgresDriver { + pd.password = password + return pd +} + +func (pd *PostgresDriver) NewDatabase(name string) *PostgresDriver { + pd.databases = append(pd.databases, name) + return pd +} // A unique identifier for the database container func (pd *PostgresDriver) GetUniqueId() string { @@ -31,7 +59,18 @@ func (pd *PostgresDriver) GetUniqueId() string { } // Should create a new container for the database or use the existing one (returns container id + error in case one happened) -func (pd *PostgresDriver) StartContainer(ctx context.Context, a DatabaseContainerAllocation, c *client.Client) (string, error) { +func (pd *PostgresDriver) StartContainer(ctx context.Context, c *client.Client, a ContainerAllocation) (string, error) { + + // Set to default username and password when not set + if pd.username == "" { + pd.username = DefaultPostgresUsername + } + if pd.password == "" { + pd.password = DefaultPostgresPassword + } + if pd.image == "" { + pd.image = "postgres:latest" + } // Check if the container already exists f := make(client.Filters) @@ -98,19 +137,21 @@ func (pd *PostgresDriver) StartContainer(ctx context.Context, a DatabaseContaine Mounts: mounts, } - // Check if an environment variable is set for the postgres image - postgresImage := os.Getenv("MAGIC_POSTGRES_IMAGE") - if postgresImage == "" { - postgresImage = "postgres:latest" + // Pull the image + pgLog.Println("Pulling image", pd.image, "...") + pullResponse, err := c.ImagePull(ctx, pd.image, client.ImagePullOptions{}) + if err != nil { + return "", fmt.Errorf("couldn't pull image %s: %v", pd.image, err) } + pullResponse.Wait(ctx) // Create the container resp, err := c.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: &container.Config{ - Image: postgresImage, + Image: pd.image, Env: []string{ - fmt.Sprintf("POSTGRES_PASSWORD=%s", DefaultPostgresPassword), - fmt.Sprintf("POSTGRES_USER=%s", DefaultPostgresUsername), + fmt.Sprintf("POSTGRES_PASSWORD=%s", pd.password), + fmt.Sprintf("POSTGRES_USER=%s", pd.username), "POSTGRES_DATABASE=postgres", }, ExposedPorts: exposedPorts, @@ -128,8 +169,12 @@ func (pd *PostgresDriver) StartContainer(ctx context.Context, a DatabaseContaine return "", fmt.Errorf("couldn't start postgres container: %s", err) } - // Wait for the container to start (with pg_isready) - pgLog.Println("Waiting for PostgreSQL to be ready...") + pgLog.Println("Database container started.") + return resp.ID, nil +} + +// Check for postgres health +func (pd *PostgresDriver) IsHealthy(ctx context.Context, c *client.Client, id string) (bool, error) { readyCommand := "pg_isready -d postgres" cmd := strings.Split(readyCommand, " ") execConfig := client.ExecCreateOptions{ @@ -137,27 +182,26 @@ func (pd *PostgresDriver) StartContainer(ctx context.Context, a DatabaseContaine AttachStdout: true, AttachStderr: true, } - for { - execIDResp, err := c.ExecCreate(ctx, containerId, execConfig) - if err != nil { - return "", fmt.Errorf("couldn't create command for readiness of container: %s", err) - } - execStartCheck := client.ExecStartOptions{Detach: false, TTY: false} - if _, err := c.ExecStart(ctx, execIDResp.ID, execStartCheck); err != nil { - return "", fmt.Errorf("couldn't start command for readiness of container: %s", err) - } - respInspect, err := c.ExecInspect(ctx, execIDResp.ID, client.ExecInspectOptions{}) - if err != nil { - return "", fmt.Errorf("couldn't inspect command for readiness of container: %s", err) - } - if respInspect.ExitCode == 0 { - break - } - time.Sleep(200 * time.Millisecond) + // Try to execute the command + execIDResp, err := c.ExecCreate(ctx, id, execConfig) + if err != nil { + return false, fmt.Errorf("couldn't create command for readiness of container: %s", err) + } + execStartCheck := client.ExecStartOptions{Detach: false, TTY: false} + if _, err := c.ExecStart(ctx, execIDResp.ID, execStartCheck); err != nil { + return false, fmt.Errorf("couldn't start command for readiness of container: %s", err) + } + respInspect, err := c.ExecInspect(ctx, execIDResp.ID, client.ExecInspectOptions{}) + if err != nil { + return false, fmt.Errorf("couldn't inspect command for readiness of container: %s", err) } - time.Sleep(200 * time.Millisecond) // Some additional time, sometimes takes longer - pgLog.Println("Database container started.") - return resp.ID, nil + return respInspect.ExitCode == 0, nil +} + +// Initialize the internal container with a script (for example) +func (pd *PostgresDriver) Initialize(ctx context.Context, c *client.Client, id string) error { + // TODO: Create + return nil } diff --git a/mrunner/mservices/service.go b/mrunner/mservices/service.go new file mode 100644 index 0000000..4438564 --- /dev/null +++ b/mrunner/mservices/service.go @@ -0,0 +1,23 @@ +package mservices + +import ( + "context" + + "github.com/moby/moby/client" +) + +// TODO: Extract postgres to external methods and create this interface based on it +type ServiceDriver interface { + GetUniqueId() string + CreateContainer(ctx context.Context, c *client.Client, a ContainerAllocation) (string, error) + IsHealthy(ctx context.Context, c *client.Client, id string) (bool, error) + Initialize(ctx context.Context, c *client.Client, id string) error +} + +// TODO: Figure out proper environment variable handling + +// All things required to create a service container +type ContainerAllocation struct { + Name string + Port int +} From ccacb05f31897b9f3d6a3c376fc5e62c6333f7f6 Mon Sep 17 00:00:00 2001 From: Unbreathable <70802809+Unbreathable@users.noreply.github.com> Date: Fri, 6 Feb 2026 19:22:42 +0100 Subject: [PATCH 04/10] feat: Postgres Driver and ServiceDriver interface finished --- go.mod | 29 +---- go.sum | 211 ++++------------------------------ mrunner/mservices/postgres.go | 133 +++++++++++++++++++-- mrunner/mservices/service.go | 47 ++++++-- 4 files changed, 192 insertions(+), 228 deletions(-) diff --git a/go.mod b/go.mod index c10aa70..2db8055 100644 --- a/go.mod +++ b/go.mod @@ -4,25 +4,19 @@ go 1.24.3 require ( github.com/charmbracelet/huh v0.8.0 - github.com/docker/go-connections v0.6.0 github.com/go-playground/validator/v10 v10.29.0 github.com/gofrs/flock v0.13.0 - github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/moby/moby/api v1.52.0 github.com/moby/moby/client v0.2.1 github.com/spf13/pflag v1.0.10 - github.com/tiemingo/greentea v0.0.0-20250617100431-2fb1d40d1668 - github.com/urfave/cli/v3 v3.6.1 ) require ( - github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect @@ -37,8 +31,8 @@ require ( github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containerd/log v0.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -48,9 +42,6 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -58,37 +49,19 @@ require ( github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/sys/atomicwriter v0.1.0 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect - github.com/moby/term v0.5.2 // indirect - github.com/morikuni/aec v1.1.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/sdk v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/crypto v0.46.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.77.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect - gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 2e10a8f..b909f38 100644 --- a/go.sum +++ b/go.sum @@ -1,57 +1,45 @@ -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= -github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= -github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= -github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= -github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI= github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/strings v0.0.0-20251212161403-a3028fabe6bc h1:eQqG71BSPjLL8kIcuQflwQkaZu0naX6EVNVIrKTtgCQ= github.com/charmbracelet/x/exp/strings v0.0.0-20251212161403-a3028fabe6bc/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY= github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -62,19 +50,12 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= -github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= -github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -85,58 +66,37 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk= github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4= -github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= @@ -147,16 +107,6 @@ github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg= github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= github.com/moby/moby/client v0.2.1 h1:1Grh1552mvv6i+sYOdY+xKKVTvzJegcVMhuXocyDz/k= github.com/moby/moby/client v0.2.1/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE= -github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= -github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= -github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= -github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -167,154 +117,43 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tiemingo/greentea v0.0.0-20250612122629-aa3c73fc4f18 h1:nOnrFWsADF9DYYEO1IqgLjbZfLFSLC/z+ICir1zMtrk= -github.com/tiemingo/greentea v0.0.0-20250612122629-aa3c73fc4f18/go.mod h1:AXVD0HaBuulWWxSAHv8H0o/L87eQ0NrHBpwQ548TpmM= -github.com/tiemingo/greentea v0.0.0-20250617100431-2fb1d40d1668 h1:iEtu0ACmn5aFK6W266169HYpf0fLpmn/2zUDE1gswgo= -github.com/tiemingo/greentea v0.0.0-20250617100431-2fb1d40d1668/go.mod h1:AXVD0HaBuulWWxSAHv8H0o/L87eQ0NrHBpwQ548TpmM= -github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= -github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= -github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= -github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= -go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= -google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/mrunner/mservices/postgres.go b/mrunner/mservices/postgres.go index 26a8dfb..5442f97 100644 --- a/mrunner/mservices/postgres.go +++ b/mrunner/mservices/postgres.go @@ -2,6 +2,7 @@ package mservices import ( "context" + "database/sql" "fmt" "log" "net/netip" @@ -15,6 +16,9 @@ import ( "github.com/moby/moby/client" ) +// Make sure the driver complies +var _ ServiceDriver = &PostgresDriver{} + const ( DefaultPostgresUsername = "postgres" DefaultPostgresPassword = "postgres" @@ -32,7 +36,28 @@ type PostgresDriver struct { databases []string } +// Create a new PostgreSQL service driver. +// +// It currently supports version 14-17. +// +// This driver will eventually be renamed into the legacy driver for people who still want to use PostgreSQL v17 or lower. Eventually it will be deprecated and fully removed (only once the v18 driver is available). func NewPostgresDriver(image string) *PostgresDriver { + imageVersion := strings.Split(image, ":")[1] + + // Supported (confirmed and tested) major versions for this Postgres driver + var supportedPostgresVersions = []string{"14", "15", "16", "17"} + + // Do a quick check to make sure the image version is actually supported + supported := false + for _, version := range supportedPostgresVersions { + if strings.HasPrefix(imageVersion, fmt.Sprintf("%s.", version)) { + supported = true + } + } + if !supported { + pgLog.Fatalln("ERROR: Version", imageVersion, "is currently not supported.") + } + return &PostgresDriver{ image: image, } @@ -55,11 +80,19 @@ func (pd *PostgresDriver) NewDatabase(name string) *PostgresDriver { // A unique identifier for the database container func (pd *PostgresDriver) GetUniqueId() string { - return "postgres" + return "postgres1417" +} + +func (pd *PostgresDriver) GetRequiredPortAmount() int { + return 1 +} + +func (pd *PostgresDriver) GetImage() string { + return pd.image } // Should create a new container for the database or use the existing one (returns container id + error in case one happened) -func (pd *PostgresDriver) StartContainer(ctx context.Context, c *client.Client, a ContainerAllocation) (string, error) { +func (pd *PostgresDriver) CreateContainer(ctx context.Context, c *client.Client, a ContainerAllocation) (string, error) { // Set to default username and password when not set if pd.username == "" { @@ -132,7 +165,7 @@ func (pd *PostgresDriver) StartContainer(ctx context.Context, c *client.Client, // Create the network config for the container (exposes the container to the host) networkConf := &container.HostConfig{ PortBindings: network.PortMap{ - port: []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: fmt.Sprintf("%d", a.Port)}}, + port: []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: fmt.Sprintf("%d", a.Ports[0])}}, }, Mounts: mounts, } @@ -174,7 +207,7 @@ func (pd *PostgresDriver) StartContainer(ctx context.Context, c *client.Client, } // Check for postgres health -func (pd *PostgresDriver) IsHealthy(ctx context.Context, c *client.Client, id string) (bool, error) { +func (pd *PostgresDriver) IsHealthy(ctx context.Context, c *client.Client, container ContainerInformation) (bool, error) { readyCommand := "pg_isready -d postgres" cmd := strings.Split(readyCommand, " ") execConfig := client.ExecCreateOptions{ @@ -184,7 +217,7 @@ func (pd *PostgresDriver) IsHealthy(ctx context.Context, c *client.Client, id st } // Try to execute the command - execIDResp, err := c.ExecCreate(ctx, id, execConfig) + execIDResp, err := c.ExecCreate(ctx, container.ID, execConfig) if err != nil { return false, fmt.Errorf("couldn't create command for readiness of container: %s", err) } @@ -201,7 +234,93 @@ func (pd *PostgresDriver) IsHealthy(ctx context.Context, c *client.Client, id st } // Initialize the internal container with a script (for example) -func (pd *PostgresDriver) Initialize(ctx context.Context, c *client.Client, id string) error { - // TODO: Create +func (pd *PostgresDriver) Initialize(ctx context.Context, c *client.Client, container ContainerInformation) error { + connStr := fmt.Sprintf("host=127.0.0.1 port=%d user=postgres password=postgres dbname=postgres sslmode=disable", container.Ports[0]) + + // Connect to the database + conn, err := sql.Open("postgres", connStr) + if err != nil { + return fmt.Errorf("couldn't connect to postgres: %s", err) + } + defer conn.Close() + + for _, db := range pd.databases { + pgLog.Println("Creating database", db+"...") + _, err := conn.Exec(fmt.Sprintf("CREATE DATABASE %s", db)) + if err != nil && !strings.Contains(err.Error(), "already exists") { + return fmt.Errorf("couldn't create postgres database: %s", err) + } + } + + return nil +} + +// Handles the instructions for PostgreSQL. +// +// Supports the following instructions currently: +// - Clear tables +// - Drop tables +func (pd *PostgresDriver) HandleInstruction(ctx context.Context, c *client.Client, container ContainerInformation, instruction Instruction) error { + switch instruction { + case InstructionClearTables: + return pd.ClearTables(container) + case InstructionDropTables: + return pd.DropTables(container) + } return nil } + +// iterateTablesFn is a function that processes each table in the database +type iterateTablesFn func(tableName string, conn *sql.DB) error + +// iterateTables iterates through all tables in all databases and applies the given function +func (pd *PostgresDriver) iterateTables(container ContainerInformation, fn iterateTablesFn) error { + // For all databases, connect and iterate tables + for _, db := range pd.databases { + connStr := fmt.Sprintf("host=127.0.0.1 port=%d user=postgres password=postgres dbname=%s sslmode=disable", container.Ports[0], db) + + // Connect to the database + conn, err := sql.Open("postgres", connStr) + if err != nil { + return fmt.Errorf("couldn't connect to postgres: %v", err) + } + defer conn.Close() + + // Get all of the tables + res, err := conn.Query("SELECT table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema')") + if err != nil { + return fmt.Errorf("couldn't get database tables: %v", err) + } + for res.Next() { + var name string + if err := res.Scan(&name); err != nil { + return fmt.Errorf("couldn't get database table name: %v", err) + } + if err := fn(name, conn); err != nil { + return err + } + } + } + + return nil +} + +// Clear all tables in all databases (keeps table schema alive, just removes the content of all tables) +func (pd *PostgresDriver) ClearTables(container ContainerInformation) error { + return pd.iterateTables(container, func(tableName string, conn *sql.DB) error { + if _, err := conn.Exec(fmt.Sprintf("truncate %s CASCADE", tableName)); err != nil { + return fmt.Errorf("couldn't truncate table %s: %v", tableName, err) + } + return nil + }) +} + +// Drop all tables in all databases (actually deletes all of your tables) +func (pd *PostgresDriver) DropTables(container ContainerInformation) error { + return pd.iterateTables(container, func(tableName string, conn *sql.DB) error { + if _, err := conn.Exec(fmt.Sprintf("DROP TABLE %s CASCADE", tableName)); err != nil { + return fmt.Errorf("couldn't drop table table %s: %v", tableName, err) + } + return nil + }) +} diff --git a/mrunner/mservices/service.go b/mrunner/mservices/service.go index 4438564..ee7c62f 100644 --- a/mrunner/mservices/service.go +++ b/mrunner/mservices/service.go @@ -6,18 +6,51 @@ import ( "github.com/moby/moby/client" ) -// TODO: Extract postgres to external methods and create this interface based on it +// An instruction to do something with a container. +// +// This is used by Magic to for example tell database providers to clear their databases. +type Instruction string + +const ( + InstructionDropTables Instruction = "database:drop_tables" + InstructionClearTables Instruction = "database:clear_tables" +) + +// A service driver is a manager for containers running a particular service image. +// +// That can be databases or literally anything you could imagine. It provides a unified interface for Magic to be able to properly control those Docker containers. type ServiceDriver interface { GetUniqueId() string + + // Should return the amount of ports required to start the container. + GetRequiredPortAmount() int + + // Should return the image. Magic will + GetImage() string + + // Create a new container for this type of service CreateContainer(ctx context.Context, c *client.Client, a ContainerAllocation) (string, error) - IsHealthy(ctx context.Context, c *client.Client, id string) (bool, error) - Initialize(ctx context.Context, c *client.Client, id string) error -} -// TODO: Figure out proper environment variable handling + // This method should check if the container with the id is healthy for this service + IsHealthy(ctx context.Context, c *client.Client, container ContainerInformation) (bool, error) + + // Called to initialize the container when it is healthy + Initialize(ctx context.Context, c *client.Client, container ContainerInformation) error + + // An instruction sent down from Magic to potentially do something with the container (not every service has to handle every instruction). + // + // When implementing, please look into the instructions you can support. + HandleInstruction(ctx context.Context, c *client.Client, container ContainerInformation, instruction Instruction) error +} // All things required to create a service container type ContainerAllocation struct { - Name string - Port int + Name string + Ports []int +} + +type ContainerInformation struct { + ID string + Name string + Ports []int } From 23260a8c3caee0e49f90fd68381fce763f25306f Mon Sep 17 00:00:00 2001 From: Unbreathable <70802809+Unbreathable@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:37:02 +0100 Subject: [PATCH 05/10] feat: Integrated services properly - Services are now actually managed by the runner - Renamed postgres driver to postgres_legacy (and separated it into more files) - EnvironmentValue support for the Postgres Driver - Implemented the new instruction layer into the Runner --- mconfig/context.go | 38 +- mconfig/databases.go | 102 ---- mconfig/environment.go | 4 + mconfig/plan.go | 66 +-- .../service.go => mconfig/services.go | 32 +- mrunner/databases/init.go | 7 + mrunner/databases/postgres_legacy.go | 104 +++++ .../databases/postgres_legacy_container.go | 158 +++++++ mrunner/databases/postgres_legacy_instruct.go | 79 ++++ mrunner/mservices/postgres.go | 326 ------------- mrunner/runner.go | 26 +- mrunner/runner_deploy.go | 442 +++++++++--------- mrunner/runner_plan.go | 89 ++-- 13 files changed, 661 insertions(+), 812 deletions(-) delete mode 100644 mconfig/databases.go rename mrunner/mservices/service.go => mconfig/services.go (64%) create mode 100644 mrunner/databases/init.go create mode 100644 mrunner/databases/postgres_legacy.go create mode 100644 mrunner/databases/postgres_legacy_container.go create mode 100644 mrunner/databases/postgres_legacy_instruct.go delete mode 100644 mrunner/mservices/postgres.go diff --git a/mconfig/context.go b/mconfig/context.go index 0d5964a..f648836 100644 --- a/mconfig/context.go +++ b/mconfig/context.go @@ -13,9 +13,9 @@ type Context struct { profile string // Current profile directory string // Current working directory environment *Environment // Environment for environment variables (can be nil) - databases []*Database + services []ServiceDriver ports []uint // All ports the user wants to allocate - plan **Plan // For later filling in with actual information + plan *Plan // For later filling in with actual information } // The app name you set in your config. @@ -78,33 +78,20 @@ func (c *Context) LoadSecretsToEnvironment(path string) error { return nil } -// Get the databases. -func (c *Context) Databases() []*Database { - return c.databases +// Get all services requested. +func (c *Context) Services() []ServiceDriver { + return c.services } // Plan for later (DO NOT EXPECT THIS TO BE FILLED BEFORE DEPLOYMENT STEP) func (c *Context) Plan() *Plan { - return *c.plan + return c.plan } -// Apply a plan for the environment in the config -func (c *Context) ApplyPlan(plan *Plan) { - *c.plan = plan -} - -func (c *Context) NewPostgresDatabase(name string) *Database { - database := &Database{ - dbType: DatabasePostgres, - name: name, - } - c.databases = append(c.databases, database) - return database -} - -// Add a new database. -func (c *Context) AddDatabase(database *Database) { - c.databases = append(c.databases, database) +// Register a service driver for a service +func (c *Context) Register(driver ServiceDriver) ServiceDriver { + c.services = append(c.services, driver) + return driver } func DefaultContext(appName string, profile string) *Context { @@ -113,12 +100,11 @@ func DefaultContext(appName string, profile string) *Context { log.Fatalln("couldn't get current working directory") } - plan := &Plan{} return &Context{ directory: workDir, appName: appName, profile: profile, - databases: []*Database{}, - plan: &plan, + services: []ServiceDriver{}, + plan: &Plan{}, } } diff --git a/mconfig/databases.go b/mconfig/databases.go deleted file mode 100644 index c08d2e6..0000000 --- a/mconfig/databases.go +++ /dev/null @@ -1,102 +0,0 @@ -package mconfig - -import ( - "fmt" -) - -type DatabaseType = uint - -const ( - DatabasePostgres DatabaseType = 1 -) - -type Database struct { - dbType DatabaseType // Type of the database - name string -} - -func (db *Database) Type() DatabaseType { - return db.dbType -} - -// Get the name of the database (as in the config) -func (db *Database) Name() string { - return db.name -} - -// Get the host of the database for environment variables -func (db *Database) Host(ctx *Context) EnvironmentValue { - return EnvironmentValue{ - get: func() string { - return ctx.Plan().Database(db.name).Hostname - }, - } -} - -// Get the name of the database for environment variables -func (db *Database) DatabaseName(ctx *Context) EnvironmentValue { - return EnvironmentValue{ - get: func() string { - return ctx.Plan().Database(db.name).Name - }, - } -} - -// Get the port of the database for environment variables -func (db *Database) Port(ctx *Context) EnvironmentValue { - return EnvironmentValue{ - get: func() string { - return fmt.Sprintf("%d", ctx.Plan().Database(db.name).Port) - }, - } -} - -// Get the password of the database for environment variables -func (db *Database) Password() EnvironmentValue { - return ValueStatic(db.DefaultPassword()) -} - -// Get the username of the database for environment variables -func (db *Database) Username() EnvironmentValue { - return ValueStatic(db.DefaultUsername()) -} - -// Get the default password for the database type -func (db *Database) DefaultPassword() string { - return DefaultPassword(db.dbType) -} - -// Get the default username for the database type -func (db *Database) DefaultUsername() string { - return DefaultUsername(db.dbType) -} - -// Get the default name for the database using the runner -func (db *Database) DefaultDatabaseName(ctx *Context) string { - return DefaultDatabaseName(ctx.profile, db.name) -} - -// Get the default password for a database by type. -func DefaultPassword(dbType DatabaseType) string { - switch dbType { - case DatabasePostgres: - return "postgres" - default: - return "admin" - } -} - -// Get the default username for a database by type. -func DefaultUsername(dbType DatabaseType) string { - switch dbType { - case DatabasePostgres: - return "postgres" - default: - return "admin" - } -} - -// Get the default database name for a database. -func DefaultDatabaseName(profile string, databaseName string) string { - return fmt.Sprintf("%s:%s", profile, databaseName) -} diff --git a/mconfig/environment.go b/mconfig/environment.go index 66e8574..cc16feb 100644 --- a/mconfig/environment.go +++ b/mconfig/environment.go @@ -30,6 +30,10 @@ func ValueStatic(value string) EnvironmentValue { } } +func ValueFunction(get func() string) EnvironmentValue { + return EnvironmentValue{get} +} + // Create a new environment value based on other environment values. // // The index in the values array matches the output of the environment value. diff --git a/mconfig/plan.go b/mconfig/plan.go index d584a09..a8d67cd 100644 --- a/mconfig/plan.go +++ b/mconfig/plan.go @@ -3,40 +3,27 @@ package mconfig import ( "encoding/json" "fmt" - "log" "strings" "unicode" ) -type Plan struct { - AppName string `json:"app_name"` - Profile string `json:"profile"` - Environment map[string]string `json:"environment"` - DatabaseTypes []PlannedDatabaseType `json:"database_types"` - AllocatedPorts map[uint]uint `json:"ports"` -} - -type PlannedDatabaseType struct { - Port uint `json:"port"` - Type DatabaseType `json:"type"` - Databases []PlannedDatabase `json:"databases"` -} - -// Name for the database Docker container -func (p *PlannedDatabaseType) ContainerName(appName string, profile string) string { +// Name for a service Docker container +func ContainerName[S ServiceDriver](appName string, profile string, driver S) string { appName = EverythingToSnakeCase(appName) - return fmt.Sprintf("mgc-%s-%s-%d", appName, profile, p.Type) + return fmt.Sprintf("mgc-%s-%s-%s", appName, profile, driver.GetUniqueId()) } -type PlannedDatabase struct { - Name string `json:"name"` - Username string `json:"username"` - Password string `json:"password"` - Hostname string `json:"hostname"` +type Plan struct { + AppName string `json:"app_name"` + Profile string `json:"profile"` + Environment map[string]string `json:"environment"` + AllocatedPorts map[uint]uint `json:"ports"` + Containers map[string]ContainerAllocation `json:"containers"` // Id -> Container allocation +} - // Just for developers to access, not included in actual plan - Type DatabaseType `json:"-"` - Port uint `json:"-"` +// Name for a service container (get by plan) +func PlannedContainerName[S ServiceDriver](plan *Plan, driver S) string { + return ContainerName(plan.AppName, plan.Profile, driver) } // Turn the plan into printable form @@ -59,33 +46,6 @@ func FromPrintable(printable string) (*Plan, error) { return plan, nil } -// Get a database by its name. Panics when it can't find the database. -func (p *Plan) Database(name string) PlannedDatabase { - foundDB := PlannedDatabase{} - found := false - for _, t := range p.DatabaseTypes { - for _, db := range t.Databases { - if db.Name == name { - if found { - log.Fatalln("The database", name, "exists in the config more than once.") - } - found = true - foundDB = db - foundDB.Port = t.Port - } - } - } - if !found { - log.Fatalln("Database", name, "couldn't be found in the plan!") - } - return foundDB -} - -// Generate a connection string for the database. -func (db PlannedDatabase) ConnectString() string { - return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", db.Hostname, db.Port, db.Username, db.Password, db.Name) -} - // Convert every character except for letters and digits directly to _ func EverythingToSnakeCase(s string) string { newString := "" diff --git a/mrunner/mservices/service.go b/mconfig/services.go similarity index 64% rename from mrunner/mservices/service.go rename to mconfig/services.go index ee7c62f..bc22965 100644 --- a/mrunner/mservices/service.go +++ b/mconfig/services.go @@ -1,7 +1,8 @@ -package mservices +package mconfig import ( "context" + "sync" "github.com/moby/moby/client" ) @@ -45,12 +46,31 @@ type ServiceDriver interface { // All things required to create a service container type ContainerAllocation struct { - Name string - Ports []int + Name string `json:"name"` + Ports []uint `json:"ports"` } type ContainerInformation struct { - ID string - Name string - Ports []int + ID string `json:"id"` + Name string `json:"name"` + Ports []uint `json:"ports"` +} + +// Service registry for making sure all of the services can be created from their unique IDs (important for instruction calling outside of the main process). +// +// Service (string) -> Service Driver +var serviceRegistry *sync.Map = &sync.Map{} + +// Register a service driver for instruction calling (THIS IS NOT THE DRIVER ACTUALLY USED TO CREATE YOUR DATABASES, DO NOT USE OUTSIDE OF MAGIC INTERNALLY) +func RegisterDriver(driver ServiceDriver) { + serviceRegistry.Store(driver.GetUniqueId(), driver) +} + +// Get a service driver by its unique id (THIS IS NOT THE DRIVER ACTUALLY USED TO CREATE YOUR DATABASES, DO NOT USE OUTSIDE OF MAGIC INTERNALLY) +func GetDriver(serviceId string) (ServiceDriver, bool) { + obj, ok := serviceRegistry.Load(serviceId) + if !ok { + return nil, false + } + return obj.(ServiceDriver), true } diff --git a/mrunner/databases/init.go b/mrunner/databases/init.go new file mode 100644 index 0000000..f1db133 --- /dev/null +++ b/mrunner/databases/init.go @@ -0,0 +1,7 @@ +package databases + +import "github.com/Liphium/magic/v2/mconfig" + +func init() { + mconfig.RegisterDriver(&PostgresDriver{}) +} diff --git a/mrunner/databases/postgres_legacy.go b/mrunner/databases/postgres_legacy.go new file mode 100644 index 0000000..53655ec --- /dev/null +++ b/mrunner/databases/postgres_legacy.go @@ -0,0 +1,104 @@ +package databases + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/Liphium/magic/v2/mconfig" + "github.com/Liphium/magic/v2/util" + _ "github.com/lib/pq" +) + +// Make sure the driver complies +var _ mconfig.ServiceDriver = &PostgresDriver{} + +// IMPORTANT: Having non-static passwords would make Magic not works as the Container allocation currently does not contain service driver data. +// +// This means that instruction calling would break if we added back password and username changing. +const ( + PostgresUsername = "postgres" + PostgresPassword = "postgres" +) + +var pgLog *log.Logger = log.New(os.Stdout, "pg-manager ", log.Default().Flags()) + +type PostgresDriver struct { + image string + databases []string +} + +// Create a new PostgreSQL legacy service driver. +// +// It currently supports version PostgreSQL v14-17. Use NewPostgresDriver for v18 and beyond. +// +// This driver will eventually be deprecated and replaced by the one for v18 and above. +func NewLegacyPostgresDriver(image string) *PostgresDriver { + imageVersion := strings.Split(image, ":")[1] + + // Supported (confirmed and tested) major versions for this Postgres driver + var supportedPostgresVersions = []string{"14", "15", "16", "17"} + + // Do a quick check to make sure the image version is actually supported + supported := false + for _, version := range supportedPostgresVersions { + if strings.HasPrefix(imageVersion, fmt.Sprintf("%s.", version)) { + supported = true + } + } + if !supported { + pgLog.Fatalln("ERROR: Version", imageVersion, "is currently not supported.") + } + + return &PostgresDriver{ + image: image, + } +} + +func (pd *PostgresDriver) NewDatabase(name string) *PostgresDriver { + pd.databases = append(pd.databases, name) + return pd +} + +// A unique identifier for the database container +func (pd *PostgresDriver) GetUniqueId() string { + return "postgres1417" +} + +func (pd *PostgresDriver) GetRequiredPortAmount() int { + return 1 +} + +func (pd *PostgresDriver) GetImage() string { + return pd.image +} + +// Get the username of the databases in this driver as a EnvironmentValue for your config. +func (pd *PostgresDriver) Username() mconfig.EnvironmentValue { + return mconfig.ValueStatic(PostgresUsername) +} + +// Get the password for the user of the databases in this driver as a EnvironmentValue for your config. +func (pd *PostgresDriver) Password() mconfig.EnvironmentValue { + return mconfig.ValueStatic(PostgresPassword) +} + +// Get hostname of the database container created by the driver as a EnvironmentValue for your config. +func (pd *PostgresDriver) Host(ctx *mconfig.Context) mconfig.EnvironmentValue { + return mconfig.ValueStatic("127.0.0.1") +} + +// Get the port of the database container created by the driver as a EnvironmentValue for your config. +func (pd *PostgresDriver) Port(ctx *mconfig.Context) mconfig.EnvironmentValue { + return mconfig.ValueFunction(func() string { + for _, container := range ctx.Plan().Containers { + if container.Name == mconfig.PlannedContainerName(ctx.Plan(), pd) { + return fmt.Sprintf("%d", ctx.Plan().AllocatedPorts[container.Ports[0]]) + } + } + + util.Log.Fatalln("ERROR: Couldn't find port for PostgreSQL container in plan!") + return "not found" + }) +} diff --git a/mrunner/databases/postgres_legacy_container.go b/mrunner/databases/postgres_legacy_container.go new file mode 100644 index 0000000..9549c1f --- /dev/null +++ b/mrunner/databases/postgres_legacy_container.go @@ -0,0 +1,158 @@ +package databases + +import ( + "context" + "database/sql" + "fmt" + "net/netip" + "strings" + + "github.com/Liphium/magic/v2/mconfig" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" +) + +// Should create a new container for the database or use the existing one (returns container id + error in case one happened) +func (pd *PostgresDriver) CreateContainer(ctx context.Context, c *client.Client, a mconfig.ContainerAllocation) (string, error) { + + // Set to default username and password when not set + if pd.image == "" { + return "", fmt.Errorf("please specify a proper image") + } + + // Check if the container already exists + f := make(client.Filters) + f.Add("name", a.Name) + summary, err := c.ContainerList(ctx, client.ContainerListOptions{ + Filters: f, + All: true, + }) + if err != nil { + return "", fmt.Errorf("couldn't list containers: %s", err) + } + containerId := "" + var mounts []mount.Mount = nil + for _, container := range summary.Items { + for _, n := range container.Names { + if n == a.Name { + pgLog.Println("Found existing container...") + containerId = container.ID + + // Inspect the container to get the mounts + resp, err := c.ContainerInspect(ctx, container.ID, client.ContainerInspectOptions{}) + if err != nil { + return "", fmt.Errorf("couldn't inspect container: %s", err) + } + mounts = resp.Container.HostConfig.Mounts + } + } + } + + // Delete the container if it exists + if containerId != "" { + if _, err := c.ContainerRemove(ctx, containerId, client.ContainerRemoveOptions{ + RemoveVolumes: false, + Force: true, + }); err != nil { + return "", fmt.Errorf("couldn't delete database container: %s", err) + } + } + + // Create the port on the postgres container (this is not the port for outside) + port, err := network.ParsePort("5432/tcp") + if err != nil { + return "", fmt.Errorf("couldn't create port for postgres container: %s", err) + } + exposedPorts := network.PortSet{port: struct{}{}} + + // If no existing mounts, create a new volume for PostgreSQL data + if mounts == nil { + volumeName := fmt.Sprintf("%s-data", a.Name) + mounts = []mount.Mount{ + { + Type: mount.TypeVolume, + Source: volumeName, + Target: "/var/lib/postgresql/data", + }, + } + } + + // Create the network config for the container (exposes the container to the host) + networkConf := &container.HostConfig{ + PortBindings: network.PortMap{ + port: []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: fmt.Sprintf("%d", a.Ports[0])}}, + }, + Mounts: mounts, + } + + // Create the container + resp, err := c.ContainerCreate(ctx, client.ContainerCreateOptions{ + Config: &container.Config{ + Image: pd.image, + Env: []string{ + fmt.Sprintf("POSTGRES_PASSWORD=%s", PostgresPassword), + fmt.Sprintf("POSTGRES_USER=%s", PostgresUsername), + "POSTGRES_DATABASE=postgres", + }, + ExposedPorts: exposedPorts, + }, + HostConfig: networkConf, + Name: a.Name, + }) + if err != nil { + return "", fmt.Errorf("couldn't create postgres container: %s", err) + } + + return resp.ID, nil +} + +// Check for postgres health +func (pd *PostgresDriver) IsHealthy(ctx context.Context, c *client.Client, container mconfig.ContainerInformation) (bool, error) { + readyCommand := "pg_isready -d postgres" + cmd := strings.Split(readyCommand, " ") + execConfig := client.ExecCreateOptions{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + } + + // Try to execute the command + execIDResp, err := c.ExecCreate(ctx, container.ID, execConfig) + if err != nil { + return false, fmt.Errorf("couldn't create command for readiness of container: %s", err) + } + execStartCheck := client.ExecStartOptions{Detach: false, TTY: false} + if _, err := c.ExecStart(ctx, execIDResp.ID, execStartCheck); err != nil { + return false, fmt.Errorf("couldn't start command for readiness of container: %s", err) + } + respInspect, err := c.ExecInspect(ctx, execIDResp.ID, client.ExecInspectOptions{}) + if err != nil { + return false, fmt.Errorf("couldn't inspect command for readiness of container: %s", err) + } + + return respInspect.ExitCode == 0, nil +} + +// Initialize the internal container with a script (for example) +func (pd *PostgresDriver) Initialize(ctx context.Context, c *client.Client, container mconfig.ContainerInformation) error { + connStr := fmt.Sprintf("host=127.0.0.1 port=%d user=postgres password=postgres dbname=postgres sslmode=disable", container.Ports[0]) + + // Connect to the database + conn, err := sql.Open("postgres", connStr) + if err != nil { + return fmt.Errorf("couldn't connect to postgres: %s", err) + } + defer conn.Close() + + for _, db := range pd.databases { + pgLog.Println("Creating database", db+"...") + _, err := conn.Exec(fmt.Sprintf("CREATE DATABASE %s", db)) + if err != nil && !strings.Contains(err.Error(), "already exists") { + return fmt.Errorf("couldn't create postgres database: %s", err) + } + } + + return nil +} diff --git a/mrunner/databases/postgres_legacy_instruct.go b/mrunner/databases/postgres_legacy_instruct.go new file mode 100644 index 0000000..23d2703 --- /dev/null +++ b/mrunner/databases/postgres_legacy_instruct.go @@ -0,0 +1,79 @@ +package databases + +import ( + "context" + "database/sql" + "fmt" + + "github.com/Liphium/magic/v2/mconfig" + "github.com/moby/moby/client" +) + +// Handles the instructions for PostgreSQL. +// Supports the following instructions currently: +// - Clear tables +// - Drop tables +func (pd *PostgresDriver) HandleInstruction(ctx context.Context, c *client.Client, container mconfig.ContainerInformation, instruction mconfig.Instruction) error { + switch instruction { + case mconfig.InstructionClearTables: + return pd.ClearTables(container) + case mconfig.InstructionDropTables: + return pd.DropTables(container) + } + return nil +} + +// iterateTablesFn is a function that processes each table in the database +type iterateTablesFn func(tableName string, conn *sql.DB) error + +// iterateTables iterates through all tables in all databases and applies the given function +func (pd *PostgresDriver) iterateTables(container mconfig.ContainerInformation, fn iterateTablesFn) error { + // For all databases, connect and iterate tables + for _, db := range pd.databases { + connStr := fmt.Sprintf("host=127.0.0.1 port=%d user=postgres password=postgres dbname=%s sslmode=disable", container.Ports[0], db) + + // Connect to the database + conn, err := sql.Open("postgres", connStr) + if err != nil { + return fmt.Errorf("couldn't connect to postgres: %v", err) + } + defer conn.Close() + + // Get all of the tables + res, err := conn.Query("SELECT table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema')") + if err != nil { + return fmt.Errorf("couldn't get database tables: %v", err) + } + for res.Next() { + var name string + if err := res.Scan(&name); err != nil { + return fmt.Errorf("couldn't get database table name: %v", err) + } + if err := fn(name, conn); err != nil { + return err + } + } + } + + return nil +} + +// Clear all tables in all databases (keeps table schema alive, just removes the content of all tables) +func (pd *PostgresDriver) ClearTables(container mconfig.ContainerInformation) error { + return pd.iterateTables(container, func(tableName string, conn *sql.DB) error { + if _, err := conn.Exec(fmt.Sprintf("truncate %s CASCADE", tableName)); err != nil { + return fmt.Errorf("couldn't truncate table %s: %v", tableName, err) + } + return nil + }) +} + +// Drop all tables in all databases (actually deletes all of your tables) +func (pd *PostgresDriver) DropTables(container mconfig.ContainerInformation) error { + return pd.iterateTables(container, func(tableName string, conn *sql.DB) error { + if _, err := conn.Exec(fmt.Sprintf("DROP TABLE %s CASCADE", tableName)); err != nil { + return fmt.Errorf("couldn't drop table table %s: %v", tableName, err) + } + return nil + }) +} diff --git a/mrunner/mservices/postgres.go b/mrunner/mservices/postgres.go deleted file mode 100644 index 5442f97..0000000 --- a/mrunner/mservices/postgres.go +++ /dev/null @@ -1,326 +0,0 @@ -package mservices - -import ( - "context" - "database/sql" - "fmt" - "log" - "net/netip" - "os" - "strings" - - _ "github.com/lib/pq" - "github.com/moby/moby/api/types/container" - "github.com/moby/moby/api/types/mount" - "github.com/moby/moby/api/types/network" - "github.com/moby/moby/client" -) - -// Make sure the driver complies -var _ ServiceDriver = &PostgresDriver{} - -const ( - DefaultPostgresUsername = "postgres" - DefaultPostgresPassword = "postgres" -) - -var pgLog *log.Logger = log.New(os.Stdout, "pg-manager ", log.Default().Flags()) - -type PostgresDriver struct { - image string - - // Database credentials - username string - password string - - databases []string -} - -// Create a new PostgreSQL service driver. -// -// It currently supports version 14-17. -// -// This driver will eventually be renamed into the legacy driver for people who still want to use PostgreSQL v17 or lower. Eventually it will be deprecated and fully removed (only once the v18 driver is available). -func NewPostgresDriver(image string) *PostgresDriver { - imageVersion := strings.Split(image, ":")[1] - - // Supported (confirmed and tested) major versions for this Postgres driver - var supportedPostgresVersions = []string{"14", "15", "16", "17"} - - // Do a quick check to make sure the image version is actually supported - supported := false - for _, version := range supportedPostgresVersions { - if strings.HasPrefix(imageVersion, fmt.Sprintf("%s.", version)) { - supported = true - } - } - if !supported { - pgLog.Fatalln("ERROR: Version", imageVersion, "is currently not supported.") - } - - return &PostgresDriver{ - image: image, - } -} - -func (pd *PostgresDriver) WithUsername(name string) *PostgresDriver { - pd.username = name - return pd -} - -func (pd *PostgresDriver) WithPassword(password string) *PostgresDriver { - pd.password = password - return pd -} - -func (pd *PostgresDriver) NewDatabase(name string) *PostgresDriver { - pd.databases = append(pd.databases, name) - return pd -} - -// A unique identifier for the database container -func (pd *PostgresDriver) GetUniqueId() string { - return "postgres1417" -} - -func (pd *PostgresDriver) GetRequiredPortAmount() int { - return 1 -} - -func (pd *PostgresDriver) GetImage() string { - return pd.image -} - -// Should create a new container for the database or use the existing one (returns container id + error in case one happened) -func (pd *PostgresDriver) CreateContainer(ctx context.Context, c *client.Client, a ContainerAllocation) (string, error) { - - // Set to default username and password when not set - if pd.username == "" { - pd.username = DefaultPostgresUsername - } - if pd.password == "" { - pd.password = DefaultPostgresPassword - } - if pd.image == "" { - pd.image = "postgres:latest" - } - - // Check if the container already exists - f := make(client.Filters) - f.Add("name", a.Name) - summary, err := c.ContainerList(ctx, client.ContainerListOptions{ - Filters: f, - All: true, - }) - if err != nil { - return "", fmt.Errorf("couldn't list containers: %s", err) - } - containerId := "" - var mounts []mount.Mount = nil - for _, container := range summary.Items { - for _, n := range container.Names { - if n == a.Name { - pgLog.Println("Found existing container...") - containerId = container.ID - - // Inspect the container to get the mounts - resp, err := c.ContainerInspect(ctx, container.ID, client.ContainerInspectOptions{}) - if err != nil { - return "", fmt.Errorf("couldn't inspect container: %s", err) - } - mounts = resp.Container.HostConfig.Mounts - } - } - } - - // Delete the container if it exists - if containerId != "" { - if _, err := c.ContainerRemove(ctx, containerId, client.ContainerRemoveOptions{ - RemoveVolumes: false, - Force: true, - }); err != nil { - return "", fmt.Errorf("couldn't delete database container: %s", err) - } - } - - // Create the port on the postgres container (this is not the port for outside) - port, err := network.ParsePort("5432/tcp") - if err != nil { - return "", fmt.Errorf("couldn't create port for postgres container: %s", err) - } - exposedPorts := network.PortSet{port: struct{}{}} - - // If no existing mounts, create a new volume for PostgreSQL data - if mounts == nil { - volumeName := fmt.Sprintf("%s-data", a.Name) - mounts = []mount.Mount{ - { - Type: mount.TypeVolume, - Source: volumeName, - Target: "/var/lib/postgresql/data", - }, - } - } - - // Create the network config for the container (exposes the container to the host) - networkConf := &container.HostConfig{ - PortBindings: network.PortMap{ - port: []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: fmt.Sprintf("%d", a.Ports[0])}}, - }, - Mounts: mounts, - } - - // Pull the image - pgLog.Println("Pulling image", pd.image, "...") - pullResponse, err := c.ImagePull(ctx, pd.image, client.ImagePullOptions{}) - if err != nil { - return "", fmt.Errorf("couldn't pull image %s: %v", pd.image, err) - } - pullResponse.Wait(ctx) - - // Create the container - resp, err := c.ContainerCreate(ctx, client.ContainerCreateOptions{ - Config: &container.Config{ - Image: pd.image, - Env: []string{ - fmt.Sprintf("POSTGRES_PASSWORD=%s", pd.password), - fmt.Sprintf("POSTGRES_USER=%s", pd.username), - "POSTGRES_DATABASE=postgres", - }, - ExposedPorts: exposedPorts, - }, - HostConfig: networkConf, - Name: a.Name, - }) - if err != nil { - return "", fmt.Errorf("couldn't create postgres container: %s", err) - } - - // Start the container - pgLog.Println("Trying to start container...") - if _, err := c.ContainerStart(ctx, containerId, client.ContainerStartOptions{}); err != nil { - return "", fmt.Errorf("couldn't start postgres container: %s", err) - } - - pgLog.Println("Database container started.") - return resp.ID, nil -} - -// Check for postgres health -func (pd *PostgresDriver) IsHealthy(ctx context.Context, c *client.Client, container ContainerInformation) (bool, error) { - readyCommand := "pg_isready -d postgres" - cmd := strings.Split(readyCommand, " ") - execConfig := client.ExecCreateOptions{ - Cmd: cmd, - AttachStdout: true, - AttachStderr: true, - } - - // Try to execute the command - execIDResp, err := c.ExecCreate(ctx, container.ID, execConfig) - if err != nil { - return false, fmt.Errorf("couldn't create command for readiness of container: %s", err) - } - execStartCheck := client.ExecStartOptions{Detach: false, TTY: false} - if _, err := c.ExecStart(ctx, execIDResp.ID, execStartCheck); err != nil { - return false, fmt.Errorf("couldn't start command for readiness of container: %s", err) - } - respInspect, err := c.ExecInspect(ctx, execIDResp.ID, client.ExecInspectOptions{}) - if err != nil { - return false, fmt.Errorf("couldn't inspect command for readiness of container: %s", err) - } - - return respInspect.ExitCode == 0, nil -} - -// Initialize the internal container with a script (for example) -func (pd *PostgresDriver) Initialize(ctx context.Context, c *client.Client, container ContainerInformation) error { - connStr := fmt.Sprintf("host=127.0.0.1 port=%d user=postgres password=postgres dbname=postgres sslmode=disable", container.Ports[0]) - - // Connect to the database - conn, err := sql.Open("postgres", connStr) - if err != nil { - return fmt.Errorf("couldn't connect to postgres: %s", err) - } - defer conn.Close() - - for _, db := range pd.databases { - pgLog.Println("Creating database", db+"...") - _, err := conn.Exec(fmt.Sprintf("CREATE DATABASE %s", db)) - if err != nil && !strings.Contains(err.Error(), "already exists") { - return fmt.Errorf("couldn't create postgres database: %s", err) - } - } - - return nil -} - -// Handles the instructions for PostgreSQL. -// -// Supports the following instructions currently: -// - Clear tables -// - Drop tables -func (pd *PostgresDriver) HandleInstruction(ctx context.Context, c *client.Client, container ContainerInformation, instruction Instruction) error { - switch instruction { - case InstructionClearTables: - return pd.ClearTables(container) - case InstructionDropTables: - return pd.DropTables(container) - } - return nil -} - -// iterateTablesFn is a function that processes each table in the database -type iterateTablesFn func(tableName string, conn *sql.DB) error - -// iterateTables iterates through all tables in all databases and applies the given function -func (pd *PostgresDriver) iterateTables(container ContainerInformation, fn iterateTablesFn) error { - // For all databases, connect and iterate tables - for _, db := range pd.databases { - connStr := fmt.Sprintf("host=127.0.0.1 port=%d user=postgres password=postgres dbname=%s sslmode=disable", container.Ports[0], db) - - // Connect to the database - conn, err := sql.Open("postgres", connStr) - if err != nil { - return fmt.Errorf("couldn't connect to postgres: %v", err) - } - defer conn.Close() - - // Get all of the tables - res, err := conn.Query("SELECT table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema')") - if err != nil { - return fmt.Errorf("couldn't get database tables: %v", err) - } - for res.Next() { - var name string - if err := res.Scan(&name); err != nil { - return fmt.Errorf("couldn't get database table name: %v", err) - } - if err := fn(name, conn); err != nil { - return err - } - } - } - - return nil -} - -// Clear all tables in all databases (keeps table schema alive, just removes the content of all tables) -func (pd *PostgresDriver) ClearTables(container ContainerInformation) error { - return pd.iterateTables(container, func(tableName string, conn *sql.DB) error { - if _, err := conn.Exec(fmt.Sprintf("truncate %s CASCADE", tableName)); err != nil { - return fmt.Errorf("couldn't truncate table %s: %v", tableName, err) - } - return nil - }) -} - -// Drop all tables in all databases (actually deletes all of your tables) -func (pd *PostgresDriver) DropTables(container ContainerInformation) error { - return pd.iterateTables(container, func(tableName string, conn *sql.DB) error { - if _, err := conn.Exec(fmt.Sprintf("DROP TABLE %s CASCADE", tableName)); err != nil { - return fmt.Errorf("couldn't drop table table %s: %v", tableName, err) - } - return nil - }) -} diff --git a/mrunner/runner.go b/mrunner/runner.go index 3d5f607..eec65b4 100644 --- a/mrunner/runner.go +++ b/mrunner/runner.go @@ -9,11 +9,12 @@ const DefaultStartPort uint = 10000 const DefaultEndPort uint = 60000 type Runner struct { - appName string - profile string - client *client.Client - ctx *mconfig.Context - plan *mconfig.Plan + appName string + profile string + client *client.Client + ctx *mconfig.Context + plan *mconfig.Plan + services []mconfig.ServiceDriver } func (r *Runner) Environment() *mconfig.Environment { @@ -24,18 +25,19 @@ func (r *Runner) Environment() *mconfig.Environment { func NewRunner(ctx *mconfig.Context) (*Runner, error) { // Create a new client for the docker sdk - dc, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + dc, err := client.New(client.FromEnv) if err != nil { return nil, err } // Create the runner return &Runner{ - appName: ctx.AppName(), - profile: ctx.Profile(), - client: dc, - ctx: ctx, - plan: ctx.Plan(), + appName: ctx.AppName(), + profile: ctx.Profile(), + client: dc, + ctx: ctx, + plan: ctx.Plan(), + services: ctx.Services(), }, nil } @@ -43,7 +45,7 @@ func NewRunner(ctx *mconfig.Context) (*Runner, error) { func NewRunnerFromPlan(plan *mconfig.Plan) (*Runner, error) { // Create a new client for the docker sdk - dc, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + dc, err := client.New(client.FromEnv) if err != nil { return nil, err } diff --git a/mrunner/runner_deploy.go b/mrunner/runner_deploy.go index 64f3b34..3452865 100644 --- a/mrunner/runner_deploy.go +++ b/mrunner/runner_deploy.go @@ -2,253 +2,244 @@ package mrunner import ( "context" - "database/sql" "fmt" - "log" - "net/netip" "os" "strings" + "sync" "time" "github.com/Liphium/magic/v2/mconfig" "github.com/Liphium/magic/v2/util" _ "github.com/lib/pq" - "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/mount" - "github.com/moby/moby/api/types/network" "github.com/moby/moby/client" ) // Deploy all the containers nessecary for the application func (r *Runner) Deploy(deleteContainers bool) error { + ctx := context.Background() // Make sure the Docker connection is working - _, err := r.client.Info(context.Background(), client.InfoOptions{}) + _, err := r.client.Info(ctx, client.InfoOptions{}) if client.IsErrConnectionFailed(err) { return fmt.Errorf("please make sure Docker is running, and that Magic (or the Go toolchain) has access to it. (%s)", err) } // Clear all state in case wanted if deleteContainers { - util.Log.Println("Clearing all state...") - r.Clear() + util.Log.Println("Deleting all containers and volumes...") + if err := r.DeleteEverything(); err != nil { + return fmt.Errorf("couldn't clear state: %v", err) + } } - // Deploy the database containers - for _, dbType := range r.plan.DatabaseTypes { - ctx := context.Background() - name := dbType.ContainerName(r.appName, r.profile) - util.Log.Println("Creating database container", name+"...") + // Pull all of the images in case they are not downloaded yet + if err := r.pullServiceImages(ctx); err != nil { + return err + } - // Check if the container already exists - f := make(client.Filters) - f.Add("name", name) - summary, err := r.client.ContainerList(ctx, client.ContainerListOptions{ - Filters: f, - All: true, - }) - if err != nil { - return fmt.Errorf("couldn't list containers: %s", err) - } - containerId := "" - var containerMounts []mount.Mount = nil - for _, c := range summary.Items { - for _, n := range c.Names { - if strings.Contains(n, name) { - util.Log.Println("Found existing container...") - containerId = c.ID + // Start all of the service containers + if err := r.startServiceContainers(ctx); err != nil { + return err + } - // Inspect the container to get the mounts - resp, err := r.client.ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{}) - if err != nil { - return fmt.Errorf("couldn't inspect container: %s", err) - } - containerMounts = resp.Container.HostConfig.Mounts - } - } + // Load environment variables into current application + for key, value := range r.plan.Environment { + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("couldn't set environment variable %s: %s", key, err) } + } - // Delete the container if it exists - if containerId != "" { - if _, err := r.client.ContainerRemove(ctx, containerId, client.ContainerRemoveOptions{ - RemoveVolumes: false, - Force: true, - }); err != nil { - return fmt.Errorf("couldn't delete database container: %s", err) - } - } + util.Log.Println("Deployment finished.") + return nil +} - // Create the new container with the volumes - util.Log.Println("Creating new container...") - containerId, err = r.createDatabaseContainer(ctx, dbType, name, containerMounts) +// Pull all of the images for the services the runner has registered +func (r *Runner) pullServiceImages(ctx context.Context) error { + for _, driver := range r.services { + image := driver.GetImage() + + // Check if the image exists locally + _, err := r.client.ImageInspect(ctx, image) if err != nil { - return fmt.Errorf("couldn't create database container: %s", err) - } - // Start the container - util.Log.Println("Trying to start container...") - if _, err := r.client.ContainerStart(ctx, containerId, client.ContainerStartOptions{}); err != nil { - return fmt.Errorf("couldn't start postgres container: %s", err) - } + // Image not found, need to pull it + util.Log.Println("Pulling image", image+"...") - // Wait for the container to start (with pg_isready) - util.Log.Println("Waiting for PostgreSQL to be ready...") - readyCommand := "pg_isready -d postgres" - cmd := strings.Split(readyCommand, " ") - execConfig := client.ExecCreateOptions{ - Cmd: cmd, - AttachStdout: true, - AttachStderr: true, - } - for { - execIDResp, err := r.client.ExecCreate(ctx, containerId, execConfig) + reader, err := r.client.ImagePull(ctx, image, client.ImagePullOptions{}) if err != nil { - return fmt.Errorf("couldn't create command for readiness of container: %s", err) - } - execStartCheck := client.ExecStartOptions{Detach: false, TTY: false} - if _, err := r.client.ExecStart(ctx, execIDResp.ID, execStartCheck); err != nil { - return fmt.Errorf("couldn't start command for readiness of container: %s", err) - } - respInspect, err := r.client.ExecInspect(ctx, execIDResp.ID, client.ExecInspectOptions{}) - if err != nil { - return fmt.Errorf("couldn't inspect command for readiness of container: %s", err) - } - if respInspect.ExitCode == 0 { - break + return fmt.Errorf("couldn't pull image %s: %s", image, err) } + defer reader.Close() + + // Track progress with updates every second + lastUpdate := time.Now() + buf := make([]byte, 1024) + for { + n, err := reader.Read(buf) + if err != nil { + if err.Error() == "EOF" { + break + } + return fmt.Errorf("error while pulling image %s: %s", image, err) + } - time.Sleep(200 * time.Millisecond) - } - time.Sleep(200 * time.Millisecond) // Some additional time, sometimes takes longer + // Print progress update every second + if time.Since(lastUpdate) >= time.Second { + util.Log.Println("Downloading", image+"...") + lastUpdate = time.Now() + } - // Create all of the databases - util.Log.Println("Connecting to PostgreSQL...") - if err := r.createDatabases(dbType); err != nil { - return err - } - } + if n == 0 { + break + } + } - // Load environment variables into current application - util.Log.Println("Loading environment...") - for key, value := range r.plan.Environment { - if err := os.Setenv(key, value); err != nil { - return fmt.Errorf("couldn't set environment variable %s: %s", key, err) + util.Log.Println("Successfully pulled image", image) } } - util.Log.Println("Deployment finished.") return nil } -// Create a new container for a postgres database. Returns container id. -func (r *Runner) createDatabaseContainer(ctx context.Context, dbType mconfig.PlannedDatabaseType, name string, mounts []mount.Mount) (string, error) { +// Create all the service containers and start them + wait until healthy and initialize +func (r *Runner) startServiceContainers(ctx context.Context) error { + var wg sync.WaitGroup + errChan := make(chan error, len(r.services)) - // Reserve the port for the container - port, err := network.ParsePort("5432/tcp") - if err != nil { - return "", fmt.Errorf("couldn't create port for postgres container: %s", err) - } - exposedPorts := network.PortSet{port: struct{}{}} - - // If no existing mounts, create a new volume for PostgreSQL data - if mounts == nil { - volumeName := fmt.Sprintf("%s-postgres-data", name) - mounts = []mount.Mount{ - { - Type: mount.TypeVolume, - Source: volumeName, - Target: "/var/lib/postgresql/data", - }, - } - } + // Deploy all service containers in parallel + for _, driver := range r.services { + wg.Add(1) + go func(driver mconfig.ServiceDriver) { + defer wg.Done() - // Create the network config for the container - networkConf := &container.HostConfig{ - PortBindings: network.PortMap{ - port: []network.PortBinding{{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: fmt.Sprintf("%d", dbType.Port)}}, - }, - Mounts: mounts, - } + name := r.plan.Containers[driver.GetUniqueId()].Name - // Check if an environment variable is set for the postgres image - postgresImage := os.Getenv("MAGIC_POSTGRES_IMAGE") - if postgresImage == "" { - postgresImage = "postgres:latest" - } + // Generate a proper port list from the allocated ports of the plan + containerPorts := []uint{} + for _, port := range r.plan.Containers[driver.GetUniqueId()].Ports { + containerPorts = append(containerPorts, r.plan.AllocatedPorts[port]) + } - // Create the container - resp, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{ - Config: &container.Config{ - Image: postgresImage, - Env: []string{ - fmt.Sprintf("POSTGRES_PASSWORD=%s", mconfig.DefaultPassword(dbType.Type)), - fmt.Sprintf("POSTGRES_USER=%s", mconfig.DefaultUsername(dbType.Type)), - "POSTGRES_DATABASE=postgres", - }, - ExposedPorts: exposedPorts, - }, - HostConfig: networkConf, - Name: name, - }) - if err != nil { - return "", fmt.Errorf("couldn't create postgres container: %s", err) - } - return resp.ID, nil -} + // Create the container using the driver + containerID, err := driver.CreateContainer(ctx, r.client, mconfig.ContainerAllocation{ + Name: name, + Ports: containerPorts, + }) + if err != nil { + errChan <- fmt.Errorf("couldn't create container for service %s: %s", driver.GetUniqueId(), err) + return + } + + // Start the container + if _, err := r.client.ContainerStart(ctx, containerID, client.ContainerStartOptions{}); err != nil { + errChan <- fmt.Errorf("couldn't start container for service %s: %s", driver.GetUniqueId(), err) + return + } + + // Monitor health until the container is ready + util.Log.Println("Waiting for", name, "to be healthy...") + containerInfo := mconfig.ContainerInformation{ + ID: containerID, + Name: name, + Ports: r.plan.Containers[driver.GetUniqueId()].Ports, + } + + for { + healthy, err := driver.IsHealthy(ctx, r.client, containerInfo) + if err != nil { + errChan <- fmt.Errorf("couldn't check health for service %s: %s", driver.GetUniqueId(), err) + return + } + if healthy { + break + } + time.Sleep(200 * time.Millisecond) + } + time.Sleep(200 * time.Millisecond) // Some extra time, some services are a little weird with healthy state -// Connect to postgres and create all the needed databases. -func (r *Runner) createDatabases(dbType mconfig.PlannedDatabaseType) error { - connStr := fmt.Sprintf("host=127.0.0.1 port=%d user=postgres password=postgres dbname=postgres sslmode=disable", dbType.Port) + // Initialize the container + if err := driver.Initialize(ctx, r.client, containerInfo); err != nil { + errChan <- fmt.Errorf("couldn't initialize service %s: %s", driver.GetUniqueId(), err) + return + } - // Connect to the database - conn, err := sql.Open("postgres", connStr) - if err != nil { - return fmt.Errorf("couldn't connect to postgres: %s", err) + util.Log.Println("Service", name, "is ready") + }(driver) } - defer conn.Close() - for _, db := range dbType.Databases { - util.Log.Println("Creating database", db.Name+"...") - _, err := conn.Exec(fmt.Sprintf("CREATE DATABASE %s", db.Name)) - if err != nil && !strings.Contains(err.Error(), "already exists") { - return fmt.Errorf("couldn't create postgres database: %s", err) + // Wait for all services to complete + wg.Wait() + close(errChan) + + // Check for any errors + for err := range errChan { + if err != nil { + return err } } + return nil } -// Delete all containers and reset all state -func (r *Runner) Clear() { - ctx := context.Background() - for _, dbType := range r.plan.DatabaseTypes { +// Helper function to iterate over containers and execute a callback for each found container +func (r *Runner) forEachContainer(ctx context.Context, callback func(service string, containerID string, container mconfig.ContainerAllocation) error) error { + for service, container := range r.plan.Containers { // Try to find the container for the type f := make(client.Filters) - name := dbType.ContainerName(r.appName, r.profile) - f.Add("name", name) + f.Add("name", container.Name) summary, err := r.client.ContainerList(ctx, client.ContainerListOptions{ Filters: f, - All: true, }) if err != nil { - log.Fatalln("Couldn't list containers:", err) + util.Log.Fatalln("Couldn't list containers:", err) } containerId := "" for _, c := range summary.Items { for _, n := range c.Names { - if strings.Contains(n, name) { + if strings.Contains(n, container.Name) { containerId = c.ID } } } - // If there is no container, nothing to delete + // If there is no container, nothing to do if containerId == "" { continue } + // Execute the callback with the container ID, container info, and key + if err := callback(service, containerId, container); err != nil { + return err + } + } + + return nil +} + +// Stop all containers +func (r *Runner) StopContainers() error { + ctx := context.Background() + return r.forEachContainer(ctx, func(_, containerID string, container mconfig.ContainerAllocation) error { + + // Stop the container + util.Log.Println("Stopping container", container.Name+"...") + if _, err := r.client.ContainerStop(ctx, containerID, client.ContainerStopOptions{}); err != nil { + return fmt.Errorf("Couldn't stop database container: %v", err) + } + + return nil + }) +} + +// Delete all containers + their attached volumes and reset all state +func (r *Runner) DeleteEverything() error { + ctx := context.Background() + return r.forEachContainer(ctx, func(_ string, containerID string, container mconfig.ContainerAllocation) error { + // Get all the attached volumes to delete them manually - containerInfo, err := r.client.ContainerInspect(ctx, containerId, client.ContainerInspectOptions{}) + containerInfo, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) if err != nil { util.Log.Println("Warning: Couldn't inspect container:", err) } @@ -262,12 +253,12 @@ func (r *Runner) Clear() { } // Delete the container - util.Log.Println("Deleting container", name+"...") - if _, err := r.client.ContainerRemove(ctx, containerId, client.ContainerRemoveOptions{ + util.Log.Println("Deleting container", container.Name+"...") + if _, err := r.client.ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{ RemoveVolumes: false, Force: true, }); err != nil { - util.Log.Fatalln("Couldn't delete database container:", err) + return fmt.Errorf("Couldn't delete database container: %v", err) } // Delete all the attached volumes @@ -276,79 +267,64 @@ func (r *Runner) Clear() { if _, err := r.client.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{ Force: true, }); err != nil { - util.Log.Println("Warning: Couldn't delete volume", volumeName+":", err) + return fmt.Errorf("Couldn't delete volume %s: %v", volumeName, err) } } - } + + return nil + }) } -// Stop all containers -func (r *Runner) StopContainers() { +// Clear the content of all tables from databases, at runtime +func (r *Runner) DropTables() error { ctx := context.Background() - for _, dbType := range r.plan.DatabaseTypes { - - // Try to find the container for the type - f := make(client.Filters) - name := dbType.ContainerName(r.appName, r.profile) - f.Add("name", name) - summary, err := r.client.ContainerList(ctx, client.ContainerListOptions{ - Filters: f, - }) - if err != nil { - util.Log.Fatalln("Couldn't list containers:", err) - } - containerId := "" - for _, c := range summary.Items { - for _, n := range c.Names { - if strings.Contains(n, name) { - containerId = c.ID - } - } + return r.forEachContainer(ctx, func(service, containerID string, container mconfig.ContainerAllocation) error { + driver, ok := mconfig.GetDriver(service) + if !ok { + return fmt.Errorf("couldn't find service driver for service type: %s", service) } - // If there is no container, nothing to stop - if containerId == "" { - continue + // Convert the ports + containerPorts := []uint{} + for _, port := range container.Ports { + containerPorts = append(containerPorts, r.plan.AllocatedPorts[port]) } - // Stop the container - util.Log.Println("Stopping container", name+"...") - if _, err := r.client.ContainerStop(ctx, containerId, client.ContainerStopOptions{}); err != nil { - util.Log.Fatalln("Couldn't stop database container:", err) + if err := driver.HandleInstruction(ctx, r.client, mconfig.ContainerInformation{ + ID: containerID, + Name: container.Name, + Ports: containerPorts, + }, mconfig.InstructionDropTables); err != nil { + return fmt.Errorf("couldn't drop tables: %v", err) } - } -} -// Clear the databases, at runtime -func (r *Runner) ClearDatabases() { - - // Delete all the databases of every type - for _, dbType := range r.plan.DatabaseTypes { + return nil + }) +} - for _, db := range dbType.Databases { - connStr := fmt.Sprintf("host=127.0.0.1 port=%d user=postgres password=postgres dbname=%s sslmode=disable", dbType.Port, db.Name) +// Delete all database tables from databases, at runtime +func (r *Runner) ClearTables() error { + ctx := context.Background() + return r.forEachContainer(ctx, func(service, containerID string, container mconfig.ContainerAllocation) error { + driver, ok := mconfig.GetDriver(service) + if !ok { + return fmt.Errorf("couldn't find service driver for service type: %s", service) + } - // Connect to the database - conn, err := sql.Open("postgres", connStr) - if err != nil { - log.Fatalln("couldn't connect to postgres:", err) - } - defer conn.Close() + // Convert the ports + containerPorts := []uint{} + for _, port := range container.Ports { + containerPorts = append(containerPorts, r.plan.AllocatedPorts[port]) + } - // Clear all of the tables - res, err := conn.Query("SELECT table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema')") - if err != nil { - log.Fatalln("couldn't get database tables:", err) - } - for res.Next() { - var name string - if err := res.Scan(&name); err != nil { - util.Log.Fatalln("couldn't get database table name:", err) - } - if _, err := conn.Exec(fmt.Sprintf("truncate %s CASCADE", name)); err != nil { - util.Log.Fatalln("couldn't delete from table", name+":", err) - } - } + if err := driver.HandleInstruction(ctx, r.client, mconfig.ContainerInformation{ + ID: containerID, + Name: container.Name, + Ports: containerPorts, + }, mconfig.InstructionClearTables); err != nil { + return fmt.Errorf("couldn't clear tables: %v", err) } - } + + return nil + }) } diff --git a/mrunner/runner_plan.go b/mrunner/runner_plan.go index 977d04a..dba1b0e 100644 --- a/mrunner/runner_plan.go +++ b/mrunner/runner_plan.go @@ -2,7 +2,7 @@ package mrunner import ( "fmt" - "maps" + "slices" "github.com/Liphium/magic/v2/integration" "github.com/Liphium/magic/v2/mconfig" @@ -20,19 +20,44 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { util.Log.Fatalln("no context set") } - // Prepare database containers - types, err := r.prepareDatabases() - if err != nil { - util.Log.Fatalln("couldn't start databases:", err) + // Collect all the ports that should be allocated (also for the service drivers obv) + portsToAllocate := r.ctx.Ports() + startPort := DefaultStartPort + containerMap := map[string]mconfig.ContainerAllocation{} + for _, driver := range r.ctx.Services() { + if _, ok := containerMap[driver.GetUniqueId()]; ok { + util.Log.Fatalln("ERROR: You can't create multiple drivers of the same type at the moment.") + } + + alloc := mconfig.ContainerAllocation{ + Name: mconfig.PlannedContainerName(r.plan, driver), + Ports: []uint{}, + } + + for range driver.GetRequiredPortAmount() { + + // Make sure we're not allocating a port that's already taken + for slices.Contains(portsToAllocate, startPort) { + startPort++ + } + + // Allocate one of the default ports for the container + portsToAllocate = append(portsToAllocate, startPort) + startPort++ + } + + containerMap[driver.GetUniqueId()] = alloc } // Prepare all of the ports allocatedPorts := map[uint]uint{} - if r.ctx.Ports() != nil { - for _, port := range r.ctx.Ports() { + if len(portsToAllocate) >= 0 { + for _, port := range portsToAllocate { + // Generate a new port in case the current one is taken toAllocate := port if integration.ScanPort(port) { + var err error toAllocate, err = scanForOpenPort() if err != nil { util.Log.Fatalln("Couldn't find open port for", port, ":", err) @@ -48,63 +73,19 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { r.plan = &mconfig.Plan{ AppName: r.ctx.AppName(), Profile: r.ctx.Profile(), - DatabaseTypes: types, + Containers: containerMap, AllocatedPorts: allocatedPorts, } - r.ctx.ApplyPlan(r.plan) // Generate the environment variables and add to plan environment := map[string]string{} - if r.Environment() != nil { - environment = r.Environment().Generate() + if r.ctx.Environment() != nil { + environment = r.ctx.Environment().Generate() } r.plan.Environment = environment return r.plan } -func (r *Runner) prepareDatabases() ([]mconfig.PlannedDatabaseType, error) { - - // Scan for open ports per type - types := map[mconfig.DatabaseType]mconfig.PlannedDatabaseType{} - for _, db := range r.ctx.Databases() { - if _, ok := types[db.Type()]; !ok { - openPort, err := scanForOpenPort() - if err != nil { - return nil, err - } - - types[db.Type()] = mconfig.PlannedDatabaseType{ - Type: db.Type(), - Port: openPort, - Databases: []mconfig.PlannedDatabase{}, - } - } - } - - // Add all of the databases - for _, db := range r.ctx.Databases() { - dbType := types[db.Type()] - dbType.Databases = append(dbType.Databases, mconfig.PlannedDatabase{ - Name: db.Name(), - Username: db.DefaultUsername(), - Password: db.DefaultPassword(), - Hostname: "127.0.0.1", - Type: dbType.Type, - Port: dbType.Port, - }) - types[db.Type()] = dbType - } - - // Convert to list - plannedTypes := make([]mconfig.PlannedDatabaseType, len(types)) - i := 0 - for value := range maps.Values(types) { - plannedTypes[i] = value - i++ - } - return plannedTypes, nil -} - // Scan for an open port in the default range func scanForOpenPort() (uint, error) { openPort, err := integration.ScanForOpenPort(DefaultStartPort, DefaultEndPort) From 38c325fd0acd5823e76c1510af264ad03afae3b3 Mon Sep 17 00:00:00 2001 From: Unbreathable <70802809+Unbreathable@users.noreply.github.com> Date: Sun, 8 Feb 2026 19:33:47 +0100 Subject: [PATCH 06/10] fix: Remove integration package + Fix real-project and bugs with driver --- examples/real-project/starter/config.go | 24 +++-- .../real-project/starter/scripts_database.go | 23 ++++- examples/real-project/starter/start_test.go | 2 +- factory.go | 4 +- initializer.go | 16 ++-- integration/constants.go | 4 - integration/directory.go | 91 ------------------- integration/execute_command.go | 89 ------------------ integration/file.go | 31 ------- integration/formatting.go | 21 ----- integration/path_evaluator.go | 64 ------------- integration/path_evaluator_test.go | 15 --- integration/port_scanner.go | 26 ------ integration/sanitize_path.go | 12 --- mconfig/context.go | 24 +++-- mrunner/databases/postgres_legacy.go | 10 +- .../databases/postgres_legacy_container.go | 13 ++- mrunner/runner_deploy.go | 5 +- mrunner/runner_plan.go | 41 +++------ util/util.go | 17 ++++ 20 files changed, 107 insertions(+), 425 deletions(-) delete mode 100644 integration/constants.go delete mode 100644 integration/directory.go delete mode 100644 integration/execute_command.go delete mode 100644 integration/file.go delete mode 100644 integration/formatting.go delete mode 100644 integration/path_evaluator.go delete mode 100644 integration/path_evaluator_test.go delete mode 100644 integration/port_scanner.go delete mode 100644 integration/sanitize_path.go diff --git a/examples/real-project/starter/config.go b/examples/real-project/starter/config.go index 8a7fe05..149adf5 100644 --- a/examples/real-project/starter/config.go +++ b/examples/real-project/starter/config.go @@ -5,6 +5,7 @@ import ( "github.com/Liphium/magic/v2" "github.com/Liphium/magic/v2/mconfig" + "github.com/Liphium/magic/v2/mrunner/databases" "github.com/Liphium/magic/v2/scripting" ) @@ -12,8 +13,14 @@ func BuildMagicConfig() magic.Config { return magic.Config{ AppName: "magic-example-real-project", PlanDeployment: func(ctx *mconfig.Context) { - // Create a PostgreSQL database for the posts service - postsDB := ctx.NewPostgresDatabase("posts") + + // Create a new driver for PostgreSQL databases + driver := databases.NewLegacyPostgresDriver("postgres:17"). + // Create a PostgreSQL database for the posts service (the driver supports a builder pattern with this method) + NewDatabase("posts") + + // Make sure to register the driver in the context + ctx.Register(driver) // Allocate a new port for the service. This makes it possible to run multiple instances of this app // locally, without weird configuration hell. Magic will pick a port in case the preferred one is taken. @@ -22,11 +29,11 @@ func BuildMagicConfig() magic.Config { // Set up environment variables for the application ctx.WithEnvironment(mconfig.Environment{ // Database connection environment variables - "DB_HOST": postsDB.Host(ctx), - "DB_PORT": postsDB.Port(ctx), - "DB_USER": postsDB.Username(), - "DB_PASSWORD": postsDB.Password(), - "DB_DATABASE": postsDB.DatabaseName(ctx), + "DB_HOST": driver.Host(ctx), + "DB_PORT": driver.Port(ctx), + "DB_USER": driver.Username(), + "DB_PASSWORD": driver.Password(), + "DB_DATABASE": mconfig.ValueStatic("posts"), // Make the server listen on localhost using the port allocated by Magic "LISTEN": mconfig.ValueWithBase([]mconfig.EnvironmentValue{port}, func(s []string) string { @@ -41,7 +48,8 @@ func BuildMagicConfig() magic.Config { StartFunction: Start, Scripts: []scripting.Script{ // Scripts to deal with the database, can always come in handy - scripting.CreateScript("db-reset", "Reset the database by dropping and recreating all tables", ResetDatabase), + scripting.CreateScript("db-reset", "Reset the database by dropping all tables", ResetDatabase), + scripting.CreateScript("db-clear", "Clear the database by truncating all tables", ClearDatabases), scripting.CreateScript("db-seed", "Seed the database with sample posts", SeedDatabase), // Scripts to call endpoints, really useful for tests and development diff --git a/examples/real-project/starter/scripts_database.go b/examples/real-project/starter/scripts_database.go index 8b47814..1c97e50 100644 --- a/examples/real-project/starter/scripts_database.go +++ b/examples/real-project/starter/scripts_database.go @@ -8,14 +8,31 @@ import ( "github.com/Liphium/magic/v2/mrunner" ) -// Script to reset the database by dropping and recreating all tables +// Script to clear all database tables content, but not fully delete them. // // Here we just use any to ignore the argument. This can be useful for scripts such as this one. -func ResetDatabase(runner *mrunner.Runner) error { +func ClearDatabases(runner *mrunner.Runner) error { log.Println("Resetting database...") // Magic can clear all databases for you, don't worry, only data will be deleted meaning your schema is still all good :D - runner.ClearDatabases() + if err := runner.ClearTables(); err != nil { + log.Fatalln("Couldn't clear database tables:", err) + } + + log.Println("Database reset completed successfully!") + return nil +} + +// Script to reset the database by dropping all tables. +// +// Here we just use any to ignore the argument. This can be useful for scripts such as this one. +func ResetDatabase(runner *mrunner.Runner) error { + log.Println("Resetting database...") + + // Magic can drop all databases for you as well, this means that all the tables are actually gone + if err := runner.DropTables(); err != nil { + log.Fatalln("Couldn't clear database tables:", err) + } log.Println("Database reset completed successfully!") return nil diff --git a/examples/real-project/starter/start_test.go b/examples/real-project/starter/start_test.go index ebc11ea..d4c954e 100644 --- a/examples/real-project/starter/start_test.go +++ b/examples/real-project/starter/start_test.go @@ -56,7 +56,7 @@ func TestApp(t *testing.T) { defer client.Close() // You can clear databases here, but if you don't rely on an empty database for a test, just not doing it is fine, too. - magic.GetTestRunner().ClearDatabases() + assert.Nil(t, magic.GetTestRunner().ClearTables()) // Yes, you can call scripts in here to make your life a little easier. if err := starter.SeedDatabase(); err != nil { diff --git a/factory.go b/factory.go index 130a08d..66d8b2c 100644 --- a/factory.go +++ b/factory.go @@ -28,7 +28,7 @@ func createFactory() (Factory, error) { return Factory{}, err } - for i := 0; i < maxRecursiveTries; i++ { + for range maxRecursiveTries { modPath := filepath.Join(dir, "go.mod") if _, err := os.Stat(modPath); err == nil { return Factory{projectDir: dir}, nil @@ -68,7 +68,7 @@ func (f *Factory) LockFile(profile string) string { // Get the location of the plan file for a profile func (f *Factory) PlanFile(profile string) string { - return filepath.Join(f.MagicDirectory(), fmt.Sprintf("%s.mplan", profile)) + return filepath.Join(f.MagicDirectory(), fmt.Sprintf("%s.json", profile)) } // Check if a profile is locked (a magic instance is running) diff --git a/initializer.go b/initializer.go index 8f32edb..ab1fc41 100644 --- a/initializer.go +++ b/initializer.go @@ -60,13 +60,6 @@ func prepare(config Config, testProfile string) (*Factory, *mrunner.Runner) { if isTestRunner { currentProfile = "test-" + testProfile } - ctx := mconfig.DefaultContext(config.AppName, currentProfile) - - // Check if all scripts should be listed - if *scriptsFlag && !isTestRunner { - listScripts(config) - return nil, nil - } // Create a factory for initializing everything factory, err := createFactory() @@ -79,6 +72,15 @@ func prepare(config Config, testProfile string) (*Factory, *mrunner.Runner) { } factory.WarnIfNotIgnored() + // Create the context for Magic config generation + ctx := mconfig.DefaultContext(config.AppName, currentProfile, factory.projectDir) + + // Check if all scripts should be listed + if *scriptsFlag && !isTestRunner { + listScripts(config) + return nil, nil + } + // Check if a script should be run script := *runFlag if script != "" && !isTestRunner { diff --git a/integration/constants.go b/integration/constants.go deleted file mode 100644 index 3d82c7e..0000000 --- a/integration/constants.go +++ /dev/null @@ -1,4 +0,0 @@ -package integration - -// The current version of Magic to make sure the CLI and generated modules are always compatible -const MagicVersion = "v1.0.0-rc12" diff --git a/integration/directory.go b/integration/directory.go deleted file mode 100644 index 42435be..0000000 --- a/integration/directory.go +++ /dev/null @@ -1,91 +0,0 @@ -package integration - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" -) - -// Get the magic directory (as an absolute path) -func GetMagicDirectory(amount int) (string, error) { - if amount <= 0 { - return "", errors.New("amount can't be 0 or less") - } - - wd, err := os.Getwd() - if err != nil { - return "", err - } - - for i := 0; i < amount; i++ { - - files, err := os.ReadDir(wd) - if err != nil { - return "", err - } - - foundMg := false - foundGm := false - // Find the magic folder - for _, entry := range files { - if entry.IsDir() && entry.Name() == "magic" { - foundMg = true - } else if !entry.IsDir() && entry.Name() == "go.mod" { - foundGm = true - } - } - if foundMg { - return filepath.Join(wd, "magic"), nil - } else if foundGm { - return "", fmt.Errorf("can't find magic directory, too far back, found go.mod in: %q", wd) - } - wd = filepath.Dir(wd) - } - return "", errors.New("can't find magic directory") -} - -// Check if a directory exists (argument can also just be a file) -func DoesDirExist(dirPath string) (bool, error) { - _, err := os.Stat(filepath.Dir(dirPath)) - if err != nil { - return false, fmt.Errorf("path to dir does not exist: %w", err) - } else { - s, err := os.Stat(dirPath) - if err != nil { - return true, nil - } else if !s.IsDir() { - return false, errors.New("path leads to an existing file not a dir") - } else { - return false, nil - } - } -} - -// Print all files in the current directory (useful for debugging) -func PrintCurrentDirAll() { - wd, _ := os.Getwd() - fmt.Println(wd) - files, _ := os.ReadDir(".") - - // Find the magic folder - for _, entry := range files { - fmt.Println(entry.Name()) - } -} - -// Convert a path from the go.mod file to an absolute path. -// -// For relative paths to be properly parsed you need to be in the correct directory. -func ModulePathToAbsolutePath(path string) string { - trimmed := strings.TrimSpace(path) - if strings.HasPrefix(trimmed, "./") || strings.HasPrefix(trimmed, "../") { - absolute, err := filepath.Abs(path) - if err != nil { - return path - } - return absolute - } - return path -} diff --git a/integration/execute_command.go b/integration/execute_command.go deleted file mode 100644 index 78dacd0..0000000 --- a/integration/execute_command.go +++ /dev/null @@ -1,89 +0,0 @@ -package integration - -import ( - "bufio" - "os" - "os/exec" - "path/filepath" -) - -// Build and then run a go program. -func BuildThenRun(funcPrint func(string), funcStart func(*exec.Cmd), directory string, args ...string) error { - - // Get the old working directory - workDir, err := os.Getwd() - if err != nil { - return err - } - - // Change directory to the file - if err := os.Chdir(directory); err != nil { - return err - } - - // Build the program - if err := ExecCmdWithFuncStart(funcPrint, func(c *exec.Cmd) {}, "go", "build", "-o", "program.exe"); err != nil { - return err - } - - // Change back to the original working directory - if err := os.Chdir(workDir); err != nil { - return err - } - - // Execute and return the process - if err := ExecCmdWithFuncStart(funcPrint, funcStart, filepath.Join(directory, "program.exe"), args...); err != nil { - return err - } - - return nil -} - -func ExecCmdWithFunc(funcPrint func(string), name string, args ...string) error { - cmd, err := execHelper(funcPrint, name, args...) - if err != nil { - return err - } - return cmd.Run() -} - -func ExecCmdWithFuncStart(funcPrint func(string), funcStart func(*exec.Cmd), name string, args ...string) error { - cmd, err := execHelper(funcPrint, name, args...) - if err != nil { - return err - } - if err = cmd.Start(); err != nil { - return err - } - funcStart(cmd) - return cmd.Wait() -} - -func execHelper(funcPrint func(string), name string, args ...string) (*exec.Cmd, error) { - cmd := exec.Command(name, args...) - - // Read the normal logs from the app - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - go func() { - scanner := bufio.NewScanner(stdout) - for scanner.Scan() { - funcPrint(scanner.Text()) - } - }() - - // Read the errors output from the app - stderr, err := cmd.StderrPipe() - if err != nil { - return nil, err - } - go func() { - scanner := bufio.NewScanner(stderr) - for scanner.Scan() { - funcPrint(scanner.Text()) - } - }() - return cmd, nil -} diff --git a/integration/file.go b/integration/file.go deleted file mode 100644 index 2345ba7..0000000 --- a/integration/file.go +++ /dev/null @@ -1,31 +0,0 @@ -package integration - -import ( - "io" - "os" -) - -func CopyFile(source string, destination string) error { - sourceFile, err := os.Open(source) - if err != nil { - return err - } - defer sourceFile.Close() - - destinationFile, err := os.Create(destination) - if err != nil { - return err - } - defer destinationFile.Close() - - _, err = io.Copy(destinationFile, sourceFile) - if err != nil { - return err - } - return nil -} - -// Create a new file with content. -func CreateFileWithContent(name string, content string) error { - return os.WriteFile(name, []byte(content), 0755) -} diff --git a/integration/formatting.go b/integration/formatting.go deleted file mode 100644 index bdcccb1..0000000 --- a/integration/formatting.go +++ /dev/null @@ -1,21 +0,0 @@ -package integration - -import "strings" - -func SnakeToCamelCase(s string, capitalizeFirst bool) string { - // Determine the start for the capitialization - start := 1 - if capitalizeFirst { - start = 0 - } - - // Start converting and return result - parts := strings.Split(s, "_") - for i := start; i < len(parts); i++ { - if parts[i] == "" { - continue - } - parts[i] = strings.ToUpper(parts[i][0:1]) + strings.ToLower(parts[i][1:]) - } - return strings.Join(parts, "") -} diff --git a/integration/path_evaluator.go b/integration/path_evaluator.go deleted file mode 100644 index fcf6b8d..0000000 --- a/integration/path_evaluator.go +++ /dev/null @@ -1,64 +0,0 @@ -package integration - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" -) - -func EvaluatePath(pta string) (dir string, filename string, path string, _ error) { - pta = strings.TrimSpace(pta) - - if isValidPathFile(pta) { - // working filepath with filename - dir, filename = filepath.Split(pta) - return dir, filename, filepath.Join(dir, filename), nil - } else if s, err := os.Stat(pta); err == nil && s.IsDir() { - // working filepath whithout filename - if lastDir := filepath.Base(pta); lastDir != "." && lastDir != "/" && lastDir != "\\" { - dir = pta - filename = lastDir + ".go" // TODO change if other fileextentions are allowed - if isValidPathFile(filepath.Join(dir, filename)) { - return dir, filename, filepath.Join(dir, filename), nil - } - } - return "", "", "", errors.New("bad path") - } else { - return "", "", "", errors.New("bad path") - } -} - -func EvaluateNewPath(pta string) (dir string, filename string, path string, _ error) { - pta = strings.TrimSpace(pta) - - // check if path is a file - if !strings.HasSuffix(pta, ".go") { - - // extend path with filename - if base := filepath.Base(pta); base != "." { - pta += ".go" - } else { - return "", "", "", errors.New("") - } - } - - if dE, err := DoesDirExist(filepath.Dir(pta)); err != nil { - return "", "", "", err - } else if dE { - return filepath.Dir(pta), filepath.Base(pta), pta, nil - } else { - if err = os.MkdirAll(filepath.Dir(pta), 0755); err != nil { - return "", "", "", fmt.Errorf("failed to create path %q: %w", filepath.Dir(pta), err) - } - return filepath.Dir(pta), filepath.Base(pta), pta, nil - } -} - -func isValidPathFile(path string) bool { - if s, err := os.Stat(path); err == nil && !s.IsDir() { - return true - } - return false -} diff --git a/integration/path_evaluator_test.go b/integration/path_evaluator_test.go deleted file mode 100644 index 86fbf22..0000000 --- a/integration/path_evaluator_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package integration - -import ( - "fmt" - "testing" -) - -func TestPathEvaluator(t *testing.T) { - fmt.Println(EvaluatePath("./scripts/script1/")) - fmt.Println(EvaluatePath("./scripts/script1")) - fmt.Println(EvaluatePath("./scripts/script1.go")) - fmt.Println(EvaluatePath("./")) - fmt.Println(EvaluatePath("./scripts/script1/script7")) - fmt.Println(EvaluatePath("./scripts/script1/script7/test.go")) -} diff --git a/integration/port_scanner.go b/integration/port_scanner.go deleted file mode 100644 index 8e52a42..0000000 --- a/integration/port_scanner.go +++ /dev/null @@ -1,26 +0,0 @@ -package integration - -import ( - "fmt" - "net" -) - -// Scan a range of ports for an open port -func ScanForOpenPort(start, end uint) (uint, error) { - for port := start; port <= end; port++ { - if !ScanPort(port) { - return port, nil - } - } - return 0, fmt.Errorf("no open IPv4 port found in range %d-%d", start, end) -} - -// Scan an individual port. Returns true when the connection succeeds. -func ScanPort(port uint) bool { - conn, err := net.Dial("tcp", fmt.Sprintf(":%d", port)) - if err == nil { - conn.Close() - return true - } - return false -} diff --git a/integration/sanitize_path.go b/integration/sanitize_path.go deleted file mode 100644 index 8821c8f..0000000 --- a/integration/sanitize_path.go +++ /dev/null @@ -1,12 +0,0 @@ -package integration - -import "regexp" - -func IsPathSanitized(path string) bool { - pathRegex := "^[A-Za-z_][A-Za-z0-9_]*$" - found, err := regexp.MatchString(pathRegex, path) - if err != nil { - return false - } - return found -} diff --git a/mconfig/context.go b/mconfig/context.go index f648836..a93366f 100644 --- a/mconfig/context.go +++ b/mconfig/context.go @@ -2,7 +2,6 @@ package mconfig import ( "fmt" - "log" "maps" "os" "strings" @@ -11,7 +10,7 @@ import ( type Context struct { appName string // Current app name profile string // Current profile - directory string // Current working directory + projectDir string // Current project directory environment *Environment // Environment for environment variables (can be nil) services []ServiceDriver ports []uint // All ports the user wants to allocate @@ -40,6 +39,10 @@ func (c *Context) Ports() []uint { return c.ports } +func (c *Context) ProjectDirectory() string { + return c.projectDir +} + // Set the environment. func (c *Context) WithEnvironment(env Environment) { if c.environment == nil { @@ -94,17 +97,12 @@ func (c *Context) Register(driver ServiceDriver) ServiceDriver { return driver } -func DefaultContext(appName string, profile string) *Context { - workDir, err := os.Getwd() - if err != nil { - log.Fatalln("couldn't get current working directory") - } - +func DefaultContext(appName string, profile string, projectDir string) *Context { return &Context{ - directory: workDir, - appName: appName, - profile: profile, - services: []ServiceDriver{}, - plan: &Plan{}, + projectDir: projectDir, + appName: appName, + profile: profile, + services: []ServiceDriver{}, + plan: &Plan{}, } } diff --git a/mrunner/databases/postgres_legacy.go b/mrunner/databases/postgres_legacy.go index 53655ec..8c483bd 100644 --- a/mrunner/databases/postgres_legacy.go +++ b/mrunner/databases/postgres_legacy.go @@ -22,7 +22,7 @@ const ( PostgresPassword = "postgres" ) -var pgLog *log.Logger = log.New(os.Stdout, "pg-manager ", log.Default().Flags()) +var pgLegacyLog *log.Logger = log.New(os.Stdout, "pg-legacy ", log.Default().Flags()) type PostgresDriver struct { image string @@ -43,12 +43,12 @@ func NewLegacyPostgresDriver(image string) *PostgresDriver { // Do a quick check to make sure the image version is actually supported supported := false for _, version := range supportedPostgresVersions { - if strings.HasPrefix(imageVersion, fmt.Sprintf("%s.", version)) { + if strings.HasPrefix(imageVersion, fmt.Sprintf("%s.", version)) || imageVersion == version { supported = true } } if !supported { - pgLog.Fatalln("ERROR: Version", imageVersion, "is currently not supported.") + pgLegacyLog.Fatalln("ERROR: Version", imageVersion, "is currently not supported.") } return &PostgresDriver{ @@ -92,8 +92,8 @@ func (pd *PostgresDriver) Host(ctx *mconfig.Context) mconfig.EnvironmentValue { // Get the port of the database container created by the driver as a EnvironmentValue for your config. func (pd *PostgresDriver) Port(ctx *mconfig.Context) mconfig.EnvironmentValue { return mconfig.ValueFunction(func() string { - for _, container := range ctx.Plan().Containers { - if container.Name == mconfig.PlannedContainerName(ctx.Plan(), pd) { + for id, container := range ctx.Plan().Containers { + if id == pd.GetUniqueId() { return fmt.Sprintf("%d", ctx.Plan().AllocatedPorts[container.Ports[0]]) } } diff --git a/mrunner/databases/postgres_legacy_container.go b/mrunner/databases/postgres_legacy_container.go index 9549c1f..4edb4ea 100644 --- a/mrunner/databases/postgres_legacy_container.go +++ b/mrunner/databases/postgres_legacy_container.go @@ -36,8 +36,8 @@ func (pd *PostgresDriver) CreateContainer(ctx context.Context, c *client.Client, var mounts []mount.Mount = nil for _, container := range summary.Items { for _, n := range container.Names { - if n == a.Name { - pgLog.Println("Found existing container...") + if strings.HasSuffix(n, a.Name) { + pgLegacyLog.Println("Found existing container...") containerId = container.ID // Inspect the container to get the mounts @@ -52,6 +52,7 @@ func (pd *PostgresDriver) CreateContainer(ctx context.Context, c *client.Client, // Delete the container if it exists if containerId != "" { + pgLegacyLog.Println("Deleting old container...") if _, err := c.ContainerRemove(ctx, containerId, client.ContainerRemoveOptions{ RemoveVolumes: false, Force: true, @@ -110,7 +111,7 @@ func (pd *PostgresDriver) CreateContainer(ctx context.Context, c *client.Client, // Check for postgres health func (pd *PostgresDriver) IsHealthy(ctx context.Context, c *client.Client, container mconfig.ContainerInformation) (bool, error) { - readyCommand := "pg_isready -d postgres" + readyCommand := "pg_isready -d postgres -U postgres -t 0" cmd := strings.Split(readyCommand, " ") execConfig := client.ExecCreateOptions{ Cmd: cmd, @@ -132,6 +133,10 @@ func (pd *PostgresDriver) IsHealthy(ctx context.Context, c *client.Client, conta return false, fmt.Errorf("couldn't inspect command for readiness of container: %s", err) } + if mconfig.VerboseLogging { + pgLegacyLog.Println("Database health check response code:", respInspect.ExitCode) + } + return respInspect.ExitCode == 0, nil } @@ -147,7 +152,7 @@ func (pd *PostgresDriver) Initialize(ctx context.Context, c *client.Client, cont defer conn.Close() for _, db := range pd.databases { - pgLog.Println("Creating database", db+"...") + pgLegacyLog.Println("Creating database", db+"...") _, err := conn.Exec(fmt.Sprintf("CREATE DATABASE %s", db)) if err != nil && !strings.Contains(err.Error(), "already exists") { return fmt.Errorf("couldn't create postgres database: %s", err) diff --git a/mrunner/runner_deploy.go b/mrunner/runner_deploy.go index a6e7895..3ca309e 100644 --- a/mrunner/runner_deploy.go +++ b/mrunner/runner_deploy.go @@ -89,6 +89,8 @@ func (r *Runner) pullServiceImages(ctx context.Context) error { return fmt.Errorf("error while pulling image %s: %s", image, err) } + util.Log.Println(string(buf[:n])) + // Print progress update every second if time.Since(lastUpdate) >= time.Second { util.Log.Println("Downloading", image+"...") @@ -127,6 +129,7 @@ func (r *Runner) startServiceContainers(ctx context.Context) error { } // Create the container using the driver + util.Log.Println("Creating container for driver", driver.GetUniqueId()+"...") containerID, err := driver.CreateContainer(ctx, r.client, mconfig.ContainerAllocation{ Name: name, Ports: containerPorts, @@ -147,7 +150,7 @@ func (r *Runner) startServiceContainers(ctx context.Context) error { containerInfo := mconfig.ContainerInformation{ ID: containerID, Name: name, - Ports: r.plan.Containers[driver.GetUniqueId()].Ports, + Ports: containerPorts, } for { diff --git a/mrunner/runner_plan.go b/mrunner/runner_plan.go index dba1b0e..98e8e00 100644 --- a/mrunner/runner_plan.go +++ b/mrunner/runner_plan.go @@ -1,10 +1,8 @@ package mrunner import ( - "fmt" "slices" - "github.com/Liphium/magic/v2/integration" "github.com/Liphium/magic/v2/mconfig" "github.com/Liphium/magic/v2/util" ) @@ -20,9 +18,13 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { util.Log.Fatalln("no context set") } + // Set basic stuff + r.plan.AppName = r.ctx.AppName() + r.plan.Profile = r.ctx.Profile() + // Collect all the ports that should be allocated (also for the service drivers obv) portsToAllocate := r.ctx.Ports() - startPort := DefaultStartPort + currentPort := DefaultStartPort containerMap := map[string]mconfig.ContainerAllocation{} for _, driver := range r.ctx.Services() { if _, ok := containerMap[driver.GetUniqueId()]; ok { @@ -37,13 +39,13 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { for range driver.GetRequiredPortAmount() { // Make sure we're not allocating a port that's already taken - for slices.Contains(portsToAllocate, startPort) { - startPort++ + for slices.Contains(portsToAllocate, currentPort) && !util.ScanPort(currentPort) { + currentPort = util.RandomPort(DefaultStartPort, DefaultEndPort) } // Allocate one of the default ports for the container - portsToAllocate = append(portsToAllocate, startPort) - startPort++ + portsToAllocate = append(portsToAllocate, currentPort) + alloc.Ports = append(alloc.Ports, currentPort) } containerMap[driver.GetUniqueId()] = alloc @@ -56,12 +58,8 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { // Generate a new port in case the current one is taken toAllocate := port - if integration.ScanPort(port) { - var err error - toAllocate, err = scanForOpenPort() - if err != nil { - util.Log.Fatalln("Couldn't find open port for", port, ":", err) - } + for slices.Contains(portsToAllocate, port) && !util.ScanPort(toAllocate) { + toAllocate = util.RandomPort(DefaultStartPort, DefaultEndPort) } // Add the port to the plan @@ -70,12 +68,8 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { } // Load into plan - r.plan = &mconfig.Plan{ - AppName: r.ctx.AppName(), - Profile: r.ctx.Profile(), - Containers: containerMap, - AllocatedPorts: allocatedPorts, - } + r.plan.Containers = containerMap + r.plan.AllocatedPorts = allocatedPorts // Generate the environment variables and add to plan environment := map[string]string{} @@ -85,12 +79,3 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { r.plan.Environment = environment return r.plan } - -// Scan for an open port in the default range -func scanForOpenPort() (uint, error) { - openPort, err := integration.ScanForOpenPort(DefaultStartPort, DefaultEndPort) - if err != nil { - return 0, fmt.Errorf("couldn't find open port: %e", err) - } - return openPort, err -} diff --git a/util/util.go b/util/util.go index d285a13..65f8b26 100644 --- a/util/util.go +++ b/util/util.go @@ -1,8 +1,10 @@ package util import ( + "fmt" "log" "math/rand" + "net" "os" "time" ) @@ -18,3 +20,18 @@ func RandomString(length int) string { } return string(b) } + +// Generate a random port +func RandomPort(start, end uint) uint { + return start + uint(rand.Intn(int(end-start+1))) +} + +// Scan an individual port. Returns true when the creation of the listener succeeds. +func ScanPort(port uint) bool { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err == nil { + listener.Close() + return true + } + return false +} From 199a72b2ce87f91193f0fffbf85c23c76059f3dd Mon Sep 17 00:00:00 2001 From: Unbreathable <70802809+Unbreathable@users.noreply.github.com> Date: Sun, 8 Feb 2026 20:45:29 +0100 Subject: [PATCH 07/10] fix: Make sure service drivers are persisted properly --- mconfig/environment.go | 2 +- mconfig/plan.go | 3 +- mconfig/services.go | 6 +++ mrunner/databases/postgres_legacy.go | 27 +++++++++--- .../databases/postgres_legacy_container.go | 6 +-- mrunner/databases/postgres_legacy_instruct.go | 44 +++++++++++-------- mrunner/runner_deploy.go | 26 ++++++++--- mrunner/runner_plan.go | 14 +++++- 8 files changed, 90 insertions(+), 38 deletions(-) diff --git a/mconfig/environment.go b/mconfig/environment.go index cc16feb..d2c29d5 100644 --- a/mconfig/environment.go +++ b/mconfig/environment.go @@ -63,7 +63,7 @@ func (c *Context) ValuePort(preferredPort uint) EnvironmentValue { // Make sure the port isn't already allocated if slices.Contains(c.ports, preferredPort) { - log.Fatalln("port", preferredPort, "is already taken: taken ports: ", c.ports) + log.Fatalln("Port", preferredPort, "is already taken: taken ports: ", c.ports) } c.ports = append(c.ports, preferredPort) diff --git a/mconfig/plan.go b/mconfig/plan.go index a8d67cd..fa39ef9 100644 --- a/mconfig/plan.go +++ b/mconfig/plan.go @@ -18,7 +18,8 @@ type Plan struct { Profile string `json:"profile"` Environment map[string]string `json:"environment"` AllocatedPorts map[uint]uint `json:"ports"` - Containers map[string]ContainerAllocation `json:"containers"` // Id -> Container allocation + Containers map[string]ContainerAllocation `json:"containers"` // Service id -> Container allocation + Services map[string]string `json:"services"` // Service id -> Data } // Name for a service container (get by plan) diff --git a/mconfig/services.go b/mconfig/services.go index bc22965..c3f54e2 100644 --- a/mconfig/services.go +++ b/mconfig/services.go @@ -42,6 +42,12 @@ type ServiceDriver interface { // // When implementing, please look into the instructions you can support. HandleInstruction(ctx context.Context, c *client.Client, container ContainerInformation, instruction Instruction) error + + // For creating a new instance of the service driver with the loaded data + Load(data string) (ServiceDriver, error) + + // Save the current data of the service driver into string form (will be persisted in the plan) + Save() (string, error) } // All things required to create a service container diff --git a/mrunner/databases/postgres_legacy.go b/mrunner/databases/postgres_legacy.go index 8c483bd..b9a0737 100644 --- a/mrunner/databases/postgres_legacy.go +++ b/mrunner/databases/postgres_legacy.go @@ -1,6 +1,7 @@ package databases import ( + "encoding/json" "fmt" "log" "os" @@ -25,8 +26,8 @@ const ( var pgLegacyLog *log.Logger = log.New(os.Stdout, "pg-legacy ", log.Default().Flags()) type PostgresDriver struct { - image string - databases []string + Image string `json:"image"` + Databases []string `json:"databases"` } // Create a new PostgreSQL legacy service driver. @@ -52,12 +53,28 @@ func NewLegacyPostgresDriver(image string) *PostgresDriver { } return &PostgresDriver{ - image: image, + Image: image, } } +func (pd *PostgresDriver) Load(data string) (mconfig.ServiceDriver, error) { + var driver PostgresDriver + if err := json.Unmarshal([]byte(data), &driver); err != nil { + return nil, err + } + return &driver, nil +} + +func (pd *PostgresDriver) Save() (string, error) { + bytes, err := json.Marshal(pd) + if err != nil { + return "", err + } + return string(bytes), nil +} + func (pd *PostgresDriver) NewDatabase(name string) *PostgresDriver { - pd.databases = append(pd.databases, name) + pd.Databases = append(pd.Databases, name) return pd } @@ -71,7 +88,7 @@ func (pd *PostgresDriver) GetRequiredPortAmount() int { } func (pd *PostgresDriver) GetImage() string { - return pd.image + return pd.Image } // Get the username of the databases in this driver as a EnvironmentValue for your config. diff --git a/mrunner/databases/postgres_legacy_container.go b/mrunner/databases/postgres_legacy_container.go index 4edb4ea..ba02511 100644 --- a/mrunner/databases/postgres_legacy_container.go +++ b/mrunner/databases/postgres_legacy_container.go @@ -18,7 +18,7 @@ import ( func (pd *PostgresDriver) CreateContainer(ctx context.Context, c *client.Client, a mconfig.ContainerAllocation) (string, error) { // Set to default username and password when not set - if pd.image == "" { + if pd.Image == "" { return "", fmt.Errorf("please specify a proper image") } @@ -91,7 +91,7 @@ func (pd *PostgresDriver) CreateContainer(ctx context.Context, c *client.Client, // Create the container resp, err := c.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: &container.Config{ - Image: pd.image, + Image: pd.Image, Env: []string{ fmt.Sprintf("POSTGRES_PASSWORD=%s", PostgresPassword), fmt.Sprintf("POSTGRES_USER=%s", PostgresUsername), @@ -151,7 +151,7 @@ func (pd *PostgresDriver) Initialize(ctx context.Context, c *client.Client, cont } defer conn.Close() - for _, db := range pd.databases { + for _, db := range pd.Databases { pgLegacyLog.Println("Creating database", db+"...") _, err := conn.Exec(fmt.Sprintf("CREATE DATABASE %s", db)) if err != nil && !strings.Contains(err.Error(), "already exists") { diff --git a/mrunner/databases/postgres_legacy_instruct.go b/mrunner/databases/postgres_legacy_instruct.go index 23d2703..934336a 100644 --- a/mrunner/databases/postgres_legacy_instruct.go +++ b/mrunner/databases/postgres_legacy_instruct.go @@ -29,29 +29,35 @@ type iterateTablesFn func(tableName string, conn *sql.DB) error // iterateTables iterates through all tables in all databases and applies the given function func (pd *PostgresDriver) iterateTables(container mconfig.ContainerInformation, fn iterateTablesFn) error { // For all databases, connect and iterate tables - for _, db := range pd.databases { - connStr := fmt.Sprintf("host=127.0.0.1 port=%d user=postgres password=postgres dbname=%s sslmode=disable", container.Ports[0], db) + for _, db := range pd.Databases { + if err := func() error { + connStr := fmt.Sprintf("host=127.0.0.1 port=%d user=postgres password=postgres dbname=%s sslmode=disable", container.Ports[0], db) - // Connect to the database - conn, err := sql.Open("postgres", connStr) - if err != nil { - return fmt.Errorf("couldn't connect to postgres: %v", err) - } - defer conn.Close() + // Connect to the database + conn, err := sql.Open("postgres", connStr) + if err != nil { + return fmt.Errorf("couldn't connect to postgres: %v", err) + } + defer conn.Close() - // Get all of the tables - res, err := conn.Query("SELECT table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema')") - if err != nil { - return fmt.Errorf("couldn't get database tables: %v", err) - } - for res.Next() { - var name string - if err := res.Scan(&name); err != nil { - return fmt.Errorf("couldn't get database table name: %v", err) + // Get all of the tables + res, err := conn.Query("SELECT table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema')") + if err != nil { + return fmt.Errorf("couldn't get database tables: %v", err) } - if err := fn(name, conn); err != nil { - return err + for res.Next() { + var name string + if err := res.Scan(&name); err != nil { + return fmt.Errorf("couldn't get database table name: %v", err) + } + if err := fn(name, conn); err != nil { + return err + } } + + return nil + }(); err != nil { + return err } } diff --git a/mrunner/runner_deploy.go b/mrunner/runner_deploy.go index 3ca309e..b5cdab4 100644 --- a/mrunner/runner_deploy.go +++ b/mrunner/runner_deploy.go @@ -190,6 +190,17 @@ func (r *Runner) startServiceContainers(ctx context.Context) error { return nil } +// Helper function to load a driver for a driver unique id +func (r *Runner) loadDriver(id string) (mconfig.ServiceDriver, error) { + driver, ok := mconfig.GetDriver(id) + if !ok { + return nil, fmt.Errorf("couldn't find service driver for service type: %s", id) + } + + // Create a new driver from the data in the services + return driver.Load(r.Plan().Services[id]) +} + // Helper function to iterate over containers and execute a callback for each found container func (r *Runner) forEachContainer(ctx context.Context, callback func(service string, containerID string, container mconfig.ContainerAllocation) error) error { for service, container := range r.plan.Containers { @@ -201,7 +212,7 @@ func (r *Runner) forEachContainer(ctx context.Context, callback func(service str Filters: f, }) if err != nil { - util.Log.Fatalln("Couldn't list containers:", err) + return fmt.Errorf("Couldn't list containers: %v", err) } containerId := "" for _, c := range summary.Items { @@ -250,6 +261,7 @@ func (r *Runner) DeleteEverything() error { containerInfo, err := r.client.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) if err != nil { util.Log.Println("Warning: Couldn't inspect container:", err) + return nil } var volumeNames []string if containerInfo.Container.Mounts != nil { @@ -287,9 +299,9 @@ func (r *Runner) DeleteEverything() error { func (r *Runner) DropTables() error { ctx := context.Background() return r.forEachContainer(ctx, func(service, containerID string, container mconfig.ContainerAllocation) error { - driver, ok := mconfig.GetDriver(service) - if !ok { - return fmt.Errorf("couldn't find service driver for service type: %s", service) + driver, err := r.loadDriver(service) + if err != nil { + return err } // Convert the ports @@ -314,9 +326,9 @@ func (r *Runner) DropTables() error { func (r *Runner) ClearTables() error { ctx := context.Background() return r.forEachContainer(ctx, func(service, containerID string, container mconfig.ContainerAllocation) error { - driver, ok := mconfig.GetDriver(service) - if !ok { - return fmt.Errorf("couldn't find service driver for service type: %s", service) + driver, err := r.loadDriver(service) + if err != nil { + return err } // Convert the ports diff --git a/mrunner/runner_plan.go b/mrunner/runner_plan.go index 98e8e00..d71d6e2 100644 --- a/mrunner/runner_plan.go +++ b/mrunner/runner_plan.go @@ -24,7 +24,6 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { // Collect all the ports that should be allocated (also for the service drivers obv) portsToAllocate := r.ctx.Ports() - currentPort := DefaultStartPort containerMap := map[string]mconfig.ContainerAllocation{} for _, driver := range r.ctx.Services() { if _, ok := containerMap[driver.GetUniqueId()]; ok { @@ -39,6 +38,7 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { for range driver.GetRequiredPortAmount() { // Make sure we're not allocating a port that's already taken + currentPort := util.RandomPort(DefaultStartPort, DefaultEndPort) for slices.Contains(portsToAllocate, currentPort) && !util.ScanPort(currentPort) { currentPort = util.RandomPort(DefaultStartPort, DefaultEndPort) } @@ -58,7 +58,7 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { // Generate a new port in case the current one is taken toAllocate := port - for slices.Contains(portsToAllocate, port) && !util.ScanPort(toAllocate) { + for !util.ScanPort(toAllocate) && slices.Contains(portsToAllocate, toAllocate) { toAllocate = util.RandomPort(DefaultStartPort, DefaultEndPort) } @@ -71,6 +71,16 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { r.plan.Containers = containerMap r.plan.AllocatedPorts = allocatedPorts + // Add all the services to the plan + r.plan.Services = map[string]string{} + for _, driver := range r.ctx.Services() { + data, err := driver.Save() + if err != nil { + util.Log.Fatalln("couldn't persist service driver of type", driver.GetUniqueId()+":", err) + } + r.plan.Services[driver.GetUniqueId()] = data + } + // Generate the environment variables and add to plan environment := map[string]string{} if r.ctx.Environment() != nil { From bd76642bd184861da70243e645cf009e05527a44 Mon Sep 17 00:00:00 2001 From: Unbreathable <70802809+Unbreathable@users.noreply.github.com> Date: Sun, 8 Feb 2026 20:49:31 +0100 Subject: [PATCH 08/10] fix: Make sure port allocation is skipped properly --- mrunner/runner_plan.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mrunner/runner_plan.go b/mrunner/runner_plan.go index d71d6e2..87d9cea 100644 --- a/mrunner/runner_plan.go +++ b/mrunner/runner_plan.go @@ -53,7 +53,7 @@ func (r *Runner) GeneratePlan() *mconfig.Plan { // Prepare all of the ports allocatedPorts := map[uint]uint{} - if len(portsToAllocate) >= 0 { + if len(portsToAllocate) > 0 { for _, port := range portsToAllocate { // Generate a new port in case the current one is taken From d129e9a253fed2ddd1c721e8da2f76af9d91d401 Mon Sep 17 00:00:00 2001 From: Unbreathable <70802809+Unbreathable@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:48:38 +0100 Subject: [PATCH 09/10] fix(examples): Make sure the logs properly indicate what's being done --- examples/real-project/starter/scripts_database.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/real-project/starter/scripts_database.go b/examples/real-project/starter/scripts_database.go index 1c97e50..a1e91f8 100644 --- a/examples/real-project/starter/scripts_database.go +++ b/examples/real-project/starter/scripts_database.go @@ -12,14 +12,14 @@ import ( // // Here we just use any to ignore the argument. This can be useful for scripts such as this one. func ClearDatabases(runner *mrunner.Runner) error { - log.Println("Resetting database...") + log.Println("Clearing database...") // Magic can clear all databases for you, don't worry, only data will be deleted meaning your schema is still all good :D if err := runner.ClearTables(); err != nil { log.Fatalln("Couldn't clear database tables:", err) } - log.Println("Database reset completed successfully!") + log.Println("Database clear completed successfully!") return nil } @@ -31,7 +31,7 @@ func ResetDatabase(runner *mrunner.Runner) error { // Magic can drop all databases for you as well, this means that all the tables are actually gone if err := runner.DropTables(); err != nil { - log.Fatalln("Couldn't clear database tables:", err) + log.Fatalln("Couldn't reset database tables:", err) } log.Println("Database reset completed successfully!") From 6a1289268f77092488542651c56b44503c96bdcb Mon Sep 17 00:00:00 2001 From: Unbreathable <70802809+Unbreathable@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:33:32 +0100 Subject: [PATCH 10/10] chore: Fix weird comments --- mconfig/services.go | 2 +- mrunner/databases/postgres_legacy.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mconfig/services.go b/mconfig/services.go index c3f54e2..2bb650e 100644 --- a/mconfig/services.go +++ b/mconfig/services.go @@ -26,7 +26,7 @@ type ServiceDriver interface { // Should return the amount of ports required to start the container. GetRequiredPortAmount() int - // Should return the image. Magic will + // Should return the image. Magic will pull it automatically. GetImage() string // Create a new container for this type of service diff --git a/mrunner/databases/postgres_legacy.go b/mrunner/databases/postgres_legacy.go index b9a0737..6804967 100644 --- a/mrunner/databases/postgres_legacy.go +++ b/mrunner/databases/postgres_legacy.go @@ -78,9 +78,9 @@ func (pd *PostgresDriver) NewDatabase(name string) *PostgresDriver { return pd } -// A unique identifier for the database container +// A unique identifier for the database driver. This is appended to the container name to make sure we know it's the container from the driver. func (pd *PostgresDriver) GetUniqueId() string { - return "postgres1417" + return "postgres1417" // Context for this: Since this driver supports PostgreSQL v14-v17 this just makes it easier to know when seeing the container in "docker ps" or sth } func (pd *PostgresDriver) GetRequiredPortAmount() int {