diff --git a/engine/.golangci.yml b/engine/.golangci.yml index 2b9a46df2c3672eed1df422a987c10a232ccb6eb..e43f4a37f4b5c88f3ecce8999493e5f98d6cab92 100644 --- a/engine/.golangci.yml +++ b/engine/.golangci.yml @@ -25,7 +25,7 @@ linters-settings: lang-version: "1.17" extra-rules: false gosimple: - go: "1.17" + go: "1.18" checks: [ "all" ] goimports: local-prefixes: gitlab.com/postgres-ai/database-lab diff --git a/engine/Makefile b/engine/Makefile index 746192a395f38e5232cbf56cd2cb537631ec0179..5e01b8312c0c3dc471cbcef1e42204c67342c041 100644 --- a/engine/Makefile +++ b/engine/Makefile @@ -34,7 +34,7 @@ help: ## Display the help message all: clean build ## Build all binary components of the project install-lint: ## Install the linter to $GOPATH/bin which is expected to be in $PATH - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.45.0 + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.45.2 run-lint: ## Run linters golangci-lint run diff --git a/engine/go.mod b/engine/go.mod index ae2654f3cac8ad165a6808014542afcfbc739ea2..de3c2b64a40b43fd13506e00572f252b7e5805ee 100644 --- a/engine/go.mod +++ b/engine/go.mod @@ -1,6 +1,6 @@ module gitlab.com/postgres-ai/database-lab/v3 -go 1.17 +go 1.18 require ( github.com/AlekSi/pointer v1.1.0 diff --git a/engine/internal/retrieval/engine/postgres/tools/cont/container.go b/engine/internal/retrieval/engine/postgres/tools/cont/container.go index 92b1c054294888d0b86d8deed08410a0956796f5..18eb52a5a0de87b748eae8e2c0ff64d79ea90f04 100644 --- a/engine/internal/retrieval/engine/postgres/tools/cont/container.go +++ b/engine/internal/retrieval/engine/postgres/tools/cont/container.go @@ -57,6 +57,8 @@ const ( DBLabRestoreLabel = "dblab_restore" // DBLabEmbeddedUILabel defines a label value for embedded UI containers. DBLabEmbeddedUILabel = "dblab_embedded_ui" + // DBLabFoundationLabel defines a label value to mark foundation containers. + DBLabFoundationLabel = "dblab_foundation" // DBLabRunner defines a label to mark runner containers. DBLabRunner = "dblab_runner" diff --git a/engine/internal/retrieval/engine/postgres/tools/db/image_content.go b/engine/internal/retrieval/engine/postgres/tools/db/image_content.go new file mode 100644 index 0000000000000000000000000000000000000000..845d31fe377500846f9d094011fde2a231ba357a --- /dev/null +++ b/engine/internal/retrieval/engine/postgres/tools/db/image_content.go @@ -0,0 +1,274 @@ +/* +2022 © Postgres.ai +*/ + +package db + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/jackc/pgx/v4" + + dockerTools "gitlab.com/postgres-ai/database-lab/v3/internal/provision/docker" + "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/engine/postgres/tools" + "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/engine/postgres/tools/cont" + "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/engine/postgres/tools/health" + "gitlab.com/postgres-ai/database-lab/v3/pkg/config/global" + "gitlab.com/postgres-ai/database-lab/v3/pkg/log" + "gitlab.com/postgres-ai/database-lab/v3/pkg/util/networks" +) + +const ( + extensionQuery = "select jsonb_object_agg(name, default_version) from pg_available_extensions" + + port = "5432" + username = "postgres" + dbname = "postgres" + password = "" + + foundationName = "dblab_foundation_" + + defaultRetries = 10 +) + +// ImageContent keeps the content lists from the foundation image. +type ImageContent struct { + engineProps global.EngineProps + isReady bool + extensions map[string]string + locales map[string]struct{} + databases map[string]struct{} +} + +// IsReady reports if the ImageContent has collected details about the current image. +func (i *ImageContent) IsReady() bool { + return i.isReady +} + +// NewImageContent creates a new ImageContent. +func NewImageContent(engineProps global.EngineProps) *ImageContent { + return &ImageContent{ + engineProps: engineProps, + extensions: make(map[string]string, 0), + locales: make(map[string]struct{}, 0), + databases: make(map[string]struct{}, 0), + } +} + +// Extensions provides list of Postgres extensions from the foundation image. +func (i *ImageContent) Extensions() map[string]string { + return i.extensions +} + +// Locales provides list of locales from the foundation image. +func (i *ImageContent) Locales() map[string]struct{} { + return i.locales +} + +// SetDatabases sets a list of databases mentioned in the Retrieval config. +// An empty list means all databases. +func (i *ImageContent) SetDatabases(dbList []string) { + if len(dbList) == 0 { + i.databases = make(map[string]struct{}, 0) + return + } + + for _, dbName := range dbList { + i.databases[dbName] = struct{}{} + } +} + +// Databases returns the list of databases mentioned in the Retrieval config. +// An empty list means all databases. +func (i *ImageContent) Databases() map[string]struct{} { + return i.databases +} + +// Collect collects extension and locale lists from the provided Docker image. +func (i *ImageContent) Collect(dockerImage string) error { + docker, err := client.NewClientWithOpts(client.FromEnv, client.WithVersion("1.39")) + if err != nil { + log.Fatal("Failed to create a Docker client:", err) + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) + defer cancel() + + if err := i.collectImageContent(ctx, docker, dockerImage); err != nil { + return err + } + + i.isReady = true + + log.Msg("The image content has been successfully collected") + + return nil +} + +func getFoundationName(instanceID string) string { + return foundationName + instanceID +} + +func (i *ImageContent) collectImageContent(ctx context.Context, docker *client.Client, dockerImage string) error { + containerID, err := createContainer(ctx, docker, dockerImage, i.engineProps) + if err != nil { + return fmt.Errorf("failed to create a Docker container: %w", err) + } + + defer tools.RemoveContainer(ctx, docker, containerID, time.Millisecond) + + if err := i.collectExtensions(ctx, i.engineProps.InstanceID); err != nil { + return fmt.Errorf("failed to collect extensions from the image %s: %w", dockerImage, err) + } + + if err := i.collectLocales(ctx, docker, containerID); err != nil { + return fmt.Errorf("failed to collect locales: %w", err) + } + + return nil +} + +func (i *ImageContent) collectExtensions(ctx context.Context, instanceID string) error { + conn, err := pgx.Connect(ctx, ConnectionString(getFoundationName(instanceID), port, username, dbname, password)) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + + var row []byte + + if err = conn.QueryRow(ctx, extensionQuery).Scan(&row); err != nil { + return err + } + + extensionMap := map[string]string{} + + if err := json.Unmarshal(row, &extensionMap); err != nil { + return err + } + + i.extensions = extensionMap + + return nil +} + +func (i *ImageContent) collectLocales(ctx context.Context, docker *client.Client, containerID string) error { + out, err := getLocales(ctx, docker, containerID) + if err != nil { + return err + } + + imageLocales := map[string]struct{}{} + + for _, line := range strings.Split(out, "\n") { + if len(line) != 0 { + locale := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(line)), "-", "") + imageLocales[locale] = struct{}{} + } + } + + i.locales = imageLocales + + return nil +} + +func createContainer(ctx context.Context, docker *client.Client, image string, props global.EngineProps) (string, error) { + if err := dockerTools.PrepareImage(ctx, docker, image); err != nil { + return "", fmt.Errorf("failed to prepare Docker image: %w", err) + } + + containerConf := &container.Config{ + Labels: map[string]string{ + cont.DBLabControlLabel: cont.DBLabFoundationLabel, + cont.DBLabInstanceIDLabel: props.InstanceID, + cont.DBLabEngineNameLabel: props.ContainerName, + }, + Env: []string{ + "POSTGRES_HOST_AUTH_METHOD=trust", + }, + Image: image, + Healthcheck: health.GetConfig(username, dbname, + health.OptionInterval(health.DefaultRestoreInterval), health.OptionRetries(defaultRetries)), + } + + containerName := getFoundationName(props.InstanceID) + + containerID, err := tools.CreateContainerIfMissing(ctx, docker, containerName, containerConf, &container.HostConfig{}) + if err != nil { + return "", fmt.Errorf("failed to create container %q %w", containerName, err) + } + + log.Msg(fmt.Sprintf("Running container: %s. ID: %v", containerName, containerID)) + + if err := docker.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil { + return "", fmt.Errorf("failed to start container %q: %w", containerName, err) + } + + if err := tools.InitDB(ctx, docker, containerID); err != nil { + return "", fmt.Errorf("failed to init Postgres: %w", err) + } + + if err := resetHBA(ctx, docker, containerID); err != nil { + return "", fmt.Errorf("failed to init Postgres: %w", err) + } + + if err := tools.StartPostgres(ctx, docker, containerID, tools.DefaultStopTimeout); err != nil { + return "", fmt.Errorf("failed to init Postgres: %w", err) + } + + log.Dbg("Waiting for container readiness") + + if err := tools.CheckContainerReadiness(ctx, docker, containerID); err != nil { + return "", fmt.Errorf("failed to readiness check: %w", err) + } + + if err := networks.Connect(ctx, docker, props.InstanceID, containerID); err != nil { + return "", fmt.Errorf("failed to connect UI container to the internal Docker network: %w", err) + } + + return containerID, nil +} + +func resetHBA(ctx context.Context, dockerClient *client.Client, containerID string) error { + command := []string{"sh", "-c", `su postgres -c "echo 'hostnossl all all 0.0.0.0/0 trust' > ${PGDATA}/pg_hba.conf"`} + + log.Dbg("Reset pg_hba", command) + + out, err := tools.ExecCommandWithOutput(ctx, dockerClient, containerID, types.ExecConfig{ + Tty: true, + Cmd: command, + }) + + if err != nil { + return fmt.Errorf("failed to reset pg_hba.conf: %w", err) + } + + log.Dbg(out) + + return nil +} + +func getLocales(ctx context.Context, dockerClient *client.Client, containerID string) (string, error) { + command := []string{"sh", "-c", `locale -a`} + + log.Dbg("Get locale list", command) + + out, err := tools.ExecCommandWithOutput(ctx, dockerClient, containerID, types.ExecConfig{ + Tty: true, + Cmd: command, + }) + + if err != nil { + return "", fmt.Errorf("failed to get locale list: %w", err) + } + + return out, nil +} diff --git a/engine/internal/retrieval/engine/postgres/tools/db/pg.go b/engine/internal/retrieval/engine/postgres/tools/db/pg.go index a438003fa0b33a0a36447eac3ca1c23fee439d77..4e3baf1f22e5f712f37c0ab009a9003b37914fc3 100644 --- a/engine/internal/retrieval/engine/postgres/tools/db/pg.go +++ b/engine/internal/retrieval/engine/postgres/tools/db/pg.go @@ -6,10 +6,243 @@ package db import ( + "context" + "errors" "fmt" + "strings" + + "github.com/jackc/pgx/v4" + "golang.org/x/mod/semver" + + "gitlab.com/postgres-ai/database-lab/v3/pkg/log" + "gitlab.com/postgres-ai/database-lab/v3/pkg/models" ) // ConnectionString builds PostgreSQL connection string. func ConnectionString(host, port, username, dbname, password string) string { return fmt.Sprintf("host=%s port=%s user='%s' database='%s' password='%s'", host, port, username, dbname, password) } + +const ( + availableExtensions = "select name, default_version, coalesce(installed_version,'') from pg_available_extensions" + availableLocales = "select datname, lower(datcollate), lower(datctype) from pg_catalog.pg_database" +) + +type extension struct { + name string + defaultVersion string + installedVersion string +} + +type locale struct { + name string + collate string + ctype string +} + +// CheckSource checks the readiness of the source database to dump and restore processes. +func CheckSource(ctx context.Context, conf *models.ConnectionTest, imageContent *ImageContent) (*models.TestConnection, error) { + if !imageContent.IsReady() { + return &models.TestConnection{ + Status: models.TCStatusNotice, + Result: models.TCResultUnexploredImage, + Message: "Service has not collected data about the Docker image yet. Please try again later", + }, nil + } + + connStr := ConnectionString(conf.Host, conf.Port, conf.Username, conf.DBName, conf.Password) + + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + log.Dbg("failed to test database connection:", err) + + return &models.TestConnection{ + Status: models.TCStatusError, + Result: models.TCResultConnectionError, + Message: err.Error(), + }, nil + } + + defer func() { + if err := conn.Close(ctx); err != nil { + log.Dbg("failed to close connection:", err) + } + }() + + var one int + + if err := conn.QueryRow(ctx, "select 1").Scan(&one); err != nil { + return &models.TestConnection{ + Status: models.TCStatusError, + Result: models.TCResultConnectionError, + Message: err.Error(), + }, nil + } + + if missing, unsupported, err := checkExtensions(ctx, conn, imageContent.Extensions()); err != nil { + return &models.TestConnection{ + Status: models.TCStatusWarning, + Result: models.TCResultMissingExtension, + Message: buildExtensionsWarningMessage(missing, unsupported), + }, nil + } + + if missing, err := checkLocales(ctx, conn, imageContent.Locales(), imageContent.Databases()); err != nil { + return &models.TestConnection{ + Status: models.TCStatusWarning, + Result: models.TCResultMissingLocale, + Message: buildLocalesWarningMessage(missing), + }, nil + } + + return &models.TestConnection{ + Status: models.TCStatusOK, + Result: models.TCResultOK, + Message: models.TCMessageOK, + }, nil +} + +func checkExtensions(ctx context.Context, conn *pgx.Conn, imageExtensions map[string]string) ([]extension, []extension, error) { + rows, err := conn.Query(ctx, availableExtensions) + if err != nil { + return nil, nil, err + } + + missingExtensions := []extension{} + unsupportedVersions := []extension{} + + for rows.Next() { + var ext extension + if err := rows.Scan(&ext.name, &ext.defaultVersion, &ext.installedVersion); err != nil { + return nil, nil, err + } + + imageExt, ok := imageExtensions[ext.name] + if !ok { + missingExtensions = append(missingExtensions, ext) + continue + } + + if !semver.IsValid(toCanonicalSemver(ext.defaultVersion)) { + unsupportedVersions = append(unsupportedVersions, ext) + continue + } + + if semver.Compare(toCanonicalSemver(imageExt), toCanonicalSemver(ext.defaultVersion)) == -1 { + unsupportedVersions = append(unsupportedVersions, ext) + } + } + + if err := rows.Err(); err != nil { + return nil, nil, err + } + + if len(missingExtensions) != 0 || len(unsupportedVersions) != 0 { + return missingExtensions, unsupportedVersions, errors.New("extension warning found") + } + + return nil, nil, nil +} + +func toCanonicalSemver(v string) string { + if v == "" { + return "" + } + + if v[0] != 'v' { + return "v" + v + } + + return v +} + +func buildExtensionsWarningMessage(missingExtensions, unsupportedVersions []extension) string { + sb := &strings.Builder{} + + if len(missingExtensions) > 0 { + sb.WriteString("There are missing extensions:") + + formatExtensionList(sb, missingExtensions) + + sb.WriteRune('\n') + } + + if len(unsupportedVersions) > 0 { + sb.WriteString("There are extensions with an unsupported version:") + + formatExtensionList(sb, unsupportedVersions) + } + + return sb.String() +} + +func formatExtensionList(sb *strings.Builder, extensions []extension) { + length := len(extensions) + + for i, missing := range extensions { + sb.WriteString(" " + missing.name + " " + missing.defaultVersion) + + if i != length-1 { + sb.WriteRune(',') + } + } +} + +func checkLocales(ctx context.Context, conn *pgx.Conn, imageLocales, databases map[string]struct{}) ([]locale, error) { + rows, err := conn.Query(ctx, availableLocales) + if err != nil { + return nil, err + } + + missingLocales := []locale{} + + for rows.Next() { + var l locale + if err := rows.Scan(&l.name, &l.collate, &l.ctype); err != nil { + return nil, err + } + + if _, ok := databases[l.name]; len(databases) > 0 && !ok { + // Skip the check if there is a list of restored databases, and it does not contain the current database. + continue + } + + cleanCollate := strings.ReplaceAll(strings.ToLower(l.collate), "-", "") + + if _, ok := imageLocales[cleanCollate]; !ok { + missingLocales = append(missingLocales, l) + continue + } + + cleanCtype := strings.ReplaceAll(strings.ToLower(l.ctype), "-", "") + + if _, ok := imageLocales[cleanCtype]; !ok { + missingLocales = append(missingLocales, l) + continue + } + } + + if len(missingLocales) != 0 { + return missingLocales, errors.New("locale warning found") + } + + return nil, nil +} + +func buildLocalesWarningMessage(missingLocales []locale) string { + sb := &strings.Builder{} + + if length := len(missingLocales); length > 0 { + sb.WriteString("There are missing locales:") + + for i, missing := range missingLocales { + sb.WriteString(fmt.Sprintf(" '%s' (collate: %s, ctype: %s)", missing.name, missing.collate, missing.ctype)) + + if i != length-1 { + sb.WriteRune(',') + } + } + } + + return sb.String() +} diff --git a/engine/internal/retrieval/engine/postgres/tools/tools.go b/engine/internal/retrieval/engine/postgres/tools/tools.go index f4feee8596343109d424c5f6bacd6f5c367c3258..8f32ea83ebca50fbb85eb42351e1944037fc1d12 100644 --- a/engine/internal/retrieval/engine/postgres/tools/tools.go +++ b/engine/internal/retrieval/engine/postgres/tools/tools.go @@ -192,12 +192,12 @@ func InitDB(ctx context.Context, dockerClient *client.Client, containerID string Cmd: initCommand, }) + log.Dbg(out) + if err != nil { return errors.Wrap(err, "failed to init Postgres") } - log.Dbg(out) - return nil } diff --git a/engine/internal/retrieval/retrieval.go b/engine/internal/retrieval/retrieval.go index 17c679316c7a6268fe6abc69586f0c78268c111a..1152ef73d071f85e2dc21f036397d1a1930ebd7e 100644 --- a/engine/internal/retrieval/retrieval.go +++ b/engine/internal/retrieval/retrieval.go @@ -27,6 +27,8 @@ import ( "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/engine/postgres/physical" "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/engine/postgres/snapshot" "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/engine/postgres/tools/cont" + "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/engine/postgres/tools/db" + "gitlab.com/postgres-ai/database-lab/v3/internal/retrieval/options" "gitlab.com/postgres-ai/database-lab/v3/internal/telemetry" "gitlab.com/postgres-ai/database-lab/v3/pkg/util" @@ -50,6 +52,7 @@ type jobGroup string type Retrieval struct { Scheduler Scheduler State State + imageState *db.ImageContent cfg *config.Config global *global.Config engineProps global.EngineProps @@ -81,6 +84,7 @@ func New(cfg *dblabCfg.Config, engineProps global.EngineProps, docker *client.Cl Status: models.Inactive, alerts: make(map[models.AlertType]models.Alert), }, + imageState: db.NewImageContent(engineProps), } retrievalCfg, err := ValidateConfig(&cfg.Retrieval) @@ -97,6 +101,11 @@ func New(cfg *dblabCfg.Config, engineProps global.EngineProps, docker *client.Cl return r, nil } +// ImageContent provides the content of foundation Docker image. +func (r *Retrieval) ImageContent() *db.ImageContent { + return r.imageState +} + func checkPendingMarker(r *Retrieval) error { pendingPath, err := util.GetMetaPath(pendingFilename) if err != nil { @@ -172,6 +181,10 @@ func (r *Retrieval) Run(ctx context.Context) error { log.Msg("Retrieval mode:", r.State.Mode) + if err := r.collectFoundationImageContent(); err != nil { + return fmt.Errorf("failed to collect content lists from the foundation Docker image of the logicalDump job: %w", err) + } + fsManager, err := r.getNextPoolToDataRetrieving() if err != nil { var skipError *SkipRefreshingError @@ -217,6 +230,49 @@ func (r *Retrieval) Run(ctx context.Context) error { return nil } +func (r *Retrieval) collectFoundationImageContent() error { + if _, ok := r.cfg.JobsSpec[logical.DumpJobType]; !ok { + return nil + } + + dumpOptions := &logical.DumpOptions{} + + if err := r.JobConfig(logical.DumpJobType, &dumpOptions); err != nil { + return fmt.Errorf("failed to get config of %s job: %w", logical.DumpJobType, err) + } + + if err := r.imageState.Collect(dumpOptions.DockerImage); err != nil { + return err + } + + // Collect a list of databases mentioned in the Retrieval config. An empty list means all databases. + dbs := make([]string, 0) + + if len(dumpOptions.Databases) != 0 { + dbs = append(dbs, collectDBList(dumpOptions.Databases)...) + + restoreOptions := &logical.RestoreOptions{} + + if err := r.JobConfig(logical.RestoreJobType, &restoreOptions); err == nil && len(restoreOptions.Databases) != 0 { + dbs = append(dbs, collectDBList(restoreOptions.Databases)...) + } + } + + r.imageState.SetDatabases(dbs) + + return nil +} + +func collectDBList(definitions map[string]logical.DumpDefinition) []string { + dbs := []string{} + + for dbName := range definitions { + dbs = append(dbs, dbName) + } + + return dbs +} + func (r *Retrieval) getNextPoolToDataRetrieving() (pool.FSManager, error) { firstPool := r.poolManager.First() if firstPool == nil { @@ -603,3 +659,20 @@ func (r *Retrieval) ReportState() telemetry.Restore { Jobs: r.cfg.Jobs, } } + +// ErrStageNotFound means that the requested stage is not exist in the retrieval jobs config. +var ErrStageNotFound = errors.New("stage not found") + +// JobConfig parses job configuration to the provided structure. +func (r *Retrieval) JobConfig(stage string, jobCfg any) error { + stageSpec, ok := r.cfg.JobsSpec[stage] + if !ok { + return ErrStageNotFound + } + + if err := options.Unmarshal(stageSpec.Options, jobCfg); err != nil { + return fmt.Errorf("failed to unmarshal configuration options: %w", err) + } + + return nil +} diff --git a/engine/internal/srv/config.go b/engine/internal/srv/config.go index 5f544069484f16efa8842d0fa0cdee6878e04b25..2c9dff142d05afe828ccb797078e14a833c2c96c 100644 --- a/engine/internal/srv/config.go +++ b/engine/internal/srv/config.go @@ -7,7 +7,6 @@ import ( "net/http" "github.com/docker/docker/api/types" - "github.com/jackc/pgx/v4" yamlv2 "gopkg.in/yaml.v2" "gopkg.in/yaml.v3" @@ -83,23 +82,64 @@ func (s *Server) setProjectedAdminConfig(w http.ResponseWriter, r *http.Request) } } -func (s *Server) getAdminConnection(w http.ResponseWriter, r *http.Request) { +func (s *Server) testDBSource(w http.ResponseWriter, r *http.Request) { + if s.Retrieval.State.Mode != models.Logical { + api.SendBadRequestError(w, r, "the endpoint is only available in the Logical mode of the data retrieval") + return + } + var connection models.ConnectionTest if err := api.ReadJSON(r, &connection); err != nil { api.SendBadRequestError(w, r, err.Error()) return } - err := s.adminConnection(r.Context(), &connection) + if err := connectionPassword(&connection); err != nil { + api.SendBadRequestError(w, r, err.Error()) + return + } + + tc, err := db.CheckSource(r.Context(), &connection, s.Retrieval.ImageContent()) if err != nil { api.SendError(w, r, err) return } - err = api.WriteData(w, http.StatusOK, nil) + if err := api.WriteJSON(w, http.StatusOK, tc); err != nil { + api.SendError(w, r, err) + return + } +} + +func connectionPassword(connection *models.ConnectionTest) error { + if connection.Password != "" { + return nil + } + + proj := &models.ConfigProjection{} + + data, err := config.GetConfigBytes() if err != nil { - log.Err("failed to write response", err.Error()) + return fmt.Errorf("failed to get config: %w", err) + } + + node := &yaml.Node{} + + if err = yaml.Unmarshal(data, node); err != nil { + return fmt.Errorf("failed to unmarshal config: %w", err) } + + if err = projection.LoadYaml(proj, node, projection.LoadOptions{ + Groups: []string{"sensitive"}, + }); err != nil { + return fmt.Errorf("failed to load config projection: %w", err) + } + + if proj.Password != nil { + connection.Password = *proj.Password + } + + return nil } func adminConfigYaml() ([]byte, error) { @@ -163,62 +203,6 @@ func (s *Server) projectedAdminConfig() (interface{}, error) { return obj, nil } -func (s *Server) adminConnection(ctx context.Context, connection *models.ConnectionTest) error { - if connection.Password == "" { - proj := &models.ConfigProjection{} - - data, err := config.GetConfigBytes() - if err != nil { - return fmt.Errorf("failed to get config: %w", err) - } - - node := &yaml.Node{} - - err = yaml.Unmarshal(data, node) - if err != nil { - return fmt.Errorf("failed to unmarshal config: %w", err) - } - - err = projection.LoadYaml(proj, node, projection.LoadOptions{ - Groups: []string{"sensitive"}, - }) - if err != nil { - return fmt.Errorf("failed to load config projection: %w", err) - } - - if proj.Password != nil { - connection.Password = *proj.Password - } - } - - connStr := db.ConnectionString( - connection.Host, - connection.Port, - connection.Username, - connection.DBName, - connection.Password, - ) - - conn, err := pgx.Connect(ctx, connStr) - if err != nil { - return err - } - - defer func() { - _ = conn.Close(ctx) - }() - - row := conn.QueryRow(ctx, "SELECT 1") - - var one int - - if err := row.Scan(&one); err != nil { - return err - } - - return nil -} - func (s *Server) applyProjectedAdminConfig(ctx context.Context, obj interface{}) (interface{}, error) { if s.Retrieval.State.Mode != models.Logical { return nil, fmt.Errorf("config is only available in logical mode") diff --git a/engine/internal/srv/server.go b/engine/internal/srv/server.go index 45522936010991c024de5c1e7cfb88abed195395..7cbcf27d150da3d294acd1224cc72bcd31d5dc78 100644 --- a/engine/internal/srv/server.go +++ b/engine/internal/srv/server.go @@ -206,7 +206,7 @@ func (s *Server) InitHandlers() { adminR.HandleFunc("/config", s.getProjectedAdminConfig).Methods(http.MethodGet) adminR.HandleFunc("/config.yaml", s.getAdminConfigYaml).Methods(http.MethodGet) adminR.HandleFunc("/config", s.setProjectedAdminConfig).Methods(http.MethodPost) - adminR.HandleFunc("/test-db-connection", s.getAdminConnection).Methods(http.MethodPost) + adminR.HandleFunc("/test-db-connection", s.testDBSource).Methods(http.MethodPost) r.HandleFunc("/instance/logs", authMW.WebSocketsMW(s.wsService.tokenKeeper, s.instanceLogs)) diff --git a/engine/pkg/models/admin.go b/engine/pkg/models/admin.go new file mode 100644 index 0000000000000000000000000000000000000000..101dd0259b105c5497f7524c35cd1a62ae9d6713 --- /dev/null +++ b/engine/pkg/models/admin.go @@ -0,0 +1,40 @@ +package models + +const ( + // TCStatusOK defines the status code OK of the test connection request. + TCStatusOK = "ok" + + // TCStatusNotice defines the status code "notice" of the test connection request. + TCStatusNotice = "notice" + + // TCStatusWarning defines the status code "warning" of the test connection request. + TCStatusWarning = "warning" + + // TCStatusError defines the status code "error" of the test connection request. + TCStatusError = "error" + + // TCResultOK defines the result without errors of the test connection request. + TCResultOK = "ok" + + // TCResultConnectionError defines a connection error of the test connection request. + TCResultConnectionError = "connection_error" + + // TCResultUnexploredImage defines the notice about unexplored Docker image yet. + TCResultUnexploredImage = "unexplored_image" + + // TCResultMissingExtension defines the warning about a missing extension. + TCResultMissingExtension = "missing_extension" + + // TCResultMissingLocale defines the warning about a missing locale. + TCResultMissingLocale = "missing_locale" + + // TCMessageOK defines the source database is ready for dump and restore. + TCMessageOK = "Database ready for dump and restore" +) + +// TestConnection represents the response of the test connection request. +type TestConnection struct { + Status string `json:"status"` + Result string `json:"result"` + Message string `json:"message"` +}