diff --git a/assets/local-beach/docker-compose.yml b/assets/local-beach/docker-compose.yml index 7654b6c..93f7f10 100644 --- a/assets/local-beach/docker-compose.yml +++ b/assets/local-beach/docker-compose.yml @@ -17,12 +17,12 @@ services: environment: - DEFAULT_HOST=hello.localbeach.net database: - image: mariadb:10.11 + image: mysql:8.0 container_name: local_beach_database networks: - local_beach volumes: - - {{databasePath}}:/var/lib/mysql + - {{mysqlDatabasePath}}:/var/lib/mysql healthcheck: test: "/usr/bin/mysql --user=root --password=password --execute \"SHOW DATABASES;\"" interval: 3s @@ -32,4 +32,6 @@ services: - MYSQL_ROOT_PASSWORD=password ports: - 3307:3306 - command: 'mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci' + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci diff --git a/assets/local-beach/mariadb-compose.yml b/assets/local-beach/mariadb-compose.yml new file mode 100644 index 0000000..d7d2684 --- /dev/null +++ b/assets/local-beach/mariadb-compose.yml @@ -0,0 +1,42 @@ +networks: + local_beach: + external: true + +services: + database: + image: mysql:8.0 + container_name: local_beach_database + networks: + - local_beach + volumes: + - {{mysqlDatabasePath}}:/var/lib/mysql + healthcheck: + test: "/usr/bin/mysql --user=root --password=password --execute \"SHOW DATABASES;\"" + interval: 3s + timeout: 1s + retries: 10 + environment: + - MYSQL_ROOT_PASSWORD=password + ports: + - 3307:3306 + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + + mariadb: + image: mariadb:10.11 + container_name: local_beach_mariadb + networks: + - local_beach + volumes: + - {{mariadbDatabasePath}}:/var/lib/mysql + healthcheck: + test: "/usr/bin/mysql --user=root --password=password --execute \"SHOW DATABASES;\"" + interval: 3s + timeout: 1s + retries: 10 + environment: + - MYSQL_ROOT_PASSWORD=password + ports: + - 3306:3306 + command: 'mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci' diff --git a/cmd/beach/cmd/down.go b/cmd/beach/cmd/down.go index 2a6a0fc..e9797dc 100644 --- a/cmd/beach/cmd/down.go +++ b/cmd/beach/cmd/down.go @@ -15,15 +15,6 @@ package cmd import ( - "errors" - "os" - "path/filepath" - "strings" - - "github.com/flownative/localbeach/pkg/path" - - "github.com/flownative/localbeach/pkg/beachsandbox" - "github.com/flownative/localbeach/pkg/exec" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -42,75 +33,7 @@ func init() { } func handleDownRun(cmd *cobra.Command, args []string) { - instanceRoots, err := findInstanceRoots() - if err != nil { + if err := bringBeachDown(); err != nil { log.Fatal(err) - return - } - for _, instanceRoot := range instanceRoots { - log.Info("Stopping instance in " + instanceRoot + "...") - sandbox, err := beachsandbox.GetSandbox(instanceRoot) - if err != nil && !errors.Is(err, beachsandbox.ErrNoFlowFound) { - log.Fatal(err) - return - } - commandArgs := []string{"compose", "-f", sandbox.DockerComposeFilePath, "rm", "--force", "--stop", "-v"} - output, err := exec.RunCommand("docker", commandArgs) - if err != nil { - log.Fatal(output) - return - } - } - - log.Info("Stopping reverse proxy and database server ...") - commandArgs := []string{"compose", "-f", filepath.Join(path.Base, "docker-compose.yml"), "rm", "--force", "--stop", "-v"} - output, err := exec.RunCommand("docker", commandArgs) - if err != nil { - log.Fatal(output) - return - } - - return -} - -func findInstanceRoots() ([]string, error) { - var configurationFiles []string - - output, err := exec.RunCommand("docker", []string{"ps", "-q", "--filter", "network=local_beach"}) - if err != nil { - return nil, errors.New(output) - } - for _, line := range strings.Split(output, "\n") { - containerID := strings.TrimSpace(line) - if len(containerID) > 0 { - output, err := exec.RunCommand("docker", []string{"inspect", "-f", "{{index .Config.Labels \"com.docker.compose.project.config_files\"}}", containerID}) - if err != nil { - return nil, errors.New(output) - } - projectDirectory := filepath.Dir(strings.TrimSpace(output)) - if containsLocalBeachInstance(projectDirectory) { - configurationFiles = append(configurationFiles, projectDirectory) - } - } - } - - return removeDuplicates(configurationFiles), nil -} - -func containsLocalBeachInstance(path string) bool { - path = filepath.Join(path, ".localbeach.docker-compose.yaml") - _, err := os.Stat(path) - return !errors.Is(err, os.ErrNotExist) -} - -func removeDuplicates(strSlice []string) []string { - allKeys := make(map[string]bool) - var list []string - for _, item := range strSlice { - if _, value := allKeys[item]; !value { - allKeys[item] = true - list = append(list, item) - } } - return list } diff --git a/cmd/beach/cmd/helpers.go b/cmd/beach/cmd/helpers.go index 0b75f60..0ca101a 100644 --- a/cmd/beach/cmd/helpers.go +++ b/cmd/beach/cmd/helpers.go @@ -19,7 +19,6 @@ import ( "encoding/base64" "errors" "fmt" - "github.com/flownative/localbeach/pkg/path" "io" "net/http" "os" @@ -27,7 +26,9 @@ import ( "strings" "time" + "github.com/flownative/localbeach/pkg/beachsandbox" "github.com/flownative/localbeach/pkg/exec" + "github.com/flownative/localbeach/pkg/path" log "github.com/sirupsen/logrus" asset "github.com/flownative/localbeach/assets" @@ -144,6 +145,49 @@ func retrieveCloudStorageCredentials(instanceIdentifier string, projectNamespace return nil, bucketName, privateKey } +func writeComposeFile(assetPath, outputFileName string, replacements map[string]string) error { + composeFileContent := readFileFromAssets(assetPath) + + // Apply all replacements + for placeholder, value := range replacements { + composeFileContent = strings.ReplaceAll(composeFileContent, placeholder, value) + } + + outputPath := filepath.Join(path.Base, outputFileName) + destination, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed creating %s: %w", outputFileName, err) + } + defer destination.Close() + + _, err = destination.WriteString(composeFileContent) + if err != nil { + return fmt.Errorf("failed writing to %s: %w", outputFileName, err) + } + + return nil +} + +func writeLocalBeachComposeFile() { + replacements := map[string]string{ + "{{mysqlDatabasePath}}": path.MySQLDatabase, + "{{certificatesPath}}": path.Certificates, + } + if err := writeComposeFile("local-beach/docker-compose.yml", "docker-compose.yml", replacements); err != nil { + log.Error(err) + } +} + +func writeMariaDBComposeFile() { + replacements := map[string]string{ + "{{mysqlDatabasePath}}": path.MySQLDatabase, + "{{mariadbDatabasePath}}": path.MariaDBDatabase, + } + if err := writeComposeFile("local-beach/mariadb-compose.yml", "mariadb-compose.yml", replacements); err != nil { + log.Error(err) + } +} + func startLocalBeach() error { _, err := os.Stat(path.Base) if os.IsNotExist(err) { @@ -164,21 +208,7 @@ func startLocalBeach() error { } if len(nginxStatusOutput) == 0 || len(databaseStatusOutput) == 0 { - composeFileContent := readFileFromAssets("local-beach/docker-compose.yml") - composeFileContent = strings.ReplaceAll(composeFileContent, "{{databasePath}}", path.Database) - composeFileContent = strings.ReplaceAll(composeFileContent, "{{certificatesPath}}", path.Certificates) - - destination, err := os.Create(filepath.Join(path.Base, "docker-compose.yml")) - if err != nil { - log.Error("failed creating docker-compose.yml: ", err) - } else { - _, err = destination.WriteString(composeFileContent) - if err != nil { - log.Error(err) - } - - } - _ = destination.Close() + writeLocalBeachComposeFile() log.Info("Starting reverse proxy and database server ...") commandArgs := []string{"compose", "-f", filepath.Join(path.Base, "docker-compose.yml"), "up", "--remove-orphans", "-d"} @@ -206,3 +236,72 @@ func startLocalBeach() error { } return nil } + +func bringBeachDown() error { + instanceRoots, err := findInstanceRoots() + if err != nil { + return fmt.Errorf("failed to find instance roots: %w", err) + } + for _, instanceRoot := range instanceRoots { + log.Info("Stopping instance in " + instanceRoot + "...") + sandbox, err := beachsandbox.GetSandbox(instanceRoot) + if err != nil && !errors.Is(err, beachsandbox.ErrNoFlowFound) { + return fmt.Errorf("failed to get sandbox for %s: %w", instanceRoot, err) + } + commandArgs := []string{"compose", "-f", sandbox.DockerComposeFilePath, "rm", "--force", "--stop", "-v"} + output, err := exec.RunCommand("docker", commandArgs) + if err != nil { + return fmt.Errorf("failed to stop instance in %s: %s", instanceRoot, output) + } + } + + log.Info("Stopping reverse proxy and database server ...") + commandArgs := []string{"compose", "-f", filepath.Join(path.Base, "docker-compose.yml"), "rm", "--force", "--stop", "-v"} + output, err := exec.RunCommand("docker", commandArgs) + if err != nil { + return fmt.Errorf("failed to stop reverse proxy and database: %s", output) + } + return nil +} + +func findInstanceRoots() ([]string, error) { + var configurationFiles []string + + output, err := exec.RunCommand("docker", []string{"ps", "-q", "--filter", "network=local_beach"}) + if err != nil { + return nil, errors.New(output) + } + for _, line := range strings.Split(output, "\n") { + containerID := strings.TrimSpace(line) + if len(containerID) > 0 { + output, err := exec.RunCommand("docker", []string{"inspect", "-f", "{{index .Config.Labels \"com.docker.compose.project.config_files\"}}", containerID}) + if err != nil { + return nil, errors.New(output) + } + projectDirectory := filepath.Dir(strings.TrimSpace(output)) + if containsLocalBeachInstance(projectDirectory) { + configurationFiles = append(configurationFiles, projectDirectory) + } + } + } + + return removeDuplicates(configurationFiles), nil +} + +func containsLocalBeachInstance(path string) bool { + path = filepath.Join(path, ".localbeach.docker-compose.yaml") + _, err := os.Stat(path) + return !errors.Is(err, os.ErrNotExist) +} + +func removeDuplicates(strSlice []string) []string { + allKeys := make(map[string]bool) + var list []string + for _, item := range strSlice { + if _, value := allKeys[item]; !value { + allKeys[item] = true + list = append(list, item) + } + } + return list +} diff --git a/cmd/beach/cmd/pause.go b/cmd/beach/cmd/pause.go index 7407942..46b8368 100644 --- a/cmd/beach/cmd/pause.go +++ b/cmd/beach/cmd/pause.go @@ -15,11 +15,12 @@ package cmd import ( + "path/filepath" + "github.com/flownative/localbeach/pkg/exec" "github.com/flownative/localbeach/pkg/path" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "path/filepath" ) // pauseCmd represents the pause command diff --git a/cmd/beach/cmd/resource-download.go b/cmd/beach/cmd/resource-download.go index f1ab3bf..a571f5a 100644 --- a/cmd/beach/cmd/resource-download.go +++ b/cmd/beach/cmd/resource-download.go @@ -15,19 +15,20 @@ package cmd import ( - "cloud.google.com/go/storage" - "context" - "errors" - "fmt" - "github.com/flownative/localbeach/pkg/beachsandbox" - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "google.golang.org/api/iterator" - "google.golang.org/api/option" - "hash/crc32" - "io" - "os" - "path/filepath" + "context" + "errors" + "fmt" + "hash/crc32" + "io" + "os" + "path/filepath" + + "cloud.google.com/go/storage" + "github.com/flownative/localbeach/pkg/beachsandbox" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "google.golang.org/api/iterator" + "google.golang.org/api/option" ) var sourceBucketName, targetResourcesPath string @@ -35,9 +36,9 @@ var synchronize bool // resourceDownloadCmd represents the resource-download command var resourceDownloadCmd = &cobra.Command{ - Use: "resource-download", - Short: "Download resources (assets) from a local Flow or Neos installation to Beach", - Long: `resource-download + Use: "resource-download", + Short: "Download resources (assets) from a local Flow or Neos installation to Beach", + Long: `resource-download This command downloads Flow resources from a Beach instance to a local Flow or Neos project. @@ -56,127 +57,127 @@ Notes: - existing data in the local Neos instance will be left unchanged - older Beach instances may use a namespace called "beach" `, - Args: cobra.ExactArgs(0), - Run: handleResourceDownloadRun, + Args: cobra.ExactArgs(0), + Run: handleResourceDownloadRun, } func init() { - resourceDownloadCmd.Flags().StringVar(&instanceIdentifier, "instance", "", "instance identifier of the Beach instance to download from, eg. 'instance-123abc45-def6-7890-abcd-1234567890ab'") - resourceDownloadCmd.Flags().StringVar(&projectNamespace, "namespace", "", "The project namespace of the Beach instance to download from, eg. 'beach-project-123abc45-def6-7890-abcd-1234567890ab'") - resourceDownloadCmd.Flags().StringVar(&clusterIdentifier, "cluster", "", "The cluster identifier of the Beach instance to download from, eg. 'h9acc4'") - resourceDownloadCmd.Flags().StringVar(&sourceBucketName, "bucket", "", "name of the bucket to download resources from") - resourceDownloadCmd.Flags().StringVar(&targetResourcesPath, "resources-path", "", "custom path where to store the downloaded resources, e.g. 'Data/Persistent/Protected'") - resourceDownloadCmd.Flags().BoolVar(&synchronize, "sync", false, "Skip unchanged existing files") - - _ = resourceDownloadCmd.MarkFlagRequired("instance") - _ = resourceDownloadCmd.MarkFlagRequired("namespace") - rootCmd.AddCommand(resourceDownloadCmd) + resourceDownloadCmd.Flags().StringVar(&instanceIdentifier, "instance", "", "instance identifier of the Beach instance to download from, eg. 'instance-123abc45-def6-7890-abcd-1234567890ab'") + resourceDownloadCmd.Flags().StringVar(&projectNamespace, "namespace", "", "The project namespace of the Beach instance to download from, eg. 'beach-project-123abc45-def6-7890-abcd-1234567890ab'") + resourceDownloadCmd.Flags().StringVar(&clusterIdentifier, "cluster", "", "The cluster identifier of the Beach instance to download from, eg. 'h9acc4'") + resourceDownloadCmd.Flags().StringVar(&sourceBucketName, "bucket", "", "name of the bucket to download resources from") + resourceDownloadCmd.Flags().StringVar(&targetResourcesPath, "resources-path", "", "custom path where to store the downloaded resources, e.g. 'Data/Persistent/Protected'") + resourceDownloadCmd.Flags().BoolVar(&synchronize, "sync", false, "Skip unchanged existing files") + + _ = resourceDownloadCmd.MarkFlagRequired("instance") + _ = resourceDownloadCmd.MarkFlagRequired("namespace") + rootCmd.AddCommand(resourceDownloadCmd) } func handleResourceDownloadRun(cmd *cobra.Command, args []string) { - sandbox, err := beachsandbox.GetActiveSandbox() - if err != nil { - log.Fatal("Could not activate sandbox: ", err) - return - } - - if targetResourcesPath == "" { - targetResourcesPath = sandbox.ProjectDataPersistentResourcesPath - } - - _, err = os.Stat(targetResourcesPath) - if err != nil { - log.Fatal(fmt.Sprintf("The path %v does not exist", targetResourcesPath)) - return - } - - err, bucketNameFromCredentials, privateKeyDecoded := retrieveCloudStorageCredentials(instanceIdentifier, projectNamespace, clusterIdentifier) - if err != nil { - log.Fatal(err) - return - } - - if sourceBucketName == "" { - sourceBucketName = bucketNameFromCredentials - } - - ctx := context.Background() - client, err := storage.NewClient(ctx, option.WithCredentialsJSON(privateKeyDecoded)) - if err != nil { - log.Fatal(fmt.Sprintf("Failed to initialize cloud storage client: %v", err)) - return - } - - log.Info(fmt.Sprintf("Downloading resources from bucket %v to local directory %v ...", sourceBucketName, targetResourcesPath)) - - bucket := client.Bucket(sourceBucketName) - it := bucket.Objects(ctx, nil) - for { - attributes, err := it.Next() - if errors.Is(err, iterator.Done) { - break - } - if err != nil { - log.Error(err) - } else { - source := bucket.Object(attributes.Name) - targetPathAndFilename := filepath.Join(targetResourcesPath, getRelativePersistentResourcePathByHash(attributes.Name), filepath.Base(attributes.Name)) - - err = os.MkdirAll(filepath.Dir(targetPathAndFilename), 0755) - if err != nil { - log.Fatal(err) - return - } - - if synchronize == true { - if checkFileExists(targetPathAndFilename, attributes) { - log.Debug("Skipped " + attributes.Name + " as it already exists") - continue - } - } - - file, err := os.OpenFile(targetPathAndFilename, os.O_RDWR|os.O_CREATE, 0644) - if err != nil { - log.Fatal(err) - return - } - reader, err := source.NewReader(ctx) - if err != nil { - log.Fatal(err) - return - } - if _, err := io.Copy(file, reader); err != nil { - log.Fatal(err) - return - } - if err := reader.Close(); err != nil { - log.Fatal(err) - return - } - log.Debug("Downloaded " + attributes.Name) - } - } - - log.Info("Done") - return + sandbox, err := beachsandbox.GetActiveSandbox() + if err != nil { + log.Fatal("Could not activate sandbox: ", err) + return + } + + if targetResourcesPath == "" { + targetResourcesPath = sandbox.ProjectDataPersistentResourcesPath + } + + _, err = os.Stat(targetResourcesPath) + if err != nil { + log.Fatal(fmt.Sprintf("The path %v does not exist", targetResourcesPath)) + return + } + + err, bucketNameFromCredentials, privateKeyDecoded := retrieveCloudStorageCredentials(instanceIdentifier, projectNamespace, clusterIdentifier) + if err != nil { + log.Fatal(err) + return + } + + if sourceBucketName == "" { + sourceBucketName = bucketNameFromCredentials + } + + ctx := context.Background() + client, err := storage.NewClient(ctx, option.WithCredentialsJSON(privateKeyDecoded)) + if err != nil { + log.Fatal(fmt.Sprintf("Failed to initialize cloud storage client: %v", err)) + return + } + + log.Info(fmt.Sprintf("Downloading resources from bucket %v to local directory %v ...", sourceBucketName, targetResourcesPath)) + + bucket := client.Bucket(sourceBucketName) + it := bucket.Objects(ctx, nil) + for { + attributes, err := it.Next() + if errors.Is(err, iterator.Done) { + break + } + if err != nil { + log.Error(err) + } else { + source := bucket.Object(attributes.Name) + targetPathAndFilename := filepath.Join(targetResourcesPath, getRelativePersistentResourcePathByHash(attributes.Name), filepath.Base(attributes.Name)) + + err = os.MkdirAll(filepath.Dir(targetPathAndFilename), 0755) + if err != nil { + log.Fatal(err) + return + } + + if synchronize == true { + if checkFileExists(targetPathAndFilename, attributes) { + log.Debug("Skipped " + attributes.Name + " as it already exists") + continue + } + } + + file, err := os.OpenFile(targetPathAndFilename, os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + log.Fatal(err) + return + } + reader, err := source.NewReader(ctx) + if err != nil { + log.Fatal(err) + return + } + if _, err := io.Copy(file, reader); err != nil { + log.Fatal(err) + return + } + if err := reader.Close(); err != nil { + log.Fatal(err) + return + } + log.Debug("Downloaded " + attributes.Name) + } + } + + log.Info("Done") + return } func checkFileExists(targetPathAndFilename string, attributes *storage.ObjectAttrs) bool { - if _, err := os.Stat(targetPathAndFilename); err == nil { - file, err := os.Open(targetPathAndFilename) - if err != nil { - return false - } - defer file.Close() - - crc32c := crc32.New(crc32.MakeTable(crc32.Castagnoli)) - if _, err := io.Copy(crc32c, file); err != nil { - return false - } - - if crc32c.Sum32() == attributes.CRC32C { - return true - } - } - return false + if _, err := os.Stat(targetPathAndFilename); err == nil { + file, err := os.Open(targetPathAndFilename) + if err != nil { + return false + } + defer file.Close() + + crc32c := crc32.New(crc32.MakeTable(crc32.Castagnoli)) + if _, err := io.Copy(crc32c, file); err != nil { + return false + } + + if crc32c.Sum32() == attributes.CRC32C { + return true + } + } + return false } diff --git a/cmd/beach/cmd/resume.go b/cmd/beach/cmd/resume.go index f85c492..8065100 100644 --- a/cmd/beach/cmd/resume.go +++ b/cmd/beach/cmd/resume.go @@ -15,11 +15,12 @@ package cmd import ( + "path/filepath" + "github.com/flownative/localbeach/pkg/exec" "github.com/flownative/localbeach/pkg/path" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "path/filepath" ) // resumeCmd represents the resume command diff --git a/cmd/beach/cmd/setup-https.go b/cmd/beach/cmd/setup-https.go index 14424cf..2395bda 100644 --- a/cmd/beach/cmd/setup-https.go +++ b/cmd/beach/cmd/setup-https.go @@ -17,12 +17,12 @@ package cmd import ( "errors" "path/filepath" + "strings" "github.com/flownative/localbeach/pkg/exec" "github.com/flownative/localbeach/pkg/path" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" - "strings" ) var host string diff --git a/cmd/beach/cmd/setup.go b/cmd/beach/cmd/setup.go index 175aae7..22132f8 100644 --- a/cmd/beach/cmd/setup.go +++ b/cmd/beach/cmd/setup.go @@ -15,9 +15,12 @@ package cmd import ( + "errors" + "fmt" "os" "path/filepath" "strings" + "time" "github.com/flownative/localbeach/pkg/exec" "github.com/flownative/localbeach/pkg/path" @@ -66,7 +69,7 @@ func migrateOldBase() error { } log.Info("moving database data") - err = os.Rename(filepath.Join(path.OldBase, "MariaDB"), path.Database) + err = os.Rename(filepath.Join(path.OldBase, "MariaDB"), path.MariaDBDatabase) if err != nil { if os.IsNotExist(err) { log.Error(err) @@ -98,7 +101,18 @@ func migrateOldBase() error { func setupLocalBeach() error { log.Debug("setting up Local Beach with base path " + path.Base) - err := os.MkdirAll(path.Base, os.ModePerm) + databaseStatusOutput, err := exec.RunCommand("docker", []string{"ps", "--filter", "name=local_beach_database", "--filter", "status=running", "-q"}) + if err != nil { + log.Error(errors.New("failed checking status of container local_beach_database container")) + } + + if len(databaseStatusOutput) != 0 { + if err := bringBeachDown(); err != nil { + return fmt.Errorf("failed to bring Beach down: %w", err) + } + } + + err = os.MkdirAll(path.Base, os.ModePerm) if err != nil { log.Error(err) } @@ -114,27 +128,231 @@ func setupLocalBeach() error { log.Error(err) } - log.Debug("creating directory for databases at " + path.Database) - err = os.MkdirAll(path.Database, os.ModePerm) + log.Debug("creating directory for databases at " + path.MySQLDatabase) + err = os.MkdirAll(path.MySQLDatabase, os.ModePerm) if err != nil && !os.IsExist(err) { log.Error(err) } - composeFileContent := readFileFromAssets("local-beach/docker-compose.yml") - composeFileContent = strings.ReplaceAll(composeFileContent, "{{databasePath}}", path.Database) - composeFileContent = strings.ReplaceAll(composeFileContent, "{{certificatesPath}}", path.Certificates) + err = migrateMariaDBToMySQL() + if err != nil { + log.Error(err) + return err + } + + writeLocalBeachComposeFile() + + // Start Local Beach after setup/migration + log.Info("Starting Local Beach...") + err = startLocalBeach() + if err != nil { + log.Error("Failed to start Local Beach: ", err) + return err + } + + return nil +} + +func migrateMariaDBToMySQL() error { + migrationMarkerPath := filepath.Join(path.Base, ".mariadb-to-mysql-migration-complete") + + // Check if migration has already been completed + if _, err := os.Stat(migrationMarkerPath); err == nil { + log.Debug("MariaDB to MySQL migration already completed, skipping") + return nil + } + + // Check if MariaDB data exists + _, err := os.Stat(path.MariaDBDatabase) + if err != nil { + // No MariaDB data to migrate + if os.IsNotExist(err) { + log.Debug("No MariaDB data found, skipping migration") + return nil + } + return fmt.Errorf("failed to check MariaDB database path: %w", err) + } + + log.Info("Migrating MariaDB data from " + path.MariaDBDatabase + " to MySQL at " + path.MySQLDatabase) + log.Warn("This process may take several minutes depending on database size.") + log.Warn("Please do not interrupt this process!") + + // Start both database servers + if err = startMariaDB(); err != nil { + return fmt.Errorf("failed to start database servers for migration: %w", err) + } + + // Ensure cleanup happens on error + defer func() { + if stopErr := stopMariaDB(); stopErr != nil { + log.Error("Failed to stop MariaDB after migration: ", stopErr) + } + }() + + // Get list of databases to migrate + log.Info("Step 1/3: Discovering databases to migrate...") + commandArgs := []string{"exec", "local_beach_mariadb", "bash", "-c"} + commandArgs = append(commandArgs, "mysql -h local_beach_mariadb -u root -ppassword --batch --skip-column-names -e \"SHOW DATABASES;\" | grep -E -v \"(information|performance)_schema|mysql|sys\"") + databases, err := exec.RunCommand("docker", commandArgs) + if err != nil { + return fmt.Errorf("failed to list databases from MariaDB: %w", err) + } + + databaseList := strings.Split(strings.TrimSuffix(databases, "\n"), "\n") + if len(databaseList) == 0 || (len(databaseList) == 1 && databaseList[0] == "") { + log.Info("No databases found to migrate") + } else { + log.Info(fmt.Sprintf("Found %d database(s) to migrate", len(databaseList))) + + // Migrate each database + log.Info("Step 2/3: Migrating databases...") + migratedCount := 0 + for i, database := range databaseList { + if database == "" { + continue + } + log.Info(fmt.Sprintf(" [%d/%d] Migrating database: %s", i+1, len(databaseList), database)) + commandArgs = []string{"exec", "local_beach_database", "bash", "-c"} + // Remove DEFAULT clauses from JSON/TEXT/BLOB columns for MariaDB→MySQL 8.0 compatibility + // MySQL 8.0 is stricter about default values for these column types + // Need to handle both quoted strings ('{}'/'[]') and function calls (json_object()/json_array()) + sedPatterns := "" + + "sed -e \"s/ DEFAULT '[^']*' COMMENT '(DC2Type:json)'/ COMMENT '(DC2Type:json)'/g\" " + + "-e \"s/ DEFAULT ([^)]*) COMMENT '(DC2Type:json)'/ COMMENT '(DC2Type:json)'/g\" " + + "-e \"s/ DEFAULT '[^']*'\\( \\+COMMENT\\)/ \\1/g\" " + + "-e \"s/ DEFAULT ([^)]*)\\( \\+COMMENT\\)/ \\1/g\" " + + "-e \"s/ DEFAULT '[^']*'\\( \\+CHECK\\)/ \\1/g\" " + + "-e \"s/ DEFAULT ([^)]*)\\( \\+CHECK\\)/ \\1/g\" " + + "-e \"s/ DEFAULT [a-z_]*()\\( \\+CHECK\\)/ \\1/g\" " + + "-e \"s/ DEFAULT '[^']*'\\(,\\)/\\1/g\" " + + "-e \"s/ DEFAULT ([^)]*)\\(,\\)/\\1/g\" " + + "-e \"s/\\(json\\|text\\|blob\\|JSON\\|TEXT\\|BLOB\\) DEFAULT '[^']*'/\\1/g\" " + + "-e \"s/\\(json\\|text\\|blob\\|JSON\\|TEXT\\|BLOB\\) DEFAULT ([^)]*)/\\1/g\" " + + "-e \"s/\\(json\\|text\\|blob\\|JSON\\|TEXT\\|BLOB\\) DEFAULT [a-z_]*()\\( \\)/\\1\\2/g\" " + + "-e \"s/\\(json\\|text\\|blob\\|JSON\\|TEXT\\|BLOB\\) [a-z_]*()\\( \\)/\\1\\2/g\"" + commandArgs = append(commandArgs, "mysqldump -h local_beach_mariadb -u root -ppassword --add-drop-trigger --compress --comments --dump-date --hex-blob --quote-names --routines --triggers --no-autocommit --no-tablespaces --skip-lock-tables --single-transaction --quick --databases "+database+" | "+sedPatterns+" | mysql -h local_beach_database -u root -ppassword") + output, err := exec.RunCommand("docker", commandArgs) + if err != nil { + log.Error(fmt.Sprintf("Failed to migrate database %s: %v", database, err)) + if output != "" { + log.Error("Output: ", output) + } + return fmt.Errorf("migration failed for database %s: %w", database, err) + } + migratedCount++ + log.Info(fmt.Sprintf(" [%d/%d] Successfully migrated: %s", i+1, len(databaseList), database)) + } + log.Info(fmt.Sprintf("Successfully migrated %d database(s)", migratedCount)) + } + + // Verify migration + log.Info("Step 3/3: Verifying migration...") + if err = verifyMigration(); err != nil { + return fmt.Errorf("migration verification failed: %w", err) + } + + // Stop MariaDB + if err = stopMariaDB(); err != nil { + return fmt.Errorf("failed to stop MariaDB after migration: %w", err) + } - destination, err := os.Create(filepath.Join(path.Base, "docker-compose.yml")) + // Create migration marker file + markerFile, err := os.Create(migrationMarkerPath) if err != nil { - log.Error("failed creating docker-compose.yml: ", err) + log.Warn("Failed to create migration marker file: ", err) + log.Warn("Migration completed successfully, but may run again on next setup") } else { - _, err = destination.WriteString(composeFileContent) + timestamp := time.Now().Format(time.RFC3339) + _, _ = markerFile.WriteString(fmt.Sprintf("Migration completed at: %s\n", timestamp)) + markerFile.Close() + } + + log.Info("✓ Migration to MySQL completed successfully!") + log.Info("") + log.Info("Your MariaDB data has been preserved at: " + path.MariaDBDatabase) + log.Info("Once you've verified everything works correctly, you can safely remove it with:") + log.Info(" rm -rf " + path.MariaDBDatabase) + + return nil +} + +func verifyMigration() error { + // Check that MySQL server is running and accessible + commandArgs := []string{"exec", "local_beach_database", "bash", "-c"} + commandArgs = append(commandArgs, "mysql -h local_beach_database -u root -ppassword --batch --skip-column-names -e \"SELECT 'OK';\"") + output, err := exec.RunCommand("docker", commandArgs) + if err != nil { + return fmt.Errorf("failed to connect to MySQL: %w", err) + } + if !strings.Contains(output, "OK") { + return errors.New("MySQL connection test failed") + } + + // Get database count from MySQL + commandArgs = []string{"exec", "local_beach_database", "bash", "-c"} + commandArgs = append(commandArgs, "mysql -h local_beach_database -u root -ppassword --batch --skip-column-names -e \"SHOW DATABASES;\" | grep -E -v \"(information|performance)_schema|mysql|sys\" | wc -l") + mysqlDbCount, err := exec.RunCommand("docker", commandArgs) + if err != nil { + return fmt.Errorf("failed to count MySQL databases: %w", err) + } + + mysqlDbCount = strings.TrimSpace(mysqlDbCount) + log.Info(fmt.Sprintf("Verification: Found %s database(s) in MySQL", mysqlDbCount)) + + return nil +} + +func startMariaDB() error { + log.Debug("starting MariaDB server ...") + + writeMariaDBComposeFile() + + commandArgs := []string{"compose", "-f", filepath.Join(path.Base, "mariadb-compose.yml"), "up", "-d"} + err := exec.RunInteractiveCommand("docker", commandArgs) + if err != nil { + return errors.New("Database container startup failed") + } + + log.Debug("waiting for MariaDB server ...") + tries := 1 + for { + output, err := exec.RunCommand("docker", []string{"inspect", "-f", "{{.State.Health.Status}}", "local_beach_mariadb"}) if err != nil { - log.Error(err) + return errors.New("failed to check for MariaDB server container health") } + if strings.TrimSpace(output) == "healthy" { + break + } + if tries == 10 { + return errors.New("timeout waiting for MariaDB server to start") + } + tries++ + time.Sleep(3 * time.Second) + } + log.Debug("waiting for MySQL server ...") + tries = 1 + for { + output, err := exec.RunCommand("docker", []string{"inspect", "-f", "{{.State.Health.Status}}", "local_beach_database"}) + if err != nil { + return errors.New("failed to check for MySQL server container health") + } + if strings.TrimSpace(output) == "healthy" { + break + } + if tries == 10 { + return errors.New("timeout waiting for MySQL server to start") + } + tries++ + time.Sleep(3 * time.Second) } - _ = destination.Close() return nil } +func stopMariaDB() error { + log.Debug("stopping MariaDB server ...") + commandArgs := []string{"compose", "-f", filepath.Join(path.Base, "mariadb-compose.yml"), "rm", "--force", "--stop", "-v"} + _, err := exec.RunCommand("docker", commandArgs) + + return err +} diff --git a/cmd/beach/cmd/version.go b/cmd/beach/cmd/version.go index b776452..6ba1628 100644 --- a/cmd/beach/cmd/version.go +++ b/cmd/beach/cmd/version.go @@ -16,6 +16,7 @@ package cmd import ( "fmt" + "github.com/flownative/localbeach/pkg/version" "github.com/spf13/cobra" ) diff --git a/pkg/path/path_darwin.go b/pkg/path/path_darwin.go index a65383e..f4a72ce 100644 --- a/pkg/path/path_darwin.go +++ b/pkg/path/path_darwin.go @@ -27,7 +27,8 @@ import ( var OldBase = "" var Base = "" var Certificates = "" -var Database = "" +var MariaDBDatabase = "" +var MySQLDatabase = "" func init() { homeDir, err := os.UserHomeDir() @@ -40,5 +41,6 @@ func init() { OldBase = filepath.Join(homeDir, "Library", "Application Support", "Flownative", "Local Beach") Base = filepath.Join(homeDir, ".LocalBeach") Certificates = filepath.Join(Base, "Certificates") - Database = filepath.Join(Base, "MariaDB") + MariaDBDatabase = filepath.Join(Base, "MariaDB") + MySQLDatabase = filepath.Join(Base, "MySQL") } diff --git a/pkg/path/path_linux.go b/pkg/path/path_linux.go index 6351098..d3d39f9 100644 --- a/pkg/path/path_linux.go +++ b/pkg/path/path_linux.go @@ -27,7 +27,8 @@ import ( var OldBase = "" var Base = "" var Certificates = "" -var Database = "" +var MariaDBDatabase = "" +var MySQLDatabase = "" func init() { homeDir, err := os.UserHomeDir() @@ -40,5 +41,6 @@ func init() { OldBase = filepath.Join(homeDir, ".Flownative", "Local Beach") Base = filepath.Join(homeDir, ".LocalBeach") Certificates = filepath.Join(Base, "Certificates") - Database = filepath.Join(Base, "MariaDB") + MariaDBDatabase = filepath.Join(Base, "MariaDB") + MySQLDatabase = filepath.Join(Base, "MySQL") }