From 6497f4fa3e8c0c5e1da0fc879925fb540f725e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 01:11:49 -0600 Subject: [PATCH 01/15] Add RestoreZip method to Client in postgres.go --- internal/integration/postgres/postgres.go | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/internal/integration/postgres/postgres.go b/internal/integration/postgres/postgres.go index f50784b..daf263b 100644 --- a/internal/integration/postgres/postgres.go +++ b/internal/integration/postgres/postgres.go @@ -5,6 +5,8 @@ import ( "bytes" "fmt" "io" + "net/http" + "os" "os/exec" "github.com/orsinium-labs/enum" @@ -195,3 +197,82 @@ func (c *Client) DumpZip( return reader } + +// RestoreZip downloads the ZIP from the given url, unzips it, and runs the +// psql command to restore the database. +func (Client) RestoreZip( + version PGVersion, connString string, zipURL string, +) error { + // Create a temporary directory + dir, err := os.MkdirTemp("", "pbw-restore-*") + if err != nil { + return fmt.Errorf("error creating temp dir: %w", err) + } + defer os.RemoveAll(dir) + + // Download the ZIP file from the given URL + zipPath := fmt.Sprintf("%s/dump.zip", dir) + resp, err := http.Get(zipURL) + if err != nil { + return fmt.Errorf("error downloading ZIP file: %w", err) + } + defer resp.Body.Close() + + out, err := os.Create(zipPath) + if err != nil { + return fmt.Errorf("error creating ZIP file: %w", err) + } + defer out.Close() + + if _, err = io.Copy(out, resp.Body); err != nil { + return fmt.Errorf("error writing to ZIP file: %w", err) + } + + // Unzip the file into the temp dir + zipReadCloser, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("error opening ZIP file: %w", err) + } + defer zipReadCloser.Close() + + var dumpPath string + for _, file := range zipReadCloser.File { + if file.Name == "dump.sql" { + dumpPath = fmt.Sprintf("%s/%s", dir, file.Name) + + fileReadCloser, err := file.Open() + if err != nil { + return fmt.Errorf("error opening dump.sql in ZIP file: %w", err) + } + defer fileReadCloser.Close() + + outFile, err := os.Create(dumpPath) + if err != nil { + return fmt.Errorf("error creating dump.sql: %w", err) + } + defer outFile.Close() + + if _, err = io.Copy(outFile, fileReadCloser); err != nil { + return fmt.Errorf("error writing dump.sql: %w", err) + } + + break + } + } + + if dumpPath == "" { + return fmt.Errorf("dump.sql not found in ZIP file") + } + + // Run the psql command to restore the database + cmd := exec.Command(version.Value.psql, connString, "-f", dumpPath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf( + "error running psql v%s command: %s", + version.Value.version, output, + ) + } + + return nil +} From 3d30729c5c5d8f177e6ab61cf6a4d2ac30819194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 17:25:42 -0600 Subject: [PATCH 02/15] Update Dockerfile dependencies to specific versions and add unzip dependency --- docker/Dockerfile | 4 +++- docker/Dockerfile.cicd | 4 +++- docker/Dockerfile.dev | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 46df87d..b871cb9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -19,7 +19,9 @@ ENV DEBIAN_FRONTEND=noninteractive # Install system dependencies RUN apt-get update && \ - apt-get install -y wget git gnupg2 lsb-release && \ + apt-get install -y \ + wget=1.21.4-1ubuntu4.1 unzip=6.0-28ubuntu4 git=1:2.43.0-1ubuntu7.1 \ + gnupg2=2.4.4-2ubuntu17 lsb-release=12.0-2 && \ rm -rf /var/lib/apt/lists/* # Add PostgreSQL repository and key diff --git a/docker/Dockerfile.cicd b/docker/Dockerfile.cicd index 1a778fa..a49aa22 100644 --- a/docker/Dockerfile.cicd +++ b/docker/Dockerfile.cicd @@ -19,7 +19,9 @@ ENV DEBIAN_FRONTEND=noninteractive # Install system dependencies RUN apt-get update && \ - apt-get install -y wget git gnupg2 lsb-release && \ + apt-get install -y \ + wget=1.21.4-1ubuntu4.1 unzip=6.0-28ubuntu4 git=1:2.43.0-1ubuntu7.1 \ + gnupg2=2.4.4-2ubuntu17 lsb-release=12.0-2 && \ rm -rf /var/lib/apt/lists/* # Add PostgreSQL repository and key diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 826a37f..af68a1b 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -19,7 +19,9 @@ ENV DEBIAN_FRONTEND=noninteractive # Install system dependencies RUN apt-get update && \ - apt-get install -y wget git gnupg2 lsb-release && \ + apt-get install -y \ + wget=1.21.4-1ubuntu4.1 unzip=6.0-28ubuntu4 git=1:2.43.0-1ubuntu7.1 \ + gnupg2=2.4.4-2ubuntu17 lsb-release=12.0-2 && \ rm -rf /var/lib/apt/lists/* # Add PostgreSQL repository and key From ba8286e788ad2afe0d5cae605ac637f1def48147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 17:59:12 -0600 Subject: [PATCH 03/15] Refactor RestoreZip function to support both local and remote ZIP files --- internal/integration/postgres/postgres.go | 89 +++++++++-------------- 1 file changed, 36 insertions(+), 53 deletions(-) diff --git a/internal/integration/postgres/postgres.go b/internal/integration/postgres/postgres.go index daf263b..83d8ffc 100644 --- a/internal/integration/postgres/postgres.go +++ b/internal/integration/postgres/postgres.go @@ -5,10 +5,10 @@ import ( "bytes" "fmt" "io" - "net/http" "os" "os/exec" + "github.com/eduardolat/pgbackweb/internal/util/strutil" "github.com/orsinium-labs/enum" ) @@ -198,75 +198,58 @@ func (c *Client) DumpZip( return reader } -// RestoreZip downloads the ZIP from the given url, unzips it, and runs the -// psql command to restore the database. +// RestoreZip downloads or copies the ZIP from the given url or path, unzips it, +// and runs the psql command to restore the database. +// +// The ZIP file must contain a dump.sql file with the SQL dump to restore. +// +// - version: PostgreSQL version to use for the restore +// - connString: connection string to the database +// - isLocal: whether the ZIP file is local or a URL +// - zipURLOrPath: URL or path to the ZIP file func (Client) RestoreZip( - version PGVersion, connString string, zipURL string, + version PGVersion, connString string, isLocal bool, zipURLOrPath string, ) error { - // Create a temporary directory - dir, err := os.MkdirTemp("", "pbw-restore-*") + workDir, err := os.MkdirTemp("", "pbw-restore-*") if err != nil { return fmt.Errorf("error creating temp dir: %w", err) } - defer os.RemoveAll(dir) + defer os.RemoveAll(workDir) + zipPath := strutil.CreatePath(true, workDir, "dump.zip") + dumpPath := strutil.CreatePath(true, workDir, "dump.sql") - // Download the ZIP file from the given URL - zipPath := fmt.Sprintf("%s/dump.zip", dir) - resp, err := http.Get(zipURL) - if err != nil { - return fmt.Errorf("error downloading ZIP file: %w", err) + if isLocal { + cmd := exec.Command("cp", zipURLOrPath, zipPath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("error copying ZIP file to temp dir: %s", output) + } } - defer resp.Body.Close() - out, err := os.Create(zipPath) - if err != nil { - return fmt.Errorf("error creating ZIP file: %w", err) + if !isLocal { + cmd := exec.Command("wget", "--no-verbose", "-O", zipPath, zipURLOrPath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("error downloading ZIP file: %s", output) + } } - defer out.Close() - if _, err = io.Copy(out, resp.Body); err != nil { - return fmt.Errorf("error writing to ZIP file: %w", err) + if _, err := os.Stat(zipPath); os.IsNotExist(err) { + return fmt.Errorf("zip file not found: %s", zipPath) } - // Unzip the file into the temp dir - zipReadCloser, err := zip.OpenReader(zipPath) + cmd := exec.Command("unzip", "-o", zipPath, "dump.sql", "-d", workDir) + output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("error opening ZIP file: %w", err) - } - defer zipReadCloser.Close() - - var dumpPath string - for _, file := range zipReadCloser.File { - if file.Name == "dump.sql" { - dumpPath = fmt.Sprintf("%s/%s", dir, file.Name) - - fileReadCloser, err := file.Open() - if err != nil { - return fmt.Errorf("error opening dump.sql in ZIP file: %w", err) - } - defer fileReadCloser.Close() - - outFile, err := os.Create(dumpPath) - if err != nil { - return fmt.Errorf("error creating dump.sql: %w", err) - } - defer outFile.Close() - - if _, err = io.Copy(outFile, fileReadCloser); err != nil { - return fmt.Errorf("error writing dump.sql: %w", err) - } - - break - } + return fmt.Errorf("error unzipping ZIP file: %s", output) } - if dumpPath == "" { - return fmt.Errorf("dump.sql not found in ZIP file") + if _, err := os.Stat(dumpPath); os.IsNotExist(err) { + return fmt.Errorf("dump.sql file not found in ZIP file: %s", zipPath) } - // Run the psql command to restore the database - cmd := exec.Command(version.Value.psql, connString, "-f", dumpPath) - output, err := cmd.CombinedOutput() + cmd = exec.Command(version.Value.psql, connString, "-f", dumpPath) + output, err = cmd.CombinedOutput() if err != nil { return fmt.Errorf( "error running psql v%s command: %s", From 065889fd566f7df6c1d6f70041f045b8bef71239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 18:54:56 -0600 Subject: [PATCH 04/15] Add restorations table to the database migrations --- .../20240805000451_add_restorations_table.sql | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 internal/database/migrations/20240805000451_add_restorations_table.sql diff --git a/internal/database/migrations/20240805000451_add_restorations_table.sql b/internal/database/migrations/20240805000451_add_restorations_table.sql new file mode 100644 index 0000000..4f18117 --- /dev/null +++ b/internal/database/migrations/20240805000451_add_restorations_table.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS restorations ( + id UUID NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY, + execution_id UUID NOT NULL REFERENCES executions(id) ON DELETE CASCADE, + database_id UUID REFERENCES databases(id) ON DELETE CASCADE, + + status TEXT NOT NULL CHECK ( + status IN ('running', 'success', 'failed') + ) DEFAULT 'running', + message TEXT, + + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ +); + +CREATE TRIGGER restorations_change_updated_at +BEFORE UPDATE ON restorations FOR EACH ROW EXECUTE FUNCTION change_updated_at(); + +CREATE INDEX IF NOT EXISTS +idx_restorations_execution_id ON restorations(execution_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS restorations; +-- +goose StatementEnd From 03e267ddddc9f8a815d0013afac3f26447d6ba4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 18:55:37 -0600 Subject: [PATCH 05/15] Add database_pg_version to get_execution and paginate_executions SQL queries --- internal/service/executions/get_execution.sql | 7 ++++++- internal/service/executions/paginate_executions.sql | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/service/executions/get_execution.sql b/internal/service/executions/get_execution.sql index 076153a..a12db1e 100644 --- a/internal/service/executions/get_execution.sql +++ b/internal/service/executions/get_execution.sql @@ -1,3 +1,8 @@ -- name: ExecutionsServiceGetExecution :one -SELECT * FROM executions +SELECT + executions.*, + databases.pg_version AS database_pg_version +FROM executions +INNER JOIN backups ON backups.id = executions.backup_id +INNER JOIN databases ON databases.id = backups.database_id WHERE id = @id; diff --git a/internal/service/executions/paginate_executions.sql b/internal/service/executions/paginate_executions.sql index 5c4c98c..d24ad97 100644 --- a/internal/service/executions/paginate_executions.sql +++ b/internal/service/executions/paginate_executions.sql @@ -28,6 +28,7 @@ SELECT executions.*, backups.name AS backup_name, databases.name AS database_name, + databases.pg_version AS database_pg_version, destinations.name AS destination_name, backups.is_local AS backup_is_local FROM executions From 3f2711685aaeea107bd4f0e037110ddb480aada9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 18:57:14 -0600 Subject: [PATCH 06/15] Update GetExecution function signature and SQL query --- internal/service/executions/get_execution.go | 2 +- internal/service/executions/get_execution.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/service/executions/get_execution.go b/internal/service/executions/get_execution.go index 578e061..4f4900b 100644 --- a/internal/service/executions/get_execution.go +++ b/internal/service/executions/get_execution.go @@ -9,6 +9,6 @@ import ( func (s *Service) GetExecution( ctx context.Context, id uuid.UUID, -) (dbgen.Execution, error) { +) (dbgen.ExecutionsServiceGetExecutionRow, error) { return s.dbgen.ExecutionsServiceGetExecution(ctx, id) } diff --git a/internal/service/executions/get_execution.sql b/internal/service/executions/get_execution.sql index a12db1e..8d465d5 100644 --- a/internal/service/executions/get_execution.sql +++ b/internal/service/executions/get_execution.sql @@ -5,4 +5,4 @@ SELECT FROM executions INNER JOIN backups ON backups.id = executions.backup_id INNER JOIN databases ON databases.id = backups.database_id -WHERE id = @id; +WHERE executions.id = @id; From a4c04c626329acdc3c9147ef06b6cb4b642467e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 18:59:16 -0600 Subject: [PATCH 07/15] Add backup_is_local column to GetExecution SQL query --- internal/service/executions/get_execution.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/service/executions/get_execution.sql b/internal/service/executions/get_execution.sql index 8d465d5..dcfb901 100644 --- a/internal/service/executions/get_execution.sql +++ b/internal/service/executions/get_execution.sql @@ -1,7 +1,8 @@ -- name: ExecutionsServiceGetExecution :one SELECT executions.*, - databases.pg_version AS database_pg_version + databases.pg_version AS database_pg_version, + backups.is_local AS backup_is_local FROM executions INNER JOIN backups ON backups.id = executions.backup_id INNER JOIN databases ON databases.id = backups.database_id From 50050cc895559e650e4035c5d8c1355decd70529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 19:18:20 -0600 Subject: [PATCH 08/15] Remove backup_is_local column from GetExecution SQL query --- internal/service/executions/get_execution.sql | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/service/executions/get_execution.sql b/internal/service/executions/get_execution.sql index dcfb901..8d465d5 100644 --- a/internal/service/executions/get_execution.sql +++ b/internal/service/executions/get_execution.sql @@ -1,8 +1,7 @@ -- name: ExecutionsServiceGetExecution :one SELECT executions.*, - databases.pg_version AS database_pg_version, - backups.is_local AS backup_is_local + databases.pg_version AS database_pg_version FROM executions INNER JOIN backups ON backups.id = executions.backup_id INNER JOIN databases ON databases.id = backups.database_id From 86a3d70455e78b3bdf9c61cfa14a441e6266b763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 19:20:44 -0600 Subject: [PATCH 09/15] Add restorations service and related functions --- .../restorations/create_restoration.go | 13 ++ .../restorations/create_restoration.sql | 4 + .../restorations/get_restorations_qty.go | 7 + .../restorations/get_restorations_qty.sql | 2 + .../restorations/paginate_restorations.go | 54 ++++++ .../restorations/paginate_restorations.sql | 39 +++++ internal/service/restorations/restorations.go | 31 ++++ .../service/restorations/run_restoration.go | 155 ++++++++++++++++++ .../restorations/update_restoration.go | 13 ++ .../restorations/update_restoration.sql | 8 + internal/service/service.go | 6 + 11 files changed, 332 insertions(+) create mode 100644 internal/service/restorations/create_restoration.go create mode 100644 internal/service/restorations/create_restoration.sql create mode 100644 internal/service/restorations/get_restorations_qty.go create mode 100644 internal/service/restorations/get_restorations_qty.sql create mode 100644 internal/service/restorations/paginate_restorations.go create mode 100644 internal/service/restorations/paginate_restorations.sql create mode 100644 internal/service/restorations/restorations.go create mode 100644 internal/service/restorations/run_restoration.go create mode 100644 internal/service/restorations/update_restoration.go create mode 100644 internal/service/restorations/update_restoration.sql diff --git a/internal/service/restorations/create_restoration.go b/internal/service/restorations/create_restoration.go new file mode 100644 index 0000000..b1ce32e --- /dev/null +++ b/internal/service/restorations/create_restoration.go @@ -0,0 +1,13 @@ +package restorations + +import ( + "context" + + "github.com/eduardolat/pgbackweb/internal/database/dbgen" +) + +func (s *Service) CreateRestoration( + ctx context.Context, params dbgen.RestorationsServiceCreateRestorationParams, +) (dbgen.Restoration, error) { + return s.dbgen.RestorationsServiceCreateRestoration(ctx, params) +} diff --git a/internal/service/restorations/create_restoration.sql b/internal/service/restorations/create_restoration.sql new file mode 100644 index 0000000..1eb655f --- /dev/null +++ b/internal/service/restorations/create_restoration.sql @@ -0,0 +1,4 @@ +-- name: RestorationsServiceCreateRestoration :one +INSERT INTO restorations (execution_id, database_id, status, message) +VALUES (@execution_id, @database_id, @status, @message) +RETURNING *; diff --git a/internal/service/restorations/get_restorations_qty.go b/internal/service/restorations/get_restorations_qty.go new file mode 100644 index 0000000..118f6fd --- /dev/null +++ b/internal/service/restorations/get_restorations_qty.go @@ -0,0 +1,7 @@ +package restorations + +import "context" + +func (s *Service) GetRestorationsQty(ctx context.Context) (int64, error) { + return s.dbgen.RestorationsServiceGetRestorationsQty(ctx) +} diff --git a/internal/service/restorations/get_restorations_qty.sql b/internal/service/restorations/get_restorations_qty.sql new file mode 100644 index 0000000..f12fd35 --- /dev/null +++ b/internal/service/restorations/get_restorations_qty.sql @@ -0,0 +1,2 @@ +-- name: RestorationsServiceGetRestorationsQty :one +SELECT COUNT(*) FROM restorations; diff --git a/internal/service/restorations/paginate_restorations.go b/internal/service/restorations/paginate_restorations.go new file mode 100644 index 0000000..0a25f74 --- /dev/null +++ b/internal/service/restorations/paginate_restorations.go @@ -0,0 +1,54 @@ +package restorations + +import ( + "context" + + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/paginateutil" + "github.com/google/uuid" +) + +type PaginateRestorationsParams struct { + Page int + Limit int + ExecutionFilter uuid.NullUUID + DatabaseFilter uuid.NullUUID +} + +func (s *Service) PaginateRestorations( + ctx context.Context, params PaginateRestorationsParams, +) (paginateutil.PaginateResponse, []dbgen.RestorationsServicePaginateRestorationsRow, error) { + page := max(params.Page, 1) + limit := min(max(params.Limit, 1), 100) + + count, err := s.dbgen.RestorationsServicePaginateRestorationsCount( + ctx, dbgen.RestorationsServicePaginateRestorationsCountParams{ + ExecutionID: params.ExecutionFilter, + DatabaseID: params.DatabaseFilter, + }, + ) + if err != nil { + return paginateutil.PaginateResponse{}, nil, err + } + + paginateParams := paginateutil.PaginateParams{ + Page: page, + Limit: limit, + } + offset := paginateutil.CreateOffsetFromParams(paginateParams) + paginateResponse := paginateutil.CreatePaginateResponse(paginateParams, int(count)) + + restorations, err := s.dbgen.RestorationsServicePaginateRestorations( + ctx, dbgen.RestorationsServicePaginateRestorationsParams{ + ExecutionID: params.ExecutionFilter, + DatabaseID: params.DatabaseFilter, + Limit: int32(params.Limit), + Offset: int32(offset), + }, + ) + if err != nil { + return paginateutil.PaginateResponse{}, nil, err + } + + return paginateResponse, restorations, nil +} diff --git a/internal/service/restorations/paginate_restorations.sql b/internal/service/restorations/paginate_restorations.sql new file mode 100644 index 0000000..d83bb24 --- /dev/null +++ b/internal/service/restorations/paginate_restorations.sql @@ -0,0 +1,39 @@ +-- name: RestorationsServicePaginateRestorationsCount :one +SELECT COUNT(restorations.*) +FROM restorations +INNER JOIN executions ON executions.id = restorations.execution_id +LEFT JOIN databases ON databases.id = restorations.database_id +WHERE +( + sqlc.narg('execution_id')::UUID IS NULL + OR + restorations.execution_id = sqlc.narg('execution_id')::UUID +) +AND +( + sqlc.narg('database_id')::UUID IS NULL + OR + restorations.database_id = sqlc.narg('database_id')::UUID +); + +-- name: RestorationsServicePaginateRestorations :many +SELECT + restorations.*, + databases.name AS database_name +FROM restorations +INNER JOIN executions ON executions.id = restorations.execution_id +LEFT JOIN databases ON databases.id = restorations.database_id +WHERE +( + sqlc.narg('execution_id')::UUID IS NULL + OR + restorations.execution_id = sqlc.narg('execution_id')::UUID +) +AND +( + sqlc.narg('database_id')::UUID IS NULL + OR + restorations.database_id = sqlc.narg('database_id')::UUID +) +ORDER BY restorations.started_at DESC +LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); diff --git a/internal/service/restorations/restorations.go b/internal/service/restorations/restorations.go new file mode 100644 index 0000000..a155895 --- /dev/null +++ b/internal/service/restorations/restorations.go @@ -0,0 +1,31 @@ +package restorations + +import ( + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/integration" + "github.com/eduardolat/pgbackweb/internal/service/databases" + "github.com/eduardolat/pgbackweb/internal/service/destinations" + "github.com/eduardolat/pgbackweb/internal/service/executions" +) + +type Service struct { + dbgen *dbgen.Queries + ints *integration.Integration + executionsService *executions.Service + databasesService *databases.Service + destinationsService *destinations.Service +} + +func New( + dbgen *dbgen.Queries, ints *integration.Integration, + executionsService *executions.Service, databasesService *databases.Service, + destinationsService *destinations.Service, +) *Service { + return &Service{ + dbgen: dbgen, + ints: ints, + executionsService: executionsService, + databasesService: databasesService, + destinationsService: destinationsService, + } +} diff --git a/internal/service/restorations/run_restoration.go b/internal/service/restorations/run_restoration.go new file mode 100644 index 0000000..78edbd8 --- /dev/null +++ b/internal/service/restorations/run_restoration.go @@ -0,0 +1,155 @@ +package restorations + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/logger" + "github.com/google/uuid" +) + +// RunRestoration runs a backup restoration +func (s *Service) RunRestoration( + ctx context.Context, + executionID uuid.UUID, + databaseID uuid.NullUUID, + connString string, +) error { + updateRes := func(params dbgen.RestorationsServiceUpdateRestorationParams) error { + _, err := s.dbgen.RestorationsServiceUpdateRestoration( + ctx, params, + ) + return err + } + + logError := func(err error) { + dbID := "empty" + if databaseID.Valid { + dbID = databaseID.UUID.String() + } + logger.Error("error running restoration", logger.KV{ + "execution_id": executionID.String(), + "database_id": dbID, + "error": err.Error(), + }) + } + + res, err := s.CreateRestoration(ctx, dbgen.RestorationsServiceCreateRestorationParams{ + ExecutionID: executionID, + DatabaseID: databaseID, + Status: "running", + }) + if err != nil { + logError(err) + return err + } + + if !databaseID.Valid && connString == "" { + err := fmt.Errorf("database_id or connection_string must be provided") + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + execution, err := s.executionsService.GetExecution(ctx, executionID) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + if execution.Status != "success" || !execution.Path.Valid { + err := fmt.Errorf("backup execution must be successful") + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + if databaseID.Valid { + db, err := s.databasesService.GetDatabase(ctx, databaseID.UUID) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + connString = db.DecryptedConnectionString + } + + pgVersion, err := s.ints.PGClient.ParseVersion(execution.DatabasePgVersion) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + err = s.ints.PGClient.Ping(pgVersion, connString) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + isLocal, zipURLOrPath, err := s.executionsService.GetExecutionDownloadLinkOrPath( + ctx, executionID, + ) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + err = s.ints.PGClient.RestoreZip( + pgVersion, connString, isLocal, zipURLOrPath, + ) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + logger.Info("backup restored successfully", logger.KV{ + "restoration_id": res.ID.String(), + "execution_id": executionID.String(), + }) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "success"}, + Message: sql.NullString{Valid: true, String: "Backup restored successfully"}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) +} diff --git a/internal/service/restorations/update_restoration.go b/internal/service/restorations/update_restoration.go new file mode 100644 index 0000000..965f0b2 --- /dev/null +++ b/internal/service/restorations/update_restoration.go @@ -0,0 +1,13 @@ +package restorations + +import ( + "context" + + "github.com/eduardolat/pgbackweb/internal/database/dbgen" +) + +func (s *Service) UpdateRestoration( + ctx context.Context, params dbgen.RestorationsServiceUpdateRestorationParams, +) (dbgen.Restoration, error) { + return s.dbgen.RestorationsServiceUpdateRestoration(ctx, params) +} diff --git a/internal/service/restorations/update_restoration.sql b/internal/service/restorations/update_restoration.sql new file mode 100644 index 0000000..1505ee7 --- /dev/null +++ b/internal/service/restorations/update_restoration.sql @@ -0,0 +1,8 @@ +-- name: RestorationsServiceUpdateRestoration :one +UPDATE restorations +SET + status = COALESCE(sqlc.narg('status'), status), + message = COALESCE(sqlc.narg('message'), message), + finished_at = COALESCE(sqlc.narg('finished_at'), finished_at) +WHERE id = @id +RETURNING *; diff --git a/internal/service/service.go b/internal/service/service.go index da12a18..2f3abc1 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -10,6 +10,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/service/databases" "github.com/eduardolat/pgbackweb/internal/service/destinations" "github.com/eduardolat/pgbackweb/internal/service/executions" + "github.com/eduardolat/pgbackweb/internal/service/restorations" "github.com/eduardolat/pgbackweb/internal/service/users" ) @@ -20,6 +21,7 @@ type Service struct { DestinationsService *destinations.Service ExecutionsService *executions.Service UsersService *users.Service + RestorationsService *restorations.Service } func New( @@ -32,6 +34,9 @@ func New( executionsService := executions.New(env, dbgen, ints) usersService := users.New(dbgen) backupsService := backups.New(dbgen, cr, executionsService) + restorationsService := restorations.New( + dbgen, ints, executionsService, databasesService, destinationsService, + ) return &Service{ AuthService: authService, @@ -40,5 +45,6 @@ func New( DestinationsService: destinationsService, ExecutionsService: executionsService, UsersService: usersService, + RestorationsService: restorationsService, } } From 56d32c18416ee31986ed83720c1911b7fd91ec30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 19:22:51 -0600 Subject: [PATCH 10/15] Update index page to display restorations quantity --- internal/view/web/dashboard/summary/index.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/view/web/dashboard/summary/index.go b/internal/view/web/dashboard/summary/index.go index 21f713a..2d093a4 100644 --- a/internal/view/web/dashboard/summary/index.go +++ b/internal/view/web/dashboard/summary/index.go @@ -32,15 +32,21 @@ func (h *handlers) indexPageHandler(c echo.Context) error { if err != nil { return c.String(http.StatusInternalServerError, err.Error()) } + restorationsQty, err := h.servs.RestorationsService.GetRestorationsQty(ctx) + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } return echoutil.RenderGomponent( c, http.StatusOK, - indexPage(databasesQty, destinationsQty, backupsQty, executionsQty), + indexPage( + databasesQty, destinationsQty, backupsQty, executionsQty, restorationsQty, + ), ) } func indexPage( - databasesQty, destinationsQty, backupsQty, executionsQty int64, + databasesQty, destinationsQty, backupsQty, executionsQty, restorationsQty int64, ) gomponents.Node { countCard := func(title string, count int64) gomponents.Node { return component.CardBox(component.CardBoxParams{ @@ -58,11 +64,12 @@ func indexPage( content := []gomponents.Node{ component.H1Text("Summary"), html.Div( - html.Class("mt-4 grid grid-cols-4 gap-4"), + html.Class("mt-4 grid grid-cols-5 gap-4"), countCard("Databases", databasesQty), countCard("Destinations", destinationsQty), countCard("Backups", backupsQty), countCard("Executions", executionsQty), + countCard("Restorations", restorationsQty), ), html.Div( alpine.XData("genericSlider(4)"), From c1a99f3e76587012e60657d53bd7327b1f3b47b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 19:24:30 -0600 Subject: [PATCH 11/15] Add Restorations section to dashboard aside menu --- internal/view/web/layout/dashboard_aside.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/view/web/layout/dashboard_aside.go b/internal/view/web/layout/dashboard_aside.go index 120206f..dc41cb4 100644 --- a/internal/view/web/layout/dashboard_aside.go +++ b/internal/view/web/layout/dashboard_aside.go @@ -77,6 +77,13 @@ func dashboardAside() gomponents.Node { false, ), + dashboardAsideItem( + lucide.ArchiveRestore, + "Restorations", + "/dashboard/restorations", + false, + ), + dashboardAsideItem( lucide.User, "Profile", From 04c55cda6492632e41043985d7bac4ddcead3972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 19:44:36 -0600 Subject: [PATCH 12/15] Add backup_name to RestorationsServicePaginateRestorations SQL query --- internal/service/restorations/paginate_restorations.sql | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/service/restorations/paginate_restorations.sql b/internal/service/restorations/paginate_restorations.sql index d83bb24..c52c950 100644 --- a/internal/service/restorations/paginate_restorations.sql +++ b/internal/service/restorations/paginate_restorations.sql @@ -2,6 +2,7 @@ SELECT COUNT(restorations.*) FROM restorations INNER JOIN executions ON executions.id = restorations.execution_id +INNER JOIN backups ON backups.id = executions.backup_id LEFT JOIN databases ON databases.id = restorations.database_id WHERE ( @@ -19,9 +20,11 @@ AND -- name: RestorationsServicePaginateRestorations :many SELECT restorations.*, - databases.name AS database_name + databases.name AS database_name, + backups.name AS backup_name FROM restorations INNER JOIN executions ON executions.id = restorations.execution_id +INNER JOIN backups ON backups.id = executions.backup_id LEFT JOIN databases ON databases.id = restorations.database_id WHERE ( From afd967378bbf24817afed1e5e2bfb4af00260aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 19:44:49 -0600 Subject: [PATCH 13/15] Add restorations list and it's info --- .../view/web/dashboard/restorations/index.go | 86 ++++++++++++ .../restorations/list_restorations.go | 126 ++++++++++++++++++ .../view/web/dashboard/restorations/router.go | 24 ++++ .../restorations/show_restoration.go | 95 +++++++++++++ internal/view/web/dashboard/router.go | 2 + 5 files changed, 333 insertions(+) create mode 100644 internal/view/web/dashboard/restorations/index.go create mode 100644 internal/view/web/dashboard/restorations/list_restorations.go create mode 100644 internal/view/web/dashboard/restorations/router.go create mode 100644 internal/view/web/dashboard/restorations/show_restoration.go diff --git a/internal/view/web/dashboard/restorations/index.go b/internal/view/web/dashboard/restorations/index.go new file mode 100644 index 0000000..4738c78 --- /dev/null +++ b/internal/view/web/dashboard/restorations/index.go @@ -0,0 +1,86 @@ +package restorations + +import ( + "net/http" + + "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/strutil" + "github.com/eduardolat/pgbackweb/internal/validate" + "github.com/eduardolat/pgbackweb/internal/view/web/component" + "github.com/eduardolat/pgbackweb/internal/view/web/htmx" + "github.com/eduardolat/pgbackweb/internal/view/web/layout" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/maragudk/gomponents" + "github.com/maragudk/gomponents/html" +) + +type resQueryData struct { + Execution uuid.UUID `query:"execution" validate:"omitempty,uuid"` + Database uuid.UUID `query:"database" validate:"omitempty,uuid"` +} + +func (h *handlers) indexPageHandler(c echo.Context) error { + var queryData resQueryData + if err := c.Bind(&queryData); err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + if err := validate.Struct(&queryData); err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + + return echoutil.RenderGomponent(c, http.StatusOK, indexPage(queryData)) +} + +func indexPage(queryData resQueryData) gomponents.Node { + content := []gomponents.Node{ + component.H1Text("Restorations"), + component.CardBox(component.CardBoxParams{ + Class: "mt-4", + Children: []gomponents.Node{ + html.Div( + html.Class("overflow-x-auto"), + html.Table( + html.Class("table text-nowrap"), + html.THead( + html.Tr( + html.Th(component.SpanText("Actions")), + html.Th(component.SpanText("Status")), + html.Th(component.SpanText("Backup")), + html.Th(component.SpanText("Database")), + html.Th(component.SpanText("Execution")), + html.Th(component.SpanText("Started at")), + html.Th(component.SpanText("Finished at")), + html.Th(component.SpanText("Duration")), + ), + ), + html.TBody( + htmx.HxGet(func() string { + url := "/dashboard/restorations/list?page=1" + if queryData.Execution != uuid.Nil { + url = strutil.AddQueryParamToUrl(url, "execution", queryData.Execution.String()) + } + if queryData.Database != uuid.Nil { + url = strutil.AddQueryParamToUrl(url, "database", queryData.Database.String()) + } + return url + }()), + htmx.HxTrigger("load"), + htmx.HxIndicator("#list-restorations-loading"), + ), + ), + ), + + html.Div( + html.Class("flex justify-center mt-4"), + component.HxLoadingLg("list-restorations-loading"), + ), + }, + }), + } + + return layout.Dashboard(layout.DashboardParams{ + Title: "Restorations", + Body: content, + }) +} diff --git a/internal/view/web/dashboard/restorations/list_restorations.go b/internal/view/web/dashboard/restorations/list_restorations.go new file mode 100644 index 0000000..7b82b3d --- /dev/null +++ b/internal/view/web/dashboard/restorations/list_restorations.go @@ -0,0 +1,126 @@ +package restorations + +import ( + "fmt" + "net/http" + + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/service/restorations" + "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/paginateutil" + "github.com/eduardolat/pgbackweb/internal/util/strutil" + "github.com/eduardolat/pgbackweb/internal/util/timeutil" + "github.com/eduardolat/pgbackweb/internal/validate" + "github.com/eduardolat/pgbackweb/internal/view/web/component" + "github.com/eduardolat/pgbackweb/internal/view/web/htmx" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/maragudk/gomponents" + "github.com/maragudk/gomponents/html" +) + +type listResQueryData struct { + Execution uuid.UUID `query:"execution" validate:"omitempty,uuid"` + Database uuid.UUID `query:"database" validate:"omitempty,uuid"` + Page int `query:"page" validate:"required,min=1"` +} + +func (h *handlers) listRestorationsHandler(c echo.Context) error { + ctx := c.Request().Context() + + var queryData listResQueryData + if err := c.Bind(&queryData); err != nil { + return htmx.RespondToastError(c, err.Error()) + } + if err := validate.Struct(&queryData); err != nil { + return htmx.RespondToastError(c, err.Error()) + } + + pagination, restorations, err := h.servs.RestorationsService.PaginateRestorations( + ctx, restorations.PaginateRestorationsParams{ + ExecutionFilter: uuid.NullUUID{ + UUID: queryData.Execution, Valid: queryData.Execution != uuid.Nil, + }, + DatabaseFilter: uuid.NullUUID{ + UUID: queryData.Database, Valid: queryData.Database != uuid.Nil, + }, + Page: queryData.Page, + Limit: 20, + }, + ) + if err != nil { + return htmx.RespondToastError(c, err.Error()) + } + + return echoutil.RenderGomponent( + c, http.StatusOK, listRestorations(queryData, pagination, restorations), + ) +} + +func listRestorations( + queryData listResQueryData, + pagination paginateutil.PaginateResponse, + restorations []dbgen.RestorationsServicePaginateRestorationsRow, +) gomponents.Node { + trs := []gomponents.Node{} + for _, restoration := range restorations { + trs = append(trs, html.Tr( + html.Td( + html.Class("w-[50px]"), + html.Div( + html.Class("flex justify-start space-x-1"), + showRestorationButton(restoration), + ), + ), + html.Td(component.StatusBadge(restoration.Status)), + html.Td(component.SpanText(restoration.BackupName)), + html.Td(component.SpanText(func() string { + if restoration.DatabaseName.Valid { + return restoration.DatabaseName.String + } + return "Other database" + }())), + html.Td(component.SpanText(restoration.ExecutionID.String())), + html.Td(component.SpanText( + restoration.StartedAt.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty), + )), + html.Td( + gomponents.If( + restoration.FinishedAt.Valid, + component.SpanText( + restoration.FinishedAt.Time.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty), + ), + ), + ), + html.Td( + gomponents.If( + restoration.FinishedAt.Valid, + component.SpanText( + restoration.FinishedAt.Time.Sub(restoration.StartedAt).String(), + ), + ), + ), + )) + } + + if pagination.HasNextPage { + trs = append(trs, html.Tr( + htmx.HxGet(func() string { + url := "/dashboard/restorations/list" + url = strutil.AddQueryParamToUrl(url, "page", fmt.Sprintf("%d", pagination.NextPage)) + if queryData.Execution != uuid.Nil { + url = strutil.AddQueryParamToUrl(url, "execution", queryData.Execution.String()) + } + if queryData.Database != uuid.Nil { + url = strutil.AddQueryParamToUrl(url, "database", queryData.Database.String()) + } + return url + }()), + htmx.HxTrigger("intersect once"), + htmx.HxSwap("afterend"), + htmx.HxIndicator("#list-restorations-loading"), + )) + } + + return component.RenderableGroup(trs) +} diff --git a/internal/view/web/dashboard/restorations/router.go b/internal/view/web/dashboard/restorations/router.go new file mode 100644 index 0000000..9ad134e --- /dev/null +++ b/internal/view/web/dashboard/restorations/router.go @@ -0,0 +1,24 @@ +package restorations + +import ( + "github.com/eduardolat/pgbackweb/internal/service" + "github.com/eduardolat/pgbackweb/internal/view/middleware" + "github.com/labstack/echo/v4" +) + +type handlers struct { + servs *service.Service +} + +func newHandlers(servs *service.Service) *handlers { + return &handlers{servs: servs} +} + +func MountRouter( + parent *echo.Group, mids *middleware.Middleware, servs *service.Service, +) { + h := newHandlers(servs) + + parent.GET("", h.indexPageHandler) + parent.GET("/list", h.listRestorationsHandler) +} diff --git a/internal/view/web/dashboard/restorations/show_restoration.go b/internal/view/web/dashboard/restorations/show_restoration.go new file mode 100644 index 0000000..51d5048 --- /dev/null +++ b/internal/view/web/dashboard/restorations/show_restoration.go @@ -0,0 +1,95 @@ +package restorations + +import ( + lucide "github.com/eduardolat/gomponents-lucide" + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/timeutil" + "github.com/eduardolat/pgbackweb/internal/view/web/component" + "github.com/maragudk/gomponents" + "github.com/maragudk/gomponents/html" +) + +func showRestorationButton( + restoration dbgen.RestorationsServicePaginateRestorationsRow, +) gomponents.Node { + mo := component.Modal(component.ModalParams{ + Title: "Restoration details", + Size: component.SizeMd, + Content: []gomponents.Node{ + html.Div( + html.Class("overflow-x-auto"), + html.Table( + html.Class("table"), + html.Tr( + html.Th(component.SpanText("ID")), + html.Td(component.SpanText(restoration.ID.String())), + ), + html.Tr( + html.Th(component.SpanText("Status")), + html.Td(component.StatusBadge(restoration.Status)), + ), + html.Tr( + html.Th(component.SpanText("Backup")), + html.Td(component.SpanText(restoration.BackupName)), + ), + html.Tr( + html.Th(component.SpanText("Database")), + html.Td(component.SpanText(func() string { + if restoration.DatabaseName.Valid { + return restoration.DatabaseName.String + } + return "Other database" + }())), + ), + gomponents.If( + restoration.Message.Valid, + html.Tr( + html.Th(component.SpanText("Message")), + html.Td( + html.Class("break-all"), + component.SpanText(restoration.Message.String), + ), + ), + ), + html.Tr( + html.Th(component.SpanText("Started At")), + html.Td(component.SpanText( + restoration.StartedAt.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty), + )), + ), + gomponents.If( + restoration.FinishedAt.Valid, + html.Tr( + html.Th(component.SpanText("Finished At")), + html.Td(component.SpanText( + restoration.FinishedAt.Time.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty), + )), + ), + ), + gomponents.If( + restoration.FinishedAt.Valid, + html.Tr( + html.Th(component.SpanText("Took")), + html.Td(component.SpanText( + restoration.FinishedAt.Time.Sub(restoration.StartedAt).String(), + )), + ), + ), + ), + ), + }, + }) + + button := html.Button( + mo.OpenerAttr, + html.Class("btn btn-square btn-sm btn-ghost"), + lucide.Eye(), + ) + + return html.Div( + html.Class("inline-block tooltip tooltip-right"), + html.Data("tip", "Show details"), + mo.HTML, + button, + ) +} diff --git a/internal/view/web/dashboard/router.go b/internal/view/web/dashboard/router.go index ca4cf47..0acf3bf 100644 --- a/internal/view/web/dashboard/router.go +++ b/internal/view/web/dashboard/router.go @@ -9,6 +9,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/view/web/dashboard/destinations" "github.com/eduardolat/pgbackweb/internal/view/web/dashboard/executions" "github.com/eduardolat/pgbackweb/internal/view/web/dashboard/profile" + "github.com/eduardolat/pgbackweb/internal/view/web/dashboard/restorations" "github.com/eduardolat/pgbackweb/internal/view/web/dashboard/summary" "github.com/labstack/echo/v4" ) @@ -21,6 +22,7 @@ func MountRouter( destinations.MountRouter(parent.Group("/destinations"), mids, servs) backups.MountRouter(parent.Group("/backups"), mids, servs) executions.MountRouter(parent.Group("/executions"), mids, servs) + restorations.MountRouter(parent.Group("/restorations"), mids, servs) profile.MountRouter(parent.Group("/profile"), mids, servs) about.MountRouter(parent.Group("/about"), mids, servs) } From ac4bbf7720b9377c8f461f589f7be05ffc7f4e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 22:47:21 -0600 Subject: [PATCH 14/15] Update GetExecution SQL query to include database_id and database_pg_version --- internal/service/executions/get_execution.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/service/executions/get_execution.sql b/internal/service/executions/get_execution.sql index 8d465d5..0420e2f 100644 --- a/internal/service/executions/get_execution.sql +++ b/internal/service/executions/get_execution.sql @@ -1,6 +1,7 @@ -- name: ExecutionsServiceGetExecution :one SELECT executions.*, + databases.id AS database_id, databases.pg_version AS database_pg_version FROM executions INNER JOIN backups ON backups.id = executions.backup_id From 5f69043c13662bd4c61ea2992dd1929afdc6993c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sun, 4 Aug 2024 22:47:32 -0600 Subject: [PATCH 15/15] Add restore execution functionality to dashboard --- .../dashboard/executions/list_executions.go | 1 + .../dashboard/executions/restore_execution.go | 246 ++++++++++++++++++ .../view/web/dashboard/executions/router.go | 2 + 3 files changed, 249 insertions(+) create mode 100644 internal/view/web/dashboard/executions/restore_execution.go diff --git a/internal/view/web/dashboard/executions/list_executions.go b/internal/view/web/dashboard/executions/list_executions.go index dc5f1b8..0f8c7aa 100644 --- a/internal/view/web/dashboard/executions/list_executions.go +++ b/internal/view/web/dashboard/executions/list_executions.go @@ -74,6 +74,7 @@ func listExecutions( html.Div( html.Class("flex justify-start space-x-1"), showExecutionButton(execution), + restoreExecutionButton(execution), ), ), html.Td(component.StatusBadge(execution.Status)), diff --git a/internal/view/web/dashboard/executions/restore_execution.go b/internal/view/web/dashboard/executions/restore_execution.go new file mode 100644 index 0000000..dfccbbf --- /dev/null +++ b/internal/view/web/dashboard/executions/restore_execution.go @@ -0,0 +1,246 @@ +package executions + +import ( + "context" + "fmt" + "net/http" + + lucide "github.com/eduardolat/gomponents-lucide" + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/validate" + "github.com/eduardolat/pgbackweb/internal/view/web/alpine" + "github.com/eduardolat/pgbackweb/internal/view/web/component" + "github.com/eduardolat/pgbackweb/internal/view/web/htmx" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/maragudk/gomponents" + "github.com/maragudk/gomponents/html" +) + +func (h *handlers) restoreExecutionHandler(c echo.Context) error { + ctx := c.Request().Context() + + var formData struct { + ExecutionID uuid.UUID `form:"execution_id" validate:"required,uuid"` + DatabaseID uuid.UUID `form:"database_id" validate:"omitempty,uuid"` + ConnString string `form:"conn_string" validate:"omitempty"` + } + if err := c.Bind(&formData); err != nil { + return htmx.RespondToastError(c, err.Error()) + } + if err := validate.Struct(&formData); err != nil { + return htmx.RespondToastError(c, err.Error()) + } + + if formData.DatabaseID == uuid.Nil && formData.ConnString == "" { + return htmx.RespondToastError( + c, "Database or connection string is required", + ) + } + + if formData.DatabaseID != uuid.Nil && formData.ConnString != "" { + return htmx.RespondToastError( + c, "Database and connection string cannot be both set", + ) + } + + execution, err := h.servs.ExecutionsService.GetExecution( + ctx, formData.ExecutionID, + ) + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } + + if formData.ConnString != "" { + err := h.servs.DatabasesService.TestDatabase( + ctx, execution.DatabasePgVersion, formData.ConnString, + ) + if err != nil { + return htmx.RespondToastError(c, err.Error()) + } + } + + go func() { + ctx := context.Background() + _ = h.servs.RestorationsService.RunRestoration( + ctx, + formData.ExecutionID, + uuid.NullUUID{ + Valid: formData.DatabaseID != uuid.Nil, + UUID: formData.DatabaseID, + }, + formData.ConnString, + ) + }() + + return htmx.RespondToastSuccess( + c, "Process started, check the restorations page for more details", + ) +} + +func (h *handlers) restoreExecutionFormHandler(c echo.Context) error { + ctx := c.Request().Context() + + executionID, err := uuid.Parse(c.Param("executionID")) + if err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + + execution, err := h.servs.ExecutionsService.GetExecution(ctx, executionID) + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } + + databases, err := h.servs.DatabasesService.GetAllDatabases(ctx) + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } + + return echoutil.RenderGomponent(c, http.StatusOK, restoreExecutionForm( + execution, databases, + )) +} + +func restoreExecutionForm( + execution dbgen.ExecutionsServiceGetExecutionRow, + databases []dbgen.DatabasesServiceGetAllDatabasesRow, +) gomponents.Node { + return html.Form( + htmx.HxPost("/dashboard/executions/"+execution.ID.String()+"/restore"), + htmx.HxConfirm("Are you sure you want to restore this backup?"), + htmx.HxDisabledELT("find button"), + + alpine.XData(`{ backup_to: "database" }`), + + html.Input( + html.Type("hidden"), + html.Name("execution_id"), + html.Value(execution.ID.String()), + ), + + html.Div( + html.Class("space-y-2 text-base"), + + component.SelectControl(component.SelectControlParams{ + Name: "backup_to", + Label: "Backup to", + Required: true, + HelpText: "You can restore the backup to an existing database or any other database using a connection string", + Children: []gomponents.Node{ + alpine.XModel("backup_to"), + html.Option( + html.Value("database"), + gomponents.Text("Existing database"), + html.Selected(), + ), + html.Option( + html.Value("conn_string"), + gomponents.Text("Other database"), + ), + }, + }), + + alpine.Template( + alpine.XIf("backup_to === 'database'"), + component.SelectControl(component.SelectControlParams{ + Name: "database_id", + Label: "Database", + Placeholder: "Select a database", + Required: true, + Children: []gomponents.Node{ + component.GMap( + databases, + func(db dbgen.DatabasesServiceGetAllDatabasesRow) gomponents.Node { + return html.Option( + html.Value(db.ID.String()), + gomponents.Text(db.Name), + gomponents.If( + db.ID == execution.DatabaseID, + html.Selected(), + ), + ) + }, + ), + }, + }), + ), + + alpine.Template( + alpine.XIf("backup_to === 'conn_string'"), + component.InputControl(component.InputControlParams{ + Name: "conn_string", + Label: "Connection string", + Placeholder: "postgresql://user:password@localhost:5432/mydb", + Type: component.InputTypeText, + Required: true, + }), + ), + + html.Div( + html.Class("pt-2"), + html.Div( + html.Role("alert"), + html.Class("alert alert-warning"), + lucide.TriangleAlert(), + html.Div( + html.P( + component.BText(fmt.Sprintf( + "This restoration uses psql v%s", execution.DatabasePgVersion, + )), + ), + component.PText(` + Please make sure the database you are restoring to is compatible + with this version of psql and double-check that the picked + database is the one you want to restore to. + `), + ), + ), + ), + + html.Div( + html.Class("flex justify-end items-center space-x-2 pt-2"), + component.HxLoadingMd(), + html.Button( + html.Class("btn btn-primary"), + html.Type("submit"), + component.SpanText("Start restoration"), + lucide.Zap(), + ), + ), + ), + ) +} + +func restoreExecutionButton(execution dbgen.ExecutionsServicePaginateExecutionsRow) gomponents.Node { + if execution.Status != "success" || !execution.Path.Valid { + return nil + } + + mo := component.Modal(component.ModalParams{ + Size: component.SizeMd, + Title: "Restore backup execution", + Content: []gomponents.Node{ + html.Div( + htmx.HxGet("/dashboard/executions/"+execution.ID.String()+"/restore-form"), + htmx.HxSwap("outerHTML"), + htmx.HxTrigger("intersect once"), + html.Class("p-10 flex justify-center"), + component.HxLoadingMd(), + ), + }, + }) + + button := html.Button( + mo.OpenerAttr, + html.Class("btn btn-square btn-sm btn-ghost"), + lucide.ArchiveRestore(), + ) + + return html.Div( + html.Class("inline-block tooltip tooltip-right"), + html.Data("tip", "Restore backup execution"), + mo.HTML, + button, + ) +} diff --git a/internal/view/web/dashboard/executions/router.go b/internal/view/web/dashboard/executions/router.go index fa57ecc..1a16857 100644 --- a/internal/view/web/dashboard/executions/router.go +++ b/internal/view/web/dashboard/executions/router.go @@ -23,4 +23,6 @@ func MountRouter( parent.GET("/list", h.listExecutionsHandler) parent.GET("/:executionID/download", h.downloadExecutionHandler) parent.DELETE("/:executionID", h.deleteExecutionHandler) + parent.GET("/:executionID/restore-form", h.restoreExecutionFormHandler) + parent.POST("/:executionID/restore", h.restoreExecutionHandler) }