diff --git a/.github/workflows/easy-docker.yml b/.github/workflows/easy-docker.yml new file mode 100644 index 00000000..c33a3ef1 --- /dev/null +++ b/.github/workflows/easy-docker.yml @@ -0,0 +1,66 @@ +name: Easy Docker Tests + +on: + push: + branches: + - main + paths: + - "easy-docker.sh" + - "scripts/easy-docker/**" + - "tests/easy-docker/**" + - ".github/workflows/easy-docker.yml" + pull_request: + branches: + - main + paths: + - "easy-docker.sh" + - "scripts/easy-docker/**" + - "tests/easy-docker/**" + - ".github/workflows/easy-docker.yml" + +jobs: + bats: + name: Bats (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - name: Ubuntu 24.04 + image: ubuntu:24.04 + install_cmd: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y bash ca-certificates curl gawk git gzip tar ncurses-bin + update-alternatives --set awk /usr/bin/gawk + - name: Debian 12 + image: debian:12 + install_cmd: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y bash ca-certificates curl gawk git gzip tar ncurses-bin + update-alternatives --set awk /usr/bin/gawk + - name: Fedora 43 + image: fedora:43 + install_cmd: | + dnf install -y bash ca-certificates curl gawk git gzip tar ncurses + container: + image: ${{ matrix.image }} + env: + TERM: xterm-256color + + steps: + - name: Install distro dependencies + run: ${{ matrix.install_cmd }} + + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Bats + run: | + BATS_VERSION="v1.11.1" + curl -fsSL "https://github.com/bats-core/bats-core/archive/refs/tags/${BATS_VERSION}.tar.gz" -o bats-core.tar.gz + tar -xzf bats-core.tar.gz + "./bats-core-${BATS_VERSION#v}/install.sh" /usr/local + + - name: Run easy-docker Bats tests + run: bats --recursive tests/easy-docker diff --git a/.gitignore b/.gitignore index 591cbaff..6b475176 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ node_modules # VitePress **/.vitepress/dist **/.vitepress/cache + +# easy-docker local runtime data (contains secrets) +.easy-docker/ diff --git a/README.md b/README.md index 769efef7..91f7c7bd 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,24 @@ The full `frappe_docker` documentation is available in [`docs/`](docs/) and publ - **Development workflows:** [Development](docs/05-development/01-development.md) - **FAQ:** [Frequently Asked Questions](https://github.com/frappe/frappe_docker/wiki/Frequently-Asked-Questions) +### Easy Docker + +`easy-docker` is the guided terminal workflow for creating and managing Frappe +Docker stacks from one place. It helps with stack creation, app and branch +selection, image builds, runtime actions, and supported site operations without +requiring users to assemble the Compose and Bench commands manually. + +If you want a guided setup path, start with the +[Easy Docker docs](docs/10-easy-docker/index.md). + +Run it from the repository root with: + +```bash +bash easy-docker.sh +``` + +![Easy Docker main menu](docs/images/easy-docker/entry/main-menu.png) + ## Prerequisites - [Docker](https://docs.docker.com/get-docker/) diff --git a/docs/01-getting-started/05-easy-docker.md b/docs/01-getting-started/05-easy-docker.md new file mode 100644 index 00000000..792c9935 --- /dev/null +++ b/docs/01-getting-started/05-easy-docker.md @@ -0,0 +1,52 @@ +--- +title: Easy Docker +--- + +# Easy Docker + +`easy-docker` is the interactive setup and management workflow for this repository. +It guides common stack operations through a terminal UI so you do not have to assemble +every Docker and Bench command manually. + +For the detailed guide, use the dedicated docs area under `docs/10-easy-docker/`. +This getting-started page stays short and focuses on the first steps. + +Current status: + +- `single-host` is the primary supported topology +- `split-services` is available for separated stack setup and Compose runtime control +- site actions currently remain part of the `single-host` workflow +- stack, site, app, backup, restart, and update flows are being expanded iteratively + +The script entrypoint is: + +```bash +bash ./easy-docker.sh +``` + +Before the wizard opens, `easy-docker` validates its startup dependencies. +Today that means: + +- `gum` +- `docker` +- `docker compose` +- Docker daemon availability +- `jq` + +If `gum` or `jq` is missing, `easy-docker` first tries package-manager +installation and can then fall back to a pinned GitHub binary when the setup is +interactive and fallback is not disabled. On Windows, use a real Bash +environment such as WSL or Git Bash and keep the script path in Bash syntax. + +Minimal first use: + +1. Start `easy-docker.sh` +2. Create a new stack +3. Choose `single-host` +4. Pick the apps and branches you want +5. Build the custom image +6. Start the stack +7. Create the first site or manage an existing one from the stack menu + +Use this page as the entry point. For the full workflow reference, jump to the +dedicated `easy-docker` docs section in the root `docs` tree. diff --git a/docs/07-troubleshooting/01-troubleshoot.md b/docs/07-troubleshooting/01-troubleshoot.md index d2254b8c..c7cab849 100644 --- a/docs/07-troubleshooting/01-troubleshoot.md +++ b/docs/07-troubleshooting/01-troubleshoot.md @@ -4,6 +4,7 @@ title: Troubleshoot - [Fixing MariaDB issues after rebuilding the container](#fixing-mariadb-issues-after-rebuilding-the-container) - [docker-compose does not recognize variables from `.env` file](#docker-compose-does-not-recognize-variables-from-env-file) +- [easy-docker dependency checks](#easy-docker-dependency-checks) - [Windows Based Installation](#windows-based-installation) - [Redo installation](#redo-installation) @@ -69,6 +70,33 @@ Note: For MariaDB 10.3 and older use `mysql.user` instead of `mysql.global_priv` If you are using old version of `docker-compose` the .env file needs to be located in directory from where the docker-compose command is executed. There may also be difference in official `docker-compose` and the one packaged by distro. Use `--env-file=.env` if available to explicitly specify the path to file. +### easy-docker dependency checks + +`easy-docker` now validates its startup dependencies before the TUI opens. + +The check order is: + +1. CLI options +2. `gum` +3. `docker` +4. `jq` + +If `gum` or `jq` is missing, the wizard first tries package-manager +installation. If that does not work and the session is interactive, it can then +offer a pinned GitHub binary fallback unless `--no-installation-fallback` is +set. + +If `jq` is still missing after those steps, startup stops with install guidance +instead of continuing into the menus. + +On Windows, pay attention to which Bash runtime you are actually using: + +- `bash` from PowerShell usually means WSL +- Git Bash is a separate runtime +- use Bash path syntax such as `bash ./easy-docker.sh` + +Windows-native Bash setups can use either `jq` or `jq.exe` on `PATH`. + ### Windows Based Installation - Set environment variable `COMPOSE_CONVERT_WINDOWS_PATHS` e.g. `set COMPOSE_CONVERT_WINDOWS_PATHS=1` diff --git a/docs/10-easy-docker/01-overview.md b/docs/10-easy-docker/01-overview.md new file mode 100644 index 00000000..1772857b --- /dev/null +++ b/docs/10-easy-docker/01-overview.md @@ -0,0 +1,237 @@ +--- +title: Overview +--- + +# Overview + +`easy-docker` is the guided terminal workflow for setting up and managing a +Frappe Docker stack from one place. + +Instead of collecting Docker Compose, image build, and Bench commands manually, +you move through a small set of menus that guide the main lifecycle of a stack. + +The interface is powered by `gum`, which is used to render the interactive +terminal menus and prompts. + +All stack data created by the wizard is written into the repository-local +`.easy-docker` directory. That includes the generated stack environment files +and the stack-specific metadata used by the workflow. + +Internally, the stack JSON contract is now handled through `jq` instead of +line-based `awk` parsing. This is meant to improve robustness against harmless +JSON formatting differences without changing the generated layout of +`metadata.json` or `apps.json`. + +This means `easy-docker` is not a closed system. After the setup has been +created, you can still inspect the generated files, keep working with them +manually, and continue outside the wizard if that fits your workflow better. + +## What It Helps With + +`easy-docker` is built to make the usual stack workflow easier to follow: + +- create a new stack +- choose the stack setup path +- select apps and branches +- generate the stack environment +- build the custom image +- start, stop, restart, or delete the stack +- create and manage a site +- install or uninstall apps on the site +- create backups and run site maintenance actions + +## How It Feels to Use + +The workflow is organized as a guided sequence. + +You start with a stack, define how it should be configured, let the wizard write +the stack files, then continue into the management area for image, runtime, and +site actions. + +This makes `easy-docker` useful both for first-time setup and for returning to +an existing stack later when you need to update apps, rebuild an image, restart +services, or work on the site itself. + +If you have not worked with a guided Docker setup before, it helps to think of +`easy-docker` as a step-by-step assistant. It does not ask you to memorize the +Docker commands first. Instead, it asks a small number of questions, writes the +stack configuration for you, and then gives you a menu for the most common next +actions. + +![Easy Docker main menu](../images/easy-docker/entry/main-menu.png) + +## What It Needs + +To run `easy-docker`, the environment should have: + +- a working `docker` CLI +- Docker Compose v2 through `docker compose` +- a running Docker daemon +- `gum` for the interactive terminal UI +- `jq` for stack JSON processing + +The startup checks happen in this order: + +1. CLI option parsing +2. `gum` +3. `docker` +4. `jq` +5. TUI startup + +That means `--help` exits before dependency checks, while missing dependencies +stop the workflow before the menus open. + +When `gum` is already installed, the wizard uses it directly. + +When `gum` is missing, `easy-docker` first tries to install it through the +system package manager. If that is not available or does not succeed, the +wizard can fall back to a pinned GitHub release and install `gum` +automatically when possible. + +This means the usual setup flow is: + +- check whether `gum` is already available +- try package-manager installation first +- use the verified fallback path only if needed +- continue into the wizard once the required tooling is ready + +The Docker requirements are also checked on startup so the workflow stops early +with guidance instead of failing later in the middle of stack setup. + +`jq` now follows the same install strategy as `gum`: `easy-docker` first checks +whether it is already available, then tries the system package manager, and can +finally offer a pinned GitHub binary fallback in interactive sessions. +Runtime resolution accepts either `jq` or `jq.exe`, which keeps Windows-native +Bash setups compatible as long as one of them is on `PATH`. + +## Main Areas + +### Stack Creation + +The stack creation flow collects the main decisions up front and stores them in +the stack directory so the workflow can be resumed later. + +This is where you define the stack identity, choose the setup path, and prepare +the generated configuration for the next steps. + +A typical stack creation run moves through these prompts: + +1. Name the stack and choose the Frappe version profile. + +The stack name is simply the label under which `easy-docker` will remember this +setup. If you plan to run more than one setup later, choose a name that makes +the purpose obvious. + +The Frappe version profile is the base version the stack should start from. If +you are unsure, pick the version you intend to use for the actual project or +the version your apps are built for. + +![Stack name](../images/easy-docker/stack-creation/core/name.png) + +![Frappe version profile](../images/easy-docker/stack-creation/core/frappe-version.png) + +2. Choose the deployment topology and the main infrastructure options. + +In this phase, the wizard asks how the stack should be structured. For most +users, this is the point where you choose the simplest practical setup and let +the wizard generate the rest of the configuration around it. + +The proxy and database choices decide how traffic reaches the stack and where +the site data is stored. Even if you do not know every Docker detail yet, the +important part is that these choices describe how your stack should behave once +it is running. + +![Topology selection](../images/easy-docker/stack-creation/topology/topology-menu.png) + +![Proxy mode selection](../images/easy-docker/single-host/proxy-mode.png) + +![Database selection](../images/easy-docker/single-host/database-engine.png) + +3. Define the image naming and versioning that should be used for the stack. + +This step controls the image that will later be built for your stack. You can +think of it as naming the packaged application that Docker should run. + +The image name identifies the image, while the image version or tag helps you +track which build you are currently using. That becomes especially useful when +you rebuild the stack after changing app branches or updating the setup. + +![Custom image naming](../images/easy-docker/stack-creation/image/image-name.png) + +![Custom image version](../images/easy-docker/stack-creation/image/image-version.png) + +4. Select the apps and branches that should be built into the stack image. + +This is the point where you decide what should actually be included in the +stack. The app selection defines the application set, and the branch selection +defines which code line of each app should be used for the build. + +For new users, the practical rule is simple: only include the apps you really +need, and choose branches that match the Frappe version profile you selected +earlier. + +![App selection](../images/easy-docker/stack-creation/apps/app-selection.png) + +![App version selection](../images/easy-docker/stack-creation/apps/app-version.png) + +After these decisions, `easy-docker` has enough information to write the stack +files and prepare the next phase. At that point, the workflow moves from +planning the stack to actually building and running it. + +### Stack Management + +Once a stack exists, `easy-docker` becomes the control point for the stack: + +- app selection and branch updates +- custom image build and rebuild +- Compose lifecycle actions +- site operations such as create, migrate, backup, and delete + +That means the same workflow continues after setup instead of ending once the +first stack files are written. + +The first management steps usually focus on preparing the image and bringing the +stack up in Docker Compose. + +The build step creates the actual Docker image for the stack you just defined. +Until that image exists, there is nothing concrete for Docker Compose to start. +That is why the build action comes before the start action. + +![Build image action](../images/easy-docker/stack-runtime/build-image.png) + +Once the image has been built successfully, you can start the stack. This tells +Docker Compose to create the containers and launch the services that belong to +your setup. + +![Start stack action](../images/easy-docker/stack-runtime/start-stack.png) + +After startup, the status view helps you confirm that the stack is actually +running. This is especially useful for beginners because it gives a visible +checkpoint before moving on to site creation or later maintenance steps. + +![Running stack status](../images/easy-docker/stack-runtime/running-stack.png) + +In this example, the one stopped container shown in the status output is the +`configurator` container. That container is expected to finish and stop after +its setup work has completed. + +From there, the workflow usually continues into site-level actions such as +creating the first site, installing apps on the site, running migrations, or +creating backups. In other words: stack creation defines the environment, and +stack management is where that environment becomes usable. + +### Development Stacks + +When you create a stack through the development path, newly created sites in +that stack automatically enable `developer_mode`. + +This keeps the development-specific behavior attached to the stack itself, so +the workflow stays consistent when you return to manage it later. + +## Entry Point + +Run the wizard from the repository root: + +```bash +bash ./easy-docker.sh +``` diff --git a/docs/10-easy-docker/02-workflows.md b/docs/10-easy-docker/02-workflows.md new file mode 100644 index 00000000..5a9a36fe --- /dev/null +++ b/docs/10-easy-docker/02-workflows.md @@ -0,0 +1,37 @@ +--- +title: Workflows +--- + +# Workflows + +The wizard follows a simple order: + +1. Create a stack. +2. Choose `single-host` or `split-services`. +3. Select the apps and branches for the stack. +4. Generate the stack environment and render the Compose snapshot. +5. Build the custom image. +6. Start the stack. +7. Continue into site actions when the selected workflow supports them. + +Stack actions are grouped around image and Compose lifecycle: + +- `Apps` manages the stack app selection +- `Updates` handles app-branch changes and custom image tag updates +- `Site` handles site creation, backup, install, uninstall, and deletion +- `Start`, `Restart`, `Stop`, and `Delete` control the Compose lifecycle + +Site app management is intentionally scoped to apps that are already part of the +stack image. The wizard does not try to install arbitrary apps that are not part +of the selected stack configuration. + +Internally, the stack app contract is now handled through `jq` instead of +line-based `awk` parsing. This is intended to keep app selection and branch +update behavior the same while making the JSON processing more robust in the +background. The generated `metadata.json` and `apps.json` files are still meant +to look the same to users. + +For the split-services path, see +[Split Services](./05-split-services.md). That page explains the intended flow +in simple terms and shows where the proxy, application, database, and Redis +choices fit into the setup. diff --git a/docs/10-easy-docker/03-updates.md b/docs/10-easy-docker/03-updates.md new file mode 100644 index 00000000..67d22fc1 --- /dev/null +++ b/docs/10-easy-docker/03-updates.md @@ -0,0 +1,32 @@ +--- +title: Updates +--- + +# Updates + +App updates are handled as an image update workflow, not as a live in-container +`git pull`. + +The recommended sequence is: + +1. Update the selected app branches. +2. Set a new `CUSTOM_TAG`. +3. Build the updated custom image. +4. Restart the stack. +5. Run `migrate` on the site if required by the app change. + +The wizard keeps the current `frappe_branch` visible while you update apps so +you can see the base version the stack is built against. + +`CUSTOM_TAG` is stored in the stack `.env` file. The Compose stack reads that +value on the next start or restart, so the tag change becomes effective once the +image has been rebuilt and the stack is restarted. + +The update flow still rebuilds the image from a regenerated `apps.json`, and +`metadata.json` remains the source of truth for the stack state. The difference +is internal: stack metadata and generated app state are now processed through +`jq` instead of line-based `awk` parsing. No user-visible change in the file +layout is intended. + +For now, this update flow focuses on app branch changes. A separate Frappe base +version update flow can be added later without changing the overall model. diff --git a/docs/10-easy-docker/04-generated-compose.md b/docs/10-easy-docker/04-generated-compose.md new file mode 100644 index 00000000..968a42a0 --- /dev/null +++ b/docs/10-easy-docker/04-generated-compose.md @@ -0,0 +1,23 @@ +--- +title: Generated Compose +--- + +# Generated Compose + +`easy-docker` can render a `compose.generated.yaml` snapshot from the stack +metadata and environment. + +This file is useful when you want to inspect or reuse the resolved Compose +configuration outside the wizard, but it is not the primary runtime input for +stack start or stop. + +What is important: + +- the stack runtime reads the original Compose files from metadata +- the runtime also reads the stack `.env` +- `compose.generated.yaml` is a rendered snapshot, not the source of truth +- it is refreshed after a successful custom image build + +That means the generated file stays aligned with the current stack state when the +image has actually been rebuilt, which is the point where manual reuse is most +likely to matter. diff --git a/docs/10-easy-docker/05-split-services.md b/docs/10-easy-docker/05-split-services.md new file mode 100644 index 00000000..5e02e3d1 --- /dev/null +++ b/docs/10-easy-docker/05-split-services.md @@ -0,0 +1,215 @@ +--- +title: Split Services +--- + +# Split Services + +`split-services` is the guided setup path for users who want to keep the +application part of the stack separate from the data part, with an optional +proxy layer in front. + +The goal of this page is to help a first-time user understand what each step +means and what choice they are making in the wizard. + +The current split-services flow focuses on: + +- writing the split stack files +- rendering the generated Compose snapshot +- building the custom image +- starting, stopping, restarting, and deleting the stack + +This keeps the first version easier to understand while still making the stack +layout explicit. + +Think of the split-services setup as three simple parts: + +- `Application Services` run the Frappe application itself +- `Data Services` provide the database and Redis services +- `Reverse Proxy` handles incoming web traffic when you want a proxy in front + +This page uses the clearer names above so the setup is easier to understand the +first time you see it. + +## What This Setup Is For + +Split services are useful when you do not want every service in one combined +stack. + +This is a good fit if you want to: + +- keep the application layer separate from the data layer +- run the database and Redis independently from the app stack +- place a proxy in front only when you actually need it +- keep the setup readable so you can manage parts of it later + +If you are new to Docker, you can think of this as splitting one big stack into +smaller parts that each have a clearer job. + +## How The Wizard Feels + +The wizard still behaves like the other `easy-docker` flows. + +It asks one question at a time, explains what that choice affects, and then +writes the stack files for you. You do not need to build the final Compose +files by hand before you start. + +![Split Services topology menu](../images/easy-docker/split-services/topology-menu.png) + +## Step 1. Choose The Split Topology + +When you select `Split services`, the wizard first explains that the stack is +being divided into separate parts instead of using one combined setup. + +At this point you are not changing any service settings yet. You are only +choosing the layout of the stack. + +This matters because later steps will follow the same idea. If the setup is +split, the wizard will ask about the application side, the data side, and the +proxy side separately. + +## Step 2. Decide How The Data Services Should Work + +The next decision is the data layer. + +This is where you decide whether `easy-docker` manages the database and Redis +for you, or whether the stack should connect to services that already exist +elsewhere. + +### Managed Data Services + +Choose this if you want `easy-docker` to create the database and Redis +containers as part of the generated split setup. + +This is usually the easier choice when you are trying split-services for the +first time. + +It means: + +- the wizard writes the data service configuration for you +- the stack can start with its own managed database and Redis services +- you still keep the data layer separate from the application layer + +### External Data Services + +Choose this if the database and Redis are already running somewhere else. + +This is more advanced, but it can be useful when: + +- your organization already provides shared database and Redis services +- you want the application stack to connect to existing infrastructure +- you are splitting responsibilities across different systems + +If you choose this path, the wizard asks for the required connection values and +stores them in the generated stack configuration. + +![Data services choice](../images/easy-docker/split-services/data-services-choice.png) + +## Step 3. Choose The Database Engine + +After choosing how the data layer should be handled, the wizard asks which +database engine should be used for the stack. + +For most users, this is the simpler way to think about it: + +- `MariaDB` is the default path and usually the easiest starting point +- `PostgreSQL` is available if that is the database you already use or want to use + +If the data layer is managed by `easy-docker`, this choice decides which +database service will be created in the generated setup. + +If the data layer is external, this choice still matters because it tells the +stack which kind of database it is configured to connect to. + +![Database engine choice](../images/easy-docker/split-services/database-engine.png) + +## Step 4. Decide How Redis Should Work + +Redis is asked about separately so the setup stays explicit. + +This is useful because some users want Redis managed together with the split +stack, while others already have Redis running elsewhere. + +The choices are simple: + +- `Managed Redis Services` means the stack includes Redis services for you +- `External Redis Services` means you provide the Redis endpoints yourself +- `No Redis Services` is the advanced option when you do not want the wizard to + configure Redis for this stack + +The important beginner-friendly idea is that Redis belongs to the data side of +the setup, even though it is asked in its own step. + +![Redis services choice](../images/easy-docker/split-services/redis-services.png) + +## Step 5. Decide Whether A Proxy Should Be Included + +The proxy layer is optional. + +If you want the stack to answer HTTP or HTTPS traffic directly in front of the +application services, include a proxy. + +If you do not need that yet, you can skip it and keep the setup simpler. + +For a first-time user, the important idea is: + +- the proxy is the front door +- the application services do the actual work +- the data services keep the site data and queue state safe + +![Proxy choice](../images/easy-docker/split-services/proxy-choice.png) + +## Step 6. Review The Setup Before It Is Written + +Before the wizard writes the files, it shows a summary of the choices you just +made. + +This summary is the last chance to stop and check whether the stack is shaped +the way you expected. + +The summary makes the split very obvious: + +- what runs as application services +- what runs as data services +- whether a proxy is included +- whether the data services are managed by `easy-docker` or external + +![Split services summary](../images/easy-docker/split-services/summary.png) + +## What Happens After Setup + +Once the split stack has been written, the generated files are stored in the +same repository-local `.easy-docker` area as the other wizard data. + +That means you can still inspect the generated files later and continue working +with them manually if needed. + +After the files are written, the wizard returns to the stack management view for +that split-services stack. From there you can work with the stack runtime, app +selection, and update actions that are currently supported for this topology. + +![Split Services manage stack actions](../images/easy-docker/split-services/manage-stack-actions.png) + +For split-services, the most important practical point is that the application +side, the data side, and the proxy side remain easy to understand +individually. If you come back later to change one part, you should not have to +guess where the other parts are defined. + +## A Simple Mental Model + +If this is the first time you use a split setup, this is the easiest way to +think about it: + +- `Application Services` are the part you interact with most often +- `Data Services` keep the database and Redis available +- `Reverse Proxy` is optional and sits in front of the application side + +You do not need to understand every Docker detail before using the wizard. +You only need to know which part you want to manage separately. + +## Where To Go Next + +After reading this page, the next useful pages are: + +- [Overview](./01-overview.md) +- [Workflows](./02-workflows.md) +- [Generated Compose](./04-generated-compose.md) diff --git a/docs/10-easy-docker/index.md b/docs/10-easy-docker/index.md new file mode 100644 index 00000000..767a5091 --- /dev/null +++ b/docs/10-easy-docker/index.md @@ -0,0 +1,30 @@ +--- +title: Easy Docker +--- + +# Easy Docker + +`easy-docker` is the interactive setup and management workflow for this repository. +It is designed to make common Frappe Docker tasks easier from the terminal while +keeping the underlying Compose and Bench model visible. + +This section documents the current behavior of the wizard: + +- `single-host` is the supported production workflow today +- `split-services` is available for separated stack setup and Compose runtime control +- site actions currently remain part of the `single-host` workflow +- stack, site, app, and update actions are handled through the wizard +- the generated Compose output is available as a rendered snapshot + +Before the wizard opens, `easy-docker` validates its startup dependencies. That +includes `gum`, `docker`, `docker compose`, a running Docker daemon, and `jq`. +`gum` and `jq` can both use package-manager installation and a pinned GitHub +binary fallback. `docker` still must already be present. + +Start here: + +- [Overview](./01-overview.md) +- [Workflows](./02-workflows.md) +- [Updates](./03-updates.md) +- [Generated Compose](./04-generated-compose.md) +- [Split Services](./05-split-services.md) diff --git a/docs/images/easy-docker/entry/main-menu.png b/docs/images/easy-docker/entry/main-menu.png new file mode 100644 index 00000000..5f1b078f Binary files /dev/null and b/docs/images/easy-docker/entry/main-menu.png differ diff --git a/docs/images/easy-docker/single-host/database-engine.png b/docs/images/easy-docker/single-host/database-engine.png new file mode 100644 index 00000000..f6f26a19 Binary files /dev/null and b/docs/images/easy-docker/single-host/database-engine.png differ diff --git a/docs/images/easy-docker/single-host/proxy-mode.png b/docs/images/easy-docker/single-host/proxy-mode.png new file mode 100644 index 00000000..4417b31b Binary files /dev/null and b/docs/images/easy-docker/single-host/proxy-mode.png differ diff --git a/docs/images/easy-docker/split-services/data-services-choice.png b/docs/images/easy-docker/split-services/data-services-choice.png new file mode 100644 index 00000000..7a356daf Binary files /dev/null and b/docs/images/easy-docker/split-services/data-services-choice.png differ diff --git a/docs/images/easy-docker/split-services/database-engine.png b/docs/images/easy-docker/split-services/database-engine.png new file mode 100644 index 00000000..1d30fe6c Binary files /dev/null and b/docs/images/easy-docker/split-services/database-engine.png differ diff --git a/docs/images/easy-docker/split-services/manage-stack-actions.png b/docs/images/easy-docker/split-services/manage-stack-actions.png new file mode 100644 index 00000000..1d811526 Binary files /dev/null and b/docs/images/easy-docker/split-services/manage-stack-actions.png differ diff --git a/docs/images/easy-docker/split-services/proxy-choice.png b/docs/images/easy-docker/split-services/proxy-choice.png new file mode 100644 index 00000000..6c8a79eb Binary files /dev/null and b/docs/images/easy-docker/split-services/proxy-choice.png differ diff --git a/docs/images/easy-docker/split-services/redis-services.png b/docs/images/easy-docker/split-services/redis-services.png new file mode 100644 index 00000000..1de6e294 Binary files /dev/null and b/docs/images/easy-docker/split-services/redis-services.png differ diff --git a/docs/images/easy-docker/split-services/summary.png b/docs/images/easy-docker/split-services/summary.png new file mode 100644 index 00000000..9b5da88c Binary files /dev/null and b/docs/images/easy-docker/split-services/summary.png differ diff --git a/docs/images/easy-docker/split-services/topology-menu.png b/docs/images/easy-docker/split-services/topology-menu.png new file mode 100644 index 00000000..ffe8b8f1 Binary files /dev/null and b/docs/images/easy-docker/split-services/topology-menu.png differ diff --git a/docs/images/easy-docker/stack-creation/apps/app-selection.png b/docs/images/easy-docker/stack-creation/apps/app-selection.png new file mode 100644 index 00000000..bd30bc0f Binary files /dev/null and b/docs/images/easy-docker/stack-creation/apps/app-selection.png differ diff --git a/docs/images/easy-docker/stack-creation/apps/app-version.png b/docs/images/easy-docker/stack-creation/apps/app-version.png new file mode 100644 index 00000000..4b16d755 Binary files /dev/null and b/docs/images/easy-docker/stack-creation/apps/app-version.png differ diff --git a/docs/images/easy-docker/stack-creation/core/frappe-version.png b/docs/images/easy-docker/stack-creation/core/frappe-version.png new file mode 100644 index 00000000..6ac62933 Binary files /dev/null and b/docs/images/easy-docker/stack-creation/core/frappe-version.png differ diff --git a/docs/images/easy-docker/stack-creation/core/name.png b/docs/images/easy-docker/stack-creation/core/name.png new file mode 100644 index 00000000..2a07299b Binary files /dev/null and b/docs/images/easy-docker/stack-creation/core/name.png differ diff --git a/docs/images/easy-docker/stack-creation/image/image-name.png b/docs/images/easy-docker/stack-creation/image/image-name.png new file mode 100644 index 00000000..499a603a Binary files /dev/null and b/docs/images/easy-docker/stack-creation/image/image-name.png differ diff --git a/docs/images/easy-docker/stack-creation/image/image-version.png b/docs/images/easy-docker/stack-creation/image/image-version.png new file mode 100644 index 00000000..b4333e3b Binary files /dev/null and b/docs/images/easy-docker/stack-creation/image/image-version.png differ diff --git a/docs/images/easy-docker/stack-creation/topology/topology-menu.png b/docs/images/easy-docker/stack-creation/topology/topology-menu.png new file mode 100644 index 00000000..6953e8f2 Binary files /dev/null and b/docs/images/easy-docker/stack-creation/topology/topology-menu.png differ diff --git a/docs/images/easy-docker/stack-runtime/build-image.png b/docs/images/easy-docker/stack-runtime/build-image.png new file mode 100644 index 00000000..51a1ac3e Binary files /dev/null and b/docs/images/easy-docker/stack-runtime/build-image.png differ diff --git a/docs/images/easy-docker/stack-runtime/running-stack.png b/docs/images/easy-docker/stack-runtime/running-stack.png new file mode 100644 index 00000000..30f239f7 Binary files /dev/null and b/docs/images/easy-docker/stack-runtime/running-stack.png differ diff --git a/docs/images/easy-docker/stack-runtime/start-stack.png b/docs/images/easy-docker/stack-runtime/start-stack.png new file mode 100644 index 00000000..61fcf574 Binary files /dev/null and b/docs/images/easy-docker/stack-runtime/start-stack.png differ diff --git a/easy-docker.sh b/easy-docker.sh new file mode 100755 index 00000000..d5c09025 --- /dev/null +++ b/easy-docker.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +exec "${ROOT_DIR}/scripts/easy-docker/main.sh" "$@" diff --git a/scripts/easy-docker/README.md b/scripts/easy-docker/README.md new file mode 100644 index 00000000..3972cc59 --- /dev/null +++ b/scripts/easy-docker/README.md @@ -0,0 +1,70 @@ +# Easy-Frappe-Docker + +Easy installation script for Frappe Docker for development and production + +## Run + +```bash +bash ./easy-docker.sh +``` + +Run the entrypoint from a real Bash environment. + +- On Linux, use your normal shell session. +- On Windows, use WSL or Git Bash. +- If you start `bash` from PowerShell, that usually means WSL, so keep the path + in Bash form such as `bash ./easy-docker.sh`, not `bash .\easy-docker.sh`. + +## Dependencies + +- `gum` is used for the TUI and is installed automatically when possible +- `docker` CLI is required and checked on startup +- `docker compose` (Compose v2 command) is required and checked on startup +- `jq` is required for stack JSON handling and is checked on startup +- Docker Desktop includes Compose v2 by default; on Linux Engine-only setups you may need the `docker-compose-plugin` package +- Docker daemon must be running before the TUI starts +- Required docker commands are validated (`docker ps/exec/inspect/cp` and `docker compose config/up/down/logs/exec/pull/ps`) +- Startup validation order is: CLI options, `gum`, `docker`, then `jq` +- If package manager installation for `gum` or `jq` fails, the script can use a pinned GitHub binary fallback +- The `gum` fallback is pinned to `gum` `v0.17.0` and verifies SHA256 checksums from `scripts/easy-docker/config/gum-checksums.tsv` +- The `jq` fallback is pinned to `jq` `1.8.1` and verifies SHA256 checksums from `scripts/easy-docker/config/jq-checksums.tsv` +- `docker` still has no installation fallback path and must already be present +- Runtime `jq` resolution accepts either `jq` or `jq.exe`, so Windows-native setups with only `jq.exe` on `PATH` are supported + +## JSON Handling + +- `metadata.json` remains the source of truth for stack state +- `apps.json` is still generated from stack metadata and still used for the image build +- `easy-docker` now reads and writes stack JSON through `jq` instead of line-based `awk` parsing +- This is an internal robustness change only; the generated layout of `metadata.json` and `apps.json` is intended to stay the same for users + +## Options + +- `-h`, `--help` + - Shows usage and exits without starting the TUI +- `--no-installation-fallback` + - Disables GitHub binary fallback prompts for `gum` and `jq` + - If package manager installation fails, the script exits with manual installation guidance + +## Apps Catalog + +- App options in the wizard are read from: + - `scripts/easy-docker/config/apps.tsv` +- Format per line: + - `idlabelrepodefault_branchbranches_csv` +- Example: + - `erpnextERPNexthttps://github.com/frappe/erpnextversion-15version-15,version-16,develop` +- The install selection in the wizard is limited to apps from this catalog. +- For each selected app, the wizard shows the configured branch list from this catalog and prompts branch selection. + +## Frappe Version Profiles + +- During new stack creation (after stack name), the wizard asks for a Frappe branch profile from: + - `scripts/easy-docker/config/frappe.tsv` +- Format per line: + - `idlabelfrappe_branch` +- Example: + - `v16Frappe v16 (version-16)version-16` +- The selected `frappe_branch` is saved in stack `metadata.json` and used as default branch suggestion for app branch selection. +- In `metadata.json`, this value is stored top-level as: + - `"frappe_branch": "version-16"` (example) diff --git a/scripts/easy-docker/config/apps.tsv b/scripts/easy-docker/config/apps.tsv new file mode 100644 index 00000000..3ed9591c --- /dev/null +++ b/scripts/easy-docker/config/apps.tsv @@ -0,0 +1,7 @@ +# id label repo default_branch branches_csv +erpnext ERPNext https://github.com/frappe/erpnext develop develop,version-15,version-16 +crm CRM https://github.com/frappe/crm develop develop,main +hrms HRMS https://github.com/frappe/hrms develop develop,version-16,version-15 +lms LMS https://github.com/frappe/lms develop develop,main +helpdesk Helpdesk https://github.com/frappe/helpdesk develop develop,main +drive Drive https://github.com/frappe/drive develop develop diff --git a/scripts/easy-docker/config/frappe.tsv b/scripts/easy-docker/config/frappe.tsv new file mode 100644 index 00000000..e612713b --- /dev/null +++ b/scripts/easy-docker/config/frappe.tsv @@ -0,0 +1,4 @@ +# id label frappe_branch +v16 Frappe v16 (version-16) version-16 +v15 Frappe v15 (version-15) version-15 +develop Frappe develop (develop) develop diff --git a/scripts/easy-docker/config/gum-checksums.tsv b/scripts/easy-docker/config/gum-checksums.tsv new file mode 100644 index 00000000..1babfb01 --- /dev/null +++ b/scripts/easy-docker/config/gum-checksums.tsv @@ -0,0 +1,7 @@ +# version asset_name sha256 +0.17.0 gum_0.17.0_Darwin_arm64.tar.gz e2a4b8596efa05821d8c58d0c1afbcd7ad1699ba69c689cc3ff23a4a99c8b237 +0.17.0 gum_0.17.0_Darwin_x86_64.tar.gz cd66576aeebe6cd19c771863c7e8d696e0e1d5387d1e7075666baa67c2052e53 +0.17.0 gum_0.17.0_Linux_arm64.tar.gz b0b9ed95cbf7c8b7073f17b9591811f5c001e33c7cfd066ca83ce8a07c576f9c +0.17.0 gum_0.17.0_Linux_armv7.tar.gz 25711c2fbc6887cde79ed586972834121a04955968808dd688c688381ac50ab2 +0.17.0 gum_0.17.0_Linux_x86_64.tar.gz 69ee169bd6387331928864e94d47ed01ef649fbfe875baed1bbf27b5377a6fdb +0.17.0 gum_0.17.0_Windows_x86_64.zip b2be80531c6babc8d4e0e6ca95773d58118a2e1582ae006aace08dbc55503072 diff --git a/scripts/easy-docker/config/jq-checksums.tsv b/scripts/easy-docker/config/jq-checksums.tsv new file mode 100644 index 00000000..89d34996 --- /dev/null +++ b/scripts/easy-docker/config/jq-checksums.tsv @@ -0,0 +1,10 @@ +# version asset_name sha256 +1.8.1 jq-linux-amd64 020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d +1.8.1 jq-linux64 020468de7539ce70ef1bceaf7cde2e8c4f2ca6c3afb84642aabc5c97d9fc2a0d +1.8.1 jq-linux-arm64 6bc62f25981328edd3cfcfe6fe51b073f2d7e7710d7ef7fcdac28d4e384fc3d4 +1.8.1 jq-linux-armhf ac304e50cf7cd24933d83dc7d0e4f79892a71a92fb02336d4ecaffa8933760bd +1.8.1 jq-macos-amd64 e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f +1.8.1 jq-osx-amd64 e80dbe0d2a2597e3c11c404f03337b981d74b4a8504b70586c354b7697a7c27f +1.8.1 jq-macos-arm64 a9fe3ea2f86dfc72f6728417521ec9067b343277152b114f4e98d8cb0e263603 +1.8.1 jq-win64.exe 23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334 +1.8.1 jq-windows-amd64.exe 23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334 diff --git a/scripts/easy-docker/lib/app/options.sh b/scripts/easy-docker/lib/app/options.sh new file mode 100755 index 00000000..54716723 --- /dev/null +++ b/scripts/easy-docker/lib/app/options.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +print_usage() { + cat <<'USAGE' +Usage: bash easy-docker.sh [options] + +Options: + --no-installation-fallback Disable installation fallback prompt + -h, --help Show this help +USAGE +} + +parse_cli_options() { + local result_var="${1}" + local disable_installation_fallback_value=0 + shift + + while [ "$#" -gt 0 ]; do + case "$1" in + --no-installation-fallback) + disable_installation_fallback_value=1 + ;; + -h | --help) + print_usage + return 2 + ;; + *) + echo "Unknown option: $1" + print_usage + return 1 + ;; + esac + shift + done + + printf -v "${result_var}" "%s" "${disable_installation_fallback_value}" + return 0 +} diff --git a/scripts/easy-docker/lib/app/run.sh b/scripts/easy-docker/lib/app/run.sh new file mode 100755 index 00000000..80f88b26 --- /dev/null +++ b/scripts/easy-docker/lib/app/run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +load_easy_docker_app_modules() { + local app_dir="" + app_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common.sh + source "${app_dir}/wizard/common.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/env.sh + source "${app_dir}/wizard/env.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/single_host.sh + source "${app_dir}/wizard/single_host.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/flows.sh + source "${app_dir}/wizard/flows.sh" +} + +load_easy_docker_app_modules diff --git a/scripts/easy-docker/lib/app/screen.sh b/scripts/easy-docker/lib/app/screen.sh new file mode 100755 index 00000000..8a6d1344 --- /dev/null +++ b/scripts/easy-docker/lib/app/screen.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +ALT_SCREEN_ACTIVE=0 +CURSOR_HIDDEN=0 + +stdout_is_terminal() { + [ -t 1 ] +} + +run_tput_quietly() { + tput "$@" 2>/dev/null +} + +enter_alt_screen() { + if ! stdout_is_terminal || ! command_exists tput; then + return 0 + fi + + if run_tput_quietly smcup; then + ALT_SCREEN_ACTIVE=1 + fi + + if run_tput_quietly civis; then + CURSOR_HIDDEN=1 + fi + + return 0 +} + +leave_alt_screen() { + if command_exists tput; then + if [ "${CURSOR_HIDDEN}" = "1" ]; then + run_tput_quietly cnorm || true + fi + + if [ "${ALT_SCREEN_ACTIVE}" = "1" ]; then + run_tput_quietly rmcup || true + fi + fi + + CURSOR_HIDDEN=0 + ALT_SCREEN_ACTIVE=0 + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common.sh b/scripts/easy-docker/lib/app/wizard/common.sh new file mode 100755 index 00000000..502fb4dd --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +load_easy_docker_wizard_common_modules() { + local wizard_dir="" + wizard_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/constants.sh + source "${wizard_dir}/common/constants.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/core.sh + source "${wizard_dir}/common/core.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/helpers.sh + source "${wizard_dir}/common/helpers.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose.sh + source "${wizard_dir}/common/compose.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/frappe.sh + source "${wizard_dir}/common/frappe.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps.sh + source "${wizard_dir}/common/apps.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site.sh + source "${wizard_dir}/common/site.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/ux.sh + source "${wizard_dir}/common/ux.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/split_services.sh + source "${wizard_dir}/split_services.sh" +} + +load_easy_docker_wizard_common_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/apps.sh b/scripts/easy-docker/lib/app/wizard/common/apps.sh new file mode 100755 index 00000000..cca37f19 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/apps.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +load_easy_docker_wizard_app_modules() { + local apps_dir="" + apps_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/apps" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh + source "${apps_dir}/catalog.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh + source "${apps_dir}/metadata.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh + source "${apps_dir}/persistence.sh" +} + +load_easy_docker_wizard_app_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh b/scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh new file mode 100755 index 00000000..075bd44e --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh @@ -0,0 +1,490 @@ +#!/usr/bin/env bash + +trim_predefined_catalog_field() { + local result_var="${1}" + local value="${2}" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + printf -v "${result_var}" "%s" "${value}" +} + +is_valid_predefined_app_id() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!a-z0-9._-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +generate_predefined_app_id_from_label() { + local result_var="${1}" + local app_label="${2}" + local generated_id="" + + generated_id="$( + printf '%s' "${app_label}" | + tr '[:upper:]' '[:lower:]' | + sed -E 's/[[:space:]]+/_/g; s/[^a-z0-9._-]+/_/g; s/_+/_/g; s/^_+//; s/_+$//' + )" + + if ! is_valid_predefined_app_id "${generated_id}"; then + return 1 + fi + + printf -v "${result_var}" "%s" "${generated_id}" + return 0 +} + +is_valid_predefined_app_repo() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + https://* | http://* | ssh://* | git://* | git@*:* | file://*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_valid_predefined_app_branch() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!A-Za-z0-9._/-]* | .* | *..* | */ | /*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +csv_contains_branch() { + local csv_values="${1}" + local value="${2}" + + case ",${csv_values}," in + *,"${value}",*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +normalize_predefined_branches_csv() { + local result_csv_var="${1}" + local branches_csv_raw="${2}" + local branch_token="" + local normalized_csv="" + local -a raw_tokens=() + + IFS=',' read -r -a raw_tokens <<<"${branches_csv_raw}" + for branch_token in "${raw_tokens[@]}"; do + trim_predefined_catalog_field branch_token "${branch_token}" + if [ -z "${branch_token}" ]; then + continue + fi + + if ! is_valid_predefined_app_branch "${branch_token}"; then + return 1 + fi + + if csv_contains_branch "${normalized_csv}" "${branch_token}"; then + continue + fi + + if [ -z "${normalized_csv}" ]; then + normalized_csv="${branch_token}" + else + normalized_csv="${normalized_csv},${branch_token}" + fi + done + + if [ -z "${normalized_csv}" ]; then + return 1 + fi + + printf -v "${result_csv_var}" "%s" "${normalized_csv}" + return 0 +} + +get_predefined_apps_catalog_path() { + local repo_root="" + + repo_root="$(get_easy_docker_repo_root)" + printf '%s/scripts/easy-docker/config/apps.tsv\n' "${repo_root}" +} + +get_predefined_apps_catalog_entries() { + local catalog_path="" + local raw_line="" + local line="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + local normalized_branches_csv="" + local first_branch="" + local extra="" + local seen_ids="," + local seen_labels="," + + catalog_path="$(get_predefined_apps_catalog_path)" + if [ ! -f "${catalog_path}" ]; then + return 1 + fi + + while IFS= read -r raw_line || [ -n "${raw_line}" ]; do + trim_predefined_catalog_field line "${raw_line}" + if [ -z "${line}" ]; then + continue + fi + + case "${line}" in + \#*) + continue + ;; + esac + + if [[ "${line}" == *$'\t'* ]]; then + IFS=$'\t' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" + else + # Backward compatibility for older catalog rows. + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" + fi + trim_predefined_catalog_field app_id "${app_id}" + trim_predefined_catalog_field app_label "${app_label}" + trim_predefined_catalog_field app_repo "${app_repo}" + trim_predefined_catalog_field app_default_branch "${app_default_branch}" + trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" + trim_predefined_catalog_field extra "${extra}" + + if [ -n "${extra}" ] || [ -z "${app_id}" ] || [ -z "${app_label}" ] || [ -z "${app_repo}" ] || [ -z "${app_branches_csv}" ]; then + return 1 + fi + + if ! is_valid_predefined_app_id "${app_id}"; then + return 1 + fi + + if ! is_valid_predefined_app_repo "${app_repo}"; then + return 1 + fi + + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + return 1 + fi + + if [ -z "${app_default_branch}" ]; then + first_branch="${normalized_branches_csv%%,*}" + app_default_branch="${first_branch}" + fi + + if ! is_valid_predefined_app_branch "${app_default_branch}"; then + return 1 + fi + + if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then + return 1 + fi + + case "${seen_ids}" in + *,"${app_id}",*) + return 1 + ;; + esac + case "${seen_labels}" in + *,"${app_label}",*) + return 1 + ;; + esac + + seen_ids="${seen_ids}${app_id}," + seen_labels="${seen_labels}${app_label}," + + printf '%s|%s|%s|%s|%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" + done <"${catalog_path}" +} + +parse_predefined_app_catalog_entry() { + local entry="${1}" + local app_id_var="${2}" + local app_label_var="${3}" + local app_repo_var="${4}" + local app_default_branch_var="${5}" + local app_branches_csv_var="${6}" + local parsed_app_id="" + local parsed_app_label="" + local parsed_app_repo="" + local parsed_app_default_branch="" + local parsed_app_branches_csv="" + + IFS='|' read -r parsed_app_id parsed_app_label parsed_app_repo parsed_app_default_branch parsed_app_branches_csv <<<"${entry}" + printf -v "${app_id_var}" "%s" "${parsed_app_id}" + printf -v "${app_label_var}" "%s" "${parsed_app_label}" + printf -v "${app_repo_var}" "%s" "${parsed_app_repo}" + printf -v "${app_default_branch_var}" "%s" "${parsed_app_default_branch}" + printf -v "${app_branches_csv_var}" "%s" "${parsed_app_branches_csv}" +} + +get_predefined_app_field_by_field() { + local lookup_field="${1}" + local lookup_value="${2}" + local result_field="${3}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + local lookup_candidate="" + local result_value="" + + trim_predefined_catalog_field lookup_value "${lookup_value}" + if [ -z "${lookup_value}" ]; then + return 1 + fi + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + parse_predefined_app_catalog_entry "${entry}" app_id app_label app_repo app_default_branch app_branches_csv + + case "${lookup_field}" in + id) + lookup_candidate="${app_id}" + ;; + label) + lookup_candidate="${app_label}" + ;; + *) + return 1 + ;; + esac + + trim_predefined_catalog_field lookup_candidate "${lookup_candidate}" + if [ "${lookup_candidate}" != "${lookup_value}" ]; then + continue + fi + + case "${result_field}" in + id) + result_value="${app_id}" + ;; + label) + result_value="${app_label}" + ;; + repo) + result_value="${app_repo}" + ;; + default_branch) + result_value="${app_default_branch}" + ;; + branches_csv) + result_value="${app_branches_csv}" + ;; + *) + return 1 + ;; + esac + + printf '%s\n' "${result_value}" + return 0 + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +get_predefined_app_id_by_label() { + local label="${1}" + get_predefined_app_field_by_field "label" "${label}" "id" +} + +get_predefined_app_repo_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "repo" +} + +get_predefined_app_label_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "label" +} + +get_predefined_app_default_branch_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "default_branch" +} + +get_predefined_app_branch_lines_by_id() { + local result_var="${1}" + local app_id_lookup="${2}" + local app_branches_csv="" + local branch="" + local branch_lines="" + local -a branches=() + + app_branches_csv="$(get_predefined_app_field_by_field "id" "${app_id_lookup}" "branches_csv" || true)" + if [ -z "${app_branches_csv}" ]; then + return 1 + fi + + IFS=',' read -r -a branches <<<"${app_branches_csv}" + for branch in "${branches[@]}"; do + trim_predefined_catalog_field branch "${branch}" + if [ -z "${branch}" ]; then + continue + fi + if [ -z "${branch_lines}" ]; then + branch_lines="${branch}" + else + branch_lines="${branch_lines}"$'\n'"${branch}" + fi + done + + if [ -z "${branch_lines}" ]; then + return 1 + fi + + printf -v "${result_var}" "%s" "${branch_lines}" + return 0 +} + +predefined_app_catalog_has_id() { + local app_id_lookup="${1}" + + if [ -z "${app_id_lookup}" ]; then + return 1 + fi + + get_predefined_app_field_by_field "id" "${app_id_lookup}" "id" >/dev/null 2>&1 +} + +predefined_app_catalog_has_label() { + local app_label_lookup="${1}" + + if [ -z "${app_label_lookup}" ]; then + return 1 + fi + + get_predefined_app_field_by_field "label" "${app_label_lookup}" "label" >/dev/null 2>&1 +} + +append_predefined_app_catalog_entry() { + local app_id="${1}" + local app_label="${2}" + local app_repo="${3}" + local app_default_branch="${4}" + local app_branches_csv="${5}" + local normalized_branches_csv="" + local first_branch="" + local catalog_path="" + local catalog_tmp_path="" + local last_char="" + + if ! get_predefined_apps_catalog_entries >/dev/null 2>&1; then + return 1 + fi + + trim_predefined_catalog_field app_id "${app_id}" + trim_predefined_catalog_field app_label "${app_label}" + trim_predefined_catalog_field app_repo "${app_repo}" + trim_predefined_catalog_field app_default_branch "${app_default_branch}" + trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" + + if ! is_valid_predefined_app_id "${app_id}"; then + return 1 + fi + if [ -z "${app_label}" ]; then + return 1 + fi + if ! is_valid_predefined_app_repo "${app_repo}"; then + return 1 + fi + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + return 1 + fi + + if [ -z "${app_default_branch}" ]; then + first_branch="${normalized_branches_csv%%,*}" + app_default_branch="${first_branch}" + fi + if ! is_valid_predefined_app_branch "${app_default_branch}"; then + return 1 + fi + if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then + return 1 + fi + + if predefined_app_catalog_has_id "${app_id}"; then + return 1 + fi + if predefined_app_catalog_has_label "${app_label}"; then + return 1 + fi + + catalog_path="$(get_predefined_apps_catalog_path)" + catalog_tmp_path="${catalog_path}.tmp" + if [ ! -f "${catalog_path}" ]; then + return 1 + fi + + if ! cp -- "${catalog_path}" "${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if [ -s "${catalog_tmp_path}" ]; then + if command_exists tail; then + last_char="$(tail -c 1 "${catalog_tmp_path}" 2>/dev/null || true)" + if [ -n "${last_char}" ]; then + if ! printf '\n' >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + fi + else + if ! printf '\n' >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + fi + fi + + if ! printf '%s\t%s\t%s\t%s\t%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${catalog_tmp_path}" "${catalog_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh b/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh new file mode 100755 index 00000000..fdea5902 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash + +persist_stack_apps_json_content() { + local stack_dir="${1}" + local apps_json_content="${2}" + local apps_json_path="" + local apps_json_tmp_path="" + + apps_json_path="${stack_dir}/apps.json" + apps_json_tmp_path="${apps_json_path}.tmp" + + if ! printf '%s\n' "${apps_json_content}" >"${apps_json_tmp_path}"; then + rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${apps_json_tmp_path}" "${apps_json_path}"; then + rm -f -- "${apps_json_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +get_metadata_apps_predefined_csv() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if ! easy_docker_require_jq; then + return 1 + fi + + easy_docker_run_jq -r '(.apps.predefined // []) | join(",")' "${metadata_path}" +} + +get_metadata_apps_custom_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if ! easy_docker_require_jq; then + return 1 + fi + + easy_docker_run_jq -r '(.apps.custom // [])[]? | select(has("repo") and has("branch")) | "\(.repo)|\(.branch)"' "${metadata_path}" +} + +get_metadata_apps_predefined_branch_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if ! easy_docker_require_jq; then + return 1 + fi + + easy_docker_run_jq -r '(.apps.predefined_branches // {}) | to_entries[]? | "\(.key)|\(.value)"' "${metadata_path}" +} + +get_metadata_apps_predefined_branch_for_id() { + local metadata_path="${1}" + local app_id_lookup="${2}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if ! easy_docker_require_jq; then + return 1 + fi + + # shellcheck disable=SC2016 + easy_docker_run_jq -r --arg app_id "${app_id_lookup}" '.apps.predefined_branches[$app_id] // empty' "${metadata_path}" +} + +build_metadata_apps_json_object() { + local result_var="${1}" + local predefined_csv="${2}" + local branch_lines="${3}" + local custom_apps_lines="${4:-}" + local app_id="" + local app_branch="" + local custom_repo="" + local custom_branch="" + local predefined_json_entries="" + local branch_json_entries="" + local custom_json_entries="" + local escaped_app_id="" + local escaped_branch="" + local escaped_repo="" + local entry_json="" + local line="" + local -a predefined_ids=() + + if [ -n "${predefined_csv}" ]; then + IFS=',' read -r -a predefined_ids <<<"${predefined_csv}" + for app_id in "${predefined_ids[@]}"; do + if [ -z "${app_id}" ]; then + continue + fi + + escaped_app_id="$(json_escape_string "${app_id}")" + entry_json="$(printf ' "%s"' "${escaped_app_id}")" + if [ -z "${predefined_json_entries}" ]; then + predefined_json_entries="${entry_json}" + else + predefined_json_entries="${predefined_json_entries}"$',\n'"${entry_json}" + fi + done + fi + + while IFS= read -r line; do + if [ -z "${line}" ]; then + continue + fi + + app_id="${line%%|*}" + app_branch="${line#*|}" + if [ -z "${app_id}" ] || [ -z "${app_branch}" ]; then + continue + fi + + escaped_app_id="$(json_escape_string "${app_id}")" + escaped_branch="$(json_escape_string "${app_branch}")" + entry_json="$(printf ' "%s": "%s"' "${escaped_app_id}" "${escaped_branch}")" + if [ -z "${branch_json_entries}" ]; then + branch_json_entries="${entry_json}" + else + branch_json_entries="${branch_json_entries}"$',\n'"${entry_json}" + fi + done </dev/null 2>&1; then + return 1 + fi + + printf -v "${result_var}" "%s" "${rendered_metadata}" +} + +persist_stack_metadata_top_level_object() { + local stack_dir="${1}" + local object_key="${2}" + local object_json="${3}" + local insert_before_key="${4:-}" + local metadata_path="" + local metadata_tmp_path="" + local metadata_content="" + local existing_key="" + local inserted=0 + local -a existing_keys=() + local -a ordered_keys=() + + metadata_path="${stack_dir}/metadata.json" + metadata_tmp_path="${metadata_path}.tmp" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if [ -z "${object_json}" ]; then + return 1 + fi + + if ! easy_docker_require_jq; then + return 1 + fi + + if ! easy_docker_run_jq -e 'type == "object"' "${metadata_path}" >/dev/null 2>&1; then + return 1 + fi + + object_json="${object_json%$'\n'}" + if ! printf '%s\n' "${object_json}" | easy_docker_run_jq -e 'type == "object"' >/dev/null 2>&1; then + return 1 + fi + + mapfile -t existing_keys < <(easy_docker_run_jq -r 'keys_unsorted[]' "${metadata_path}") || return 1 + + for existing_key in "${existing_keys[@]}"; do + if [ "${existing_key}" = "${object_key}" ]; then + continue + fi + + if [ "${inserted}" -eq 0 ] && [ -n "${insert_before_key}" ] && [ "${existing_key}" = "${insert_before_key}" ]; then + ordered_keys+=("${object_key}") + inserted=1 + fi + + ordered_keys+=("${existing_key}") + done + + if [ "${inserted}" -eq 0 ]; then + ordered_keys+=("${object_key}") + fi + + build_stack_metadata_top_level_object_content metadata_content "${metadata_path}" "${object_key}" "${object_json}" "${ordered_keys[@]}" || return 1 + + if ! printf '%s' "${metadata_content}" >"${metadata_tmp_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +persist_stack_metadata_apps_object() { + local stack_dir="${1}" + local apps_json_object="${2}" + + persist_stack_metadata_top_level_object "${stack_dir}" "apps" "${apps_json_object}" "wizard" +} + +persist_stack_metadata_wizard_object() { + local stack_dir="${1}" + local wizard_json_object="${2}" + + persist_stack_metadata_top_level_object "${stack_dir}" "wizard" "${wizard_json_object}" +} diff --git a/scripts/easy-docker/lib/app/wizard/common/compose.sh b/scripts/easy-docker/lib/app/wizard/common/compose.sh new file mode 100755 index 00000000..9bfc7f56 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +EASY_DOCKER_BUILD_ERROR_DETAIL="" +# shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. +EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + +load_easy_docker_compose_modules() { + local compose_dir="" + compose_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/compose" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/render.sh + source "${compose_dir}/render.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start.sh + source "${compose_dir}/start.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/build.sh + source "${compose_dir}/build.sh" +} + +load_easy_docker_compose_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/build.sh b/scripts/easy-docker/lib/app/wizard/common/compose/build.sh new file mode 100755 index 00000000..0fa2dd06 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/build.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +build_stack_custom_image() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local apps_json_path="" + local custom_image="" + local custom_tag="" + local frappe_branch="" + local frappe_path="https://github.com/frappe/frappe" + local repo_root="" + local containerfile_path="" + local apps_refs_lines="" + local app_ref_line="" + local app_url="" + local app_branch="" + local git_error="" + local image_ref="" + + EASY_DOCKER_BUILD_ERROR_DETAIL="" + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + apps_json_path="${stack_dir}/apps.json" + + if [ ! -f "${metadata_path}" ]; then + return 11 + fi + if [ ! -f "${env_path}" ]; then + return 12 + fi + if ! easy_docker_require_jq; then + return 25 + fi + + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + frappe_branch="$(get_stack_frappe_branch "${stack_dir}" || true)" + if [ -z "${custom_image}" ]; then + return 13 + fi + if [ -z "${custom_tag}" ]; then + return 14 + fi + if [ -z "${frappe_branch}" ]; then + return 15 + fi + + # Keep apps.json aligned with current metadata app selection before build. + if ! persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then + return 16 + fi + if [ ! -f "${apps_json_path}" ]; then + return 17 + fi + + if ! command_exists git; then + return 22 + fi + + apps_refs_lines="$( + easy_docker_run_jq -r '.[]? | select((.url // "") != "" and (.branch // "") != "") | "\(.url)|\(.branch)"' "${apps_json_path}" + )" || return 23 + if [ -z "${apps_refs_lines}" ]; then + return 23 + fi + + while IFS= read -r app_ref_line; do + if [ -z "${app_ref_line}" ]; then + continue + fi + + app_url="${app_ref_line%%|*}" + app_branch="${app_ref_line#*|}" + if [ -z "${app_url}" ] || [ -z "${app_branch}" ]; then + continue + fi + + if git_error="$(git ls-remote --exit-code --heads "${app_url}" "${app_branch}" 2>&1)"; then + : + else + # shellcheck disable=SC2034 # Read by manage flow after build_stack_custom_image returns 24. + EASY_DOCKER_BUILD_ERROR_DETAIL="$(printf '%s@%s :: %s' "${app_url}" "${app_branch}" "${git_error}")" + return 24 + fi + done <"${generated_compose_tmp_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" config >"${generated_compose_tmp_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${generated_compose_tmp_path}" "${generated_compose_path}"; then + rm -f -- "${generated_compose_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh new file mode 100755 index 00000000..c1aa6643 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash + +start_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local compose_project_name="" + local fallback_erpnext_version="" + local configured_pull_policy="" + local runtime_pull_policy="" + local custom_image="" + local custom_tag="" + local image_ref="" + local image_inspect_error="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name + + if [ ! -f "${metadata_path}" ]; then + return 31 + fi + + if [ ! -f "${env_path}" ]; then + return 32 + fi + + if ! easy_docker_compose_require_supported_topology "${stack_dir}" 33 34; then + return $? + fi + + easy_docker_compose_get_fallback_erpnext_version fallback_erpnext_version "${env_path}" + + configured_pull_policy="$(get_env_file_key_value "${env_path}" "PULL_POLICY" || true)" + if [ -z "${configured_pull_policy}" ]; then + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + image_ref="${custom_image}:${custom_tag}" + if image_inspect_error="$(docker image inspect "${image_ref}" 2>&1 >/dev/null)"; then + runtime_pull_policy="if_not_present" + else + case "${image_inspect_error}" in + *"No such image"* | *"No such object"*) + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 38. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_ref}" + return 38 + ;; + *) + if [ -z "${image_inspect_error}" ]; then + image_inspect_error="docker image inspect failed for ${image_ref}" + fi + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 39. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_inspect_error}" + return 39 + ;; + esac + fi + fi + fi + + if ! easy_docker_compose_collect_args compose_args "${metadata_path}" 35 36; then + return $? + fi + + if [ -n "${fallback_erpnext_version}" ] && [ -n "${runtime_pull_policy}" ]; then + if ! ERPNEXT_VERSION="${fallback_erpnext_version}" PULL_POLICY="${runtime_pull_policy}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then + return 37 + fi + elif [ -n "${fallback_erpnext_version}" ]; then + if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then + return 37 + fi + elif [ -n "${runtime_pull_policy}" ]; then + if ! PULL_POLICY="${runtime_pull_policy}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then + return 37 + fi + elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" up -d; then + return 37 + fi + + return 0 +} + +stop_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local compose_project_name="" + local fallback_erpnext_version="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name + + if [ ! -f "${metadata_path}" ]; then + return 41 + fi + + if [ ! -f "${env_path}" ]; then + return 42 + fi + + if ! easy_docker_compose_require_supported_topology "${stack_dir}" 43 44; then + return $? + fi + + easy_docker_compose_get_fallback_erpnext_version fallback_erpnext_version "${env_path}" + + if ! easy_docker_compose_collect_args compose_args "${metadata_path}" 45 46; then + return $? + fi + + if [ -n "${fallback_erpnext_version}" ]; then + if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" stop; then + return 47 + fi + elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" stop; then + return 47 + fi + + return 0 +} + +delete_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local compose_project_name="" + local fallback_erpnext_version="" + local custom_image="" + local custom_tag="" + local custom_image_ref="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name + + if [ ! -f "${metadata_path}" ]; then + return 48 + fi + + if [ ! -f "${env_path}" ]; then + return 49 + fi + + if ! easy_docker_compose_require_supported_topology "${stack_dir}" 50 51; then + return $? + fi + + easy_docker_compose_get_fallback_erpnext_version fallback_erpnext_version "${env_path}" + + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + custom_image_ref="${custom_image}:${custom_tag}" + fi + + if ! easy_docker_compose_collect_args compose_args "${metadata_path}" 52 53; then + return $? + fi + + if [ -n "${fallback_erpnext_version}" ]; then + if ! ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" down -v --remove-orphans --rmi local; then + return 54 + fi + elif ! docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" down -v --remove-orphans --rmi local; then + return 54 + fi + + if [ -n "${custom_image_ref}" ]; then + if docker image inspect "${custom_image_ref}" >/dev/null 2>&1; then + if ! docker image rm "${custom_image_ref}"; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${custom_image_ref}" + return 55 + fi + fi + fi + + if ! rollback_stack_directory "${stack_dir}"; then + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata returns 56. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}" + return 56 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh new file mode 100755 index 00000000..5799614d --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +easy_docker_compose_init_context() { + local stack_dir="${1}" + local metadata_var="${2}" + local env_var="${3}" + local project_var="${4}" + local metadata_path="" + local env_path="" + local compose_project_name="" + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + compose_project_name="$(get_stack_compose_project_name "${stack_dir}")" + + printf -v "${metadata_var}" "%s" "${metadata_path}" + printf -v "${env_var}" "%s" "${env_path}" + printf -v "${project_var}" "%s" "${compose_project_name}" +} + +easy_docker_compose_get_fallback_erpnext_version() { + local result_var="${1}" + local env_path="${2}" + local env_erpnext_version="" + local fallback_erpnext_version="" + + env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" + if [ -z "${env_erpnext_version}" ]; then + fallback_erpnext_version="$(get_default_erpnext_version || true)" + fi + + printf -v "${result_var}" "%s" "${fallback_erpnext_version}" +} + +easy_docker_compose_require_supported_topology() { + local stack_dir="${1}" + local missing_topology_code="${2}" + local unsupported_topology_code="${3}" + local stack_topology="" + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + # shellcheck disable=SC2034 # Read by callers after topology resolution fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" + return "${missing_topology_code}" + fi + + case "${stack_topology}" in + "single-host" | "split-services") + return 0 + ;; + *) + # shellcheck disable=SC2034 # Read by callers after unsupported topology is returned. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" + return "${unsupported_topology_code}" + ;; + esac +} + +easy_docker_compose_collect_args() { + local result_array_name="${1}" + local metadata_path="${2}" + local missing_compose_code="${3}" + local missing_file_code="${4}" + local compose_files_lines="" + local compose_file="" + local source_compose_path="" + local repo_root="" + local -n compose_args_ref="${result_array_name}" + + compose_args_ref=() + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return "${missing_compose_code}" + fi + + repo_root="$(get_easy_docker_repo_root)" + while IFS= read -r compose_file; do + if [ -z "${compose_file}" ]; then + continue + fi + + source_compose_path="${repo_root}/${compose_file}" + if [ ! -f "${source_compose_path}" ]; then + # shellcheck disable=SC2034 # Read by callers after compose file resolution fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" + return "${missing_file_code}" + fi + + compose_args_ref+=(-f "${source_compose_path}") + done </dev/null + )" + compose_status=$? + else + container_ids_lines="$( + docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null + )" + compose_status=$? + fi + + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker compose status failed)" + return 0 + fi + + if [ -z "${container_ids_lines}" ]; then + printf -v "${result_var}" "%s" "Not created" + return 0 + fi + + docker_ps_args=(-a --no-trunc --format "{{.ID}}|{{.State}}|{{.Status}}") + while IFS= read -r container_id; do + if [ -n "${container_id}" ]; then + docker_ps_args+=(--filter "id=${container_id}") + fi + done </dev/null)" + compose_status=$? + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker ps status failed)" + return 0 + fi + + while IFS= read -r container_status_line; do + if [ -z "${container_status_line}" ]; then + continue + fi + + total_containers_count=$((total_containers_count + 1)) + IFS='|' read -r container_id container_state container_status_text </dev/null || true + )" + if [ -n "${remaining_entry}" ]; then + return 1 + fi + + return 0 +} + +delete_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local compose_files_lines="" + local compose_file="" + local source_compose_path="" + local env_erpnext_version="" + local fallback_erpnext_version="" + local compose_project_name="" + local stack_topology="" + local repo_root="" + local custom_image="" + local custom_tag="" + local custom_image_ref="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + compose_project_name="$(get_stack_compose_project_name "${stack_dir}")" + + if [ ! -f "${metadata_path}" ]; then + return 48 + fi + + if [ ! -f "${env_path}" ]; then + if stack_directory_contains_only_metadata "${stack_dir}" "${metadata_path}"; then + if ! rollback_stack_directory "${stack_dir}"; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}" + return 56 + fi + return 0 + fi + return 49 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" + return 50 + fi + + case "${stack_topology}" in + "single-host" | "split-services") ;; + *) + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" + return 51 + ;; + esac + + env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" + if [ -z "${env_erpnext_version}" ]; then + fallback_erpnext_version="$(get_default_erpnext_version || true)" + fi + + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + custom_image_ref="${custom_image}:${custom_tag}" + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 52 + fi + + repo_root="$(get_easy_docker_repo_root)" + while IFS= read -r compose_file; do + if [ -z "${compose_file}" ]; then + continue + fi + + source_compose_path="${repo_root}/${compose_file}" + if [ ! -f "${source_compose_path}" ]; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" + return 53 + fi + + compose_args+=(-f "${source_compose_path}") + done </dev/null 2>&1; then + if ! docker image rm "${custom_image_ref}"; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${custom_image_ref}" + return 55 + fi + fi + fi + + if ! rollback_stack_directory "${stack_dir}"; then + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata returns 56. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}" + return 56 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh new file mode 100755 index 00000000..a4702733 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start/restart.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +restart_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local stop_status=0 + local start_status=0 + + # shellcheck disable=SC2034 # Read by manage flow after restart_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + if stop_stack_with_compose_from_metadata "${stack_dir}"; then + : + else + stop_status=$? + case "${stop_status}" in + 41) + return 57 + ;; + 42) + return 58 + ;; + 43) + return 59 + ;; + 44) + return 60 + ;; + 45) + return 61 + ;; + 46) + return 62 + ;; + *) + return 63 + ;; + esac + fi + + if start_stack_with_compose_from_metadata "${stack_dir}"; then + return 0 + fi + + start_status=$? + case "${start_status}" in + 31) + return 57 + ;; + 32) + return 58 + ;; + 33) + return 59 + ;; + 34) + return 60 + ;; + 35) + return 61 + ;; + 36) + return 62 + ;; + 38) + return 64 + ;; + 39) + return 65 + ;; + *) + return 63 + ;; + esac +} diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh new file mode 100755 index 00000000..004143f4 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +start_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local compose_files_lines="" + local compose_file="" + local source_compose_path="" + local env_erpnext_version="" + local fallback_erpnext_version="" + local configured_pull_policy="" + local runtime_pull_policy="" + local custom_image="" + local custom_tag="" + local image_ref="" + local image_inspect_error="" + local compose_project_name="" + local stack_topology="" + local repo_root="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + compose_project_name="$(get_stack_compose_project_name "${stack_dir}")" + + if [ ! -f "${metadata_path}" ]; then + return 31 + fi + + if [ ! -f "${env_path}" ]; then + return 32 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 33. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" + return 33 + fi + + case "${stack_topology}" in + "single-host" | "split-services") ;; + *) + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 34. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" + return 34 + ;; + esac + + env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" + if [ -z "${env_erpnext_version}" ]; then + fallback_erpnext_version="$(get_default_erpnext_version || true)" + fi + + configured_pull_policy="$(get_env_file_key_value "${env_path}" "PULL_POLICY" || true)" + if [ -z "${configured_pull_policy}" ]; then + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + image_ref="${custom_image}:${custom_tag}" + if image_inspect_error="$(docker image inspect "${image_ref}" 2>&1 >/dev/null)"; then + runtime_pull_policy="if_not_present" + else + case "${image_inspect_error}" in + *"No such image"* | *"No such object"*) + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 38. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_ref}" + return 38 + ;; + *) + if [ -z "${image_inspect_error}" ]; then + image_inspect_error="docker image inspect failed for ${image_ref}" + fi + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 39. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_inspect_error}" + return 39 + ;; + esac + fi + fi + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 35 + fi + + repo_root="$(get_easy_docker_repo_root)" + while IFS= read -r compose_file; do + if [ -z "${compose_file}" ]; then + continue + fi + + source_compose_path="${repo_root}/${compose_file}" + if [ ! -f "${source_compose_path}" ]; then + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 36. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" + return 36 + fi + + compose_args+=(-f "${source_compose_path}") + done </dev/null + )" + compose_status=$? + else + container_ids_lines="$( + docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null + )" + compose_status=$? + fi + + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker compose status failed)" + return 0 + fi + + if [ -z "${container_ids_lines}" ]; then + printf -v "${result_var}" "%s" "Not created" + return 0 + fi + + docker_ps_args=(-a --no-trunc --format "{{.ID}}|{{.State}}|{{.Status}}") + while IFS= read -r container_id; do + if [ -n "${container_id}" ]; then + docker_ps_args+=(--filter "id=${container_id}") + fi + done </dev/null)" + compose_status=$? + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker ps status failed)" + return 0 + fi + + while IFS= read -r container_status_line; do + if [ -z "${container_status_line}" ]; then + continue + fi + + total_containers_count=$((total_containers_count + 1)) + IFS='|' read -r container_id container_state container_status_text </dev/null || date +"%Y-%m-%dT%H:%M:%SZ" +} + +is_valid_stack_name() { + local stack_name="${1}" + + if [ -z "${stack_name}" ]; then + return 1 + fi + + case "${stack_name}" in + *[!A-Za-z0-9._-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +create_stack_directory_with_metadata() { + local stack_dir_var="${1}" + local stack_name="${2}" + local setup_type="${3:-production}" + local frappe_branch="${4:-}" + local stacks_dir="" + local created_stack_dir="" + local metadata_path="" + local created_at="" + + stacks_dir="$(get_easy_docker_stacks_dir)" + created_stack_dir="${stacks_dir}/${stack_name}" + metadata_path="${created_stack_dir}/metadata.json" + + if ! mkdir -p "${stacks_dir}"; then + return 1 + fi + + if [ -e "${created_stack_dir}" ]; then + return 2 + fi + + if [ -z "${frappe_branch}" ]; then + return 1 + fi + + if ! mkdir -p "${created_stack_dir}"; then + return 1 + fi + + created_at="$(get_current_utc_timestamp)" + + if ! cat >"${metadata_path}" </dev/null 2>&1 || true + return 1 + fi + + printf -v "${stack_dir_var}" "%s" "${created_stack_dir}" + return 0 +} + +rollback_stack_directory() { + local stack_dir="${1}" + local stacks_dir="" + + if [ -z "${stack_dir}" ]; then + return 1 + fi + + stacks_dir="$(get_easy_docker_stacks_dir)" + case "${stack_dir}" in + "${stacks_dir}"/*) ;; + *) + return 2 + ;; + esac + + if [ ! -d "${stack_dir}" ]; then + return 0 + fi + + rm -rf -- "${stack_dir}" +} + +get_metadata_string_field() { + local metadata_path="${1}" + local field_name="${2}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if ! easy_docker_require_jq; then + return 1 + fi + + # shellcheck disable=SC2016 + easy_docker_run_jq -r --arg field_name "${field_name}" '[.. | objects | .[$field_name]? | select(type == "string")][0] // empty' "${metadata_path}" +} + +get_env_file_key_value() { + local env_file="${1}" + local key="${2}" + local value="" + + if [ ! -f "${env_file}" ]; then + return 1 + fi + + value="$( + awk -v key="${key}" ' + /^[[:space:]]*#/ { next } + $0 !~ /=/ { next } + { + line = $0 + sub(/\r$/, "", line) + pos = index(line, "=") + if (pos == 0) { + next + } + k = substr(line, 1, pos - 1) + sub(/^[[:space:]]*export[[:space:]]+/, "", k) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", k) + if (k != key) { + next + } + v = substr(line, pos + 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", v) + print v + exit + } + ' "${env_file}" + )" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + \"*\") + value="${value#\"}" + value="${value%\"}" + ;; + \'*\') + value="${value#\'}" + value="${value%\'}" + ;; + esac + + printf '%s\n' "${value}" +} + +get_default_erpnext_version() { + local repo_root="" + local source_env_file="" + local value="" + + if [ -n "${ERPNEXT_VERSION:-}" ]; then + printf '%s\n' "${ERPNEXT_VERSION}" + return 0 + fi + + repo_root="$(get_easy_docker_repo_root)" + for source_env_file in "${repo_root}/.env" "${repo_root}/example.env"; do + value="$(get_env_file_key_value "${source_env_file}" "ERPNEXT_VERSION" || true)" + if [ -n "${value}" ]; then + printf '%s\n' "${value}" + return 0 + fi + done + + return 1 +} + +get_default_frappe_branch() { + local repo_root="" + local source_env_file="" + local value="" + + if [ -n "${FRAPPE_BRANCH:-}" ]; then + printf '%s\n' "${FRAPPE_BRANCH}" + return 0 + fi + + repo_root="$(get_easy_docker_repo_root)" + for source_env_file in "${repo_root}/.env" "${repo_root}/example.env"; do + value="$(get_env_file_key_value "${source_env_file}" "FRAPPE_BRANCH" || true)" + if [ -n "${value}" ]; then + printf '%s\n' "${value}" + return 0 + fi + done + + printf 'version-15\n' + return 0 +} + +get_stack_frappe_branch() { + local stack_dir="${1}" + local metadata_path="" + local value="" + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + value="$(get_metadata_string_field "${metadata_path}" "frappe_branch" || true)" + if [ -z "${value}" ]; then + return 1 + fi + + printf '%s\n' "${value}" + return 0 +} + +get_stack_env_path() { + local stack_dir="${1}" + local metadata_path="" + local stack_name="" + + metadata_path="${stack_dir}/metadata.json" + stack_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)" + if [ -z "${stack_name}" ]; then + stack_name="${stack_dir##*/}" + fi + + printf '%s/%s.env\n' "${stack_dir}" "${stack_name}" +} + +get_stack_compose_project_name() { + local stack_dir="${1}" + local metadata_path="" + local stack_name="" + local project_name="" + + metadata_path="${stack_dir}/metadata.json" + stack_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)" + if [ -z "${stack_name}" ]; then + stack_name="${stack_dir##*/}" + fi + + project_name="$( + printf '%s' "${stack_name}" | + tr '[:upper:]' '[:lower:]' | + sed 's/[^a-z0-9_-]/-/g; s/--*/-/g; s/^-*//; s/-*$//' + )" + if [ -z "${project_name}" ]; then + project_name="stack" + fi + + printf 'easydocker-%s\n' "${project_name}" +} + +get_stack_generated_compose_path() { + local stack_dir="${1}" + + printf '%s/compose.generated.yaml\n' "${stack_dir}" +} + +get_stack_dir_by_name() { + local stack_name="${1}" + local stacks_dir="" + local stack_dir="" + local metadata_path="" + local candidate_name="" + + stacks_dir="$(get_easy_docker_stacks_dir)" + if [ ! -d "${stacks_dir}" ]; then + return 1 + fi + + for stack_dir in "${stacks_dir}"/*; do + if [ ! -d "${stack_dir}" ]; then + continue + fi + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + continue + fi + + candidate_name="$(get_metadata_string_field "${metadata_path}" "stack_name" || true)" + if [ "${candidate_name}" = "${stack_name}" ]; then + printf '%s\n' "${stack_dir}" + return 0 + fi + done + + return 1 +} + +get_stack_topology() { + local stack_dir="${1}" + local metadata_path="" + local topology="" + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + topology="$(get_metadata_string_field "${metadata_path}" "topology" || true)" + if [ -z "${topology}" ]; then + return 1 + fi + + printf '%s\n' "${topology}" + return 0 +} + +get_stack_setup_type() { + local stack_dir="${1}" + local metadata_path="" + local setup_type="" + + metadata_path="${stack_dir}/metadata.json" + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + setup_type="$(get_metadata_string_field "${metadata_path}" "setup_type" || true)" + if [ -z "${setup_type}" ]; then + return 1 + fi + + printf '%s\n' "${setup_type}" + return 0 +} + +get_metadata_compose_files_lines() { + local metadata_path="${1}" + + if [ ! -f "${metadata_path}" ]; then + return 1 + fi + + if ! easy_docker_require_jq; then + return 1 + fi + + easy_docker_run_jq -r '([.. | objects | .compose_files? | select(type == "array")] | .[0] // [])[]?' "${metadata_path}" +} diff --git a/scripts/easy-docker/lib/app/wizard/common/frappe.sh b/scripts/easy-docker/lib/app/wizard/common/frappe.sh new file mode 100755 index 00000000..30866402 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/frappe.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash + +trim_frappe_catalog_field() { + local result_var="${1}" + local value="${2}" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + printf -v "${result_var}" "%s" "${value}" +} + +is_valid_frappe_catalog_id() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!a-z0-9._-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +is_valid_frappe_branch_name() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!A-Za-z0-9._/-]* | .* | *..* | */ | /*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +get_frappe_versions_catalog_path() { + local repo_root="" + + repo_root="$(get_easy_docker_repo_root)" + printf '%s/scripts/easy-docker/config/frappe.tsv\n' "${repo_root}" +} + +get_frappe_versions_catalog_entries() { + local catalog_path="" + local raw_line="" + local line="" + local version_id="" + local version_label="" + local frappe_branch="" + local extra="" + local seen_ids="," + local seen_labels="," + + catalog_path="$(get_frappe_versions_catalog_path)" + if [ ! -f "${catalog_path}" ]; then + return 1 + fi + + while IFS= read -r raw_line || [ -n "${raw_line}" ]; do + trim_frappe_catalog_field line "${raw_line}" + if [ -z "${line}" ]; then + continue + fi + + case "${line}" in + \#*) + continue + ;; + esac + + if [[ "${line}" == *$'\t'* ]]; then + IFS=$'\t' read -r version_id version_label frappe_branch extra <<<"${line}" + else + IFS='|' read -r version_id version_label frappe_branch extra <<<"${line}" + fi + + trim_frappe_catalog_field version_id "${version_id}" + trim_frappe_catalog_field version_label "${version_label}" + trim_frappe_catalog_field frappe_branch "${frappe_branch}" + trim_frappe_catalog_field extra "${extra}" + + if [ -n "${extra}" ] || [ -z "${version_id}" ] || [ -z "${version_label}" ] || [ -z "${frappe_branch}" ]; then + return 1 + fi + + if ! is_valid_frappe_catalog_id "${version_id}"; then + return 1 + fi + + if ! is_valid_frappe_branch_name "${frappe_branch}"; then + return 1 + fi + + case "${seen_ids}" in + *,"${version_id}",*) + return 1 + ;; + esac + case "${seen_labels}" in + *,"${version_label}",*) + return 1 + ;; + esac + + seen_ids="${seen_ids}${version_id}," + seen_labels="${seen_labels}${version_label}," + + printf '%s|%s|%s\n' "${version_id}" "${version_label}" "${frappe_branch}" + done <"${catalog_path}" +} + +get_frappe_version_branch_by_label() { + local label_lookup="${1}" + local entry="" + local version_id="" + local version_label="" + local frappe_branch="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r version_id version_label frappe_branch <<<"${entry}" + if [ "${version_label}" = "${label_lookup}" ]; then + printf '%s\n' "${frappe_branch}" + return 0 + fi + done < <(get_frappe_versions_catalog_entries) + + return 1 +} + +get_frappe_version_label_by_branch() { + local branch_lookup="${1}" + local entry="" + local version_id="" + local version_label="" + local frappe_branch="" + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + IFS='|' read -r version_id version_label frappe_branch <<<"${entry}" + if [ "${frappe_branch}" = "${branch_lookup}" ]; then + printf '%s\n' "${version_label}" + return 0 + fi + done < <(get_frappe_versions_catalog_entries) + + return 1 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/helpers.sh b/scripts/easy-docker/lib/app/wizard/common/helpers.sh new file mode 100755 index 00000000..a55cf4d4 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/helpers.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +json_escape_string() { + local raw_value="${1}" + + raw_value="${raw_value//\\/\\\\}" + raw_value="${raw_value//\"/\\\"}" + raw_value="${raw_value//$'\n'/\\n}" + raw_value="${raw_value//$'\r'/\\r}" + raw_value="${raw_value//$'\t'/\\t}" + + printf '%s' "${raw_value}" +} + +build_compose_files_json_array() { + local compose_files_lines="${1}" + local line="" + local first=1 + + while IFS= read -r line; do + if [ -z "${line}" ]; then + continue + fi + + if [ "${first}" -eq 1 ]; then + printf ' "%s"' "${line}" + first=0 + else + printf ',\n "%s"' "${line}" + fi + done </dev/null 2>&1 +} + +remove_stack_site_app_line() { + local result_var="${1}" + local existing_lines="${2:-}" + local app_name="${3:-}" + local existing_app="" + local remaining_lines="" + + while IFS= read -r existing_app; do + if [ -z "${existing_app}" ] || [ "${existing_app}" = "${app_name}" ]; then + continue + fi + + if [ -z "${remaining_lines}" ]; then + remaining_lines="${existing_app}" + else + remaining_lines="${remaining_lines}"$'\n'"${existing_app}" + fi + done </dev/null 2>&1 || true + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${current_installed_app_lines}" \ + "install-app" \ + "${EASY_DOCKER_SITE_ERROR_DETAIL}" \ + "${EASY_DOCKER_SITE_ERROR_LOG_PATH}"; then + return 89 + fi + case "${command_status}" in + 54) + return 84 + ;; + 52) + return 82 + ;; + *) + return 88 + ;; + esac + fi + + if ! get_stack_site_managed_runtime_app_lines updated_installed_app_lines "${stack_dir}" "${site_name}"; then + updated_installed_app_lines="${current_installed_app_lines}" + append_stack_installable_app_line updated_installed_app_lines "${updated_installed_app_lines}" "${app_name}" + fi + + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${updated_installed_app_lines}" \ + "install-app" \ + "" \ + ""; then + return 89 + fi + + return 0 +} + +uninstall_app_from_configured_stack_site() { + local stack_dir="${1}" + local app_name="${2:-}" + local site_name="" + local uninstallable_app_lines="" + local current_installed_app_lines="" + local updated_installed_app_lines="" + local uninstall_command="" + local uninstall_output="" + local command_status=0 + local uninstallable_status=0 + + reset_easy_docker_site_error_state + + if [ -z "${app_name}" ] || [ "${app_name}" = "frappe" ]; then + return 96 + fi + + if get_configured_stack_site_uninstallable_app_lines uninstallable_app_lines "${stack_dir}"; then + : + else + uninstallable_status=$? + return "${uninstallable_status}" + fi + + if [ -z "${uninstallable_app_lines}" ]; then + return 95 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + current_installed_app_lines="${uninstallable_app_lines}" + + if ! stack_site_app_lines_contain "${uninstallable_app_lines}" "${app_name}"; then + return 96 + fi + + uninstall_command="$( + printf "bench --site %s uninstall-app %s --yes" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${app_name}")" + )" + + if run_stack_backend_bash_command_capture uninstall_output "${stack_dir}" "${uninstall_command}"; then + : + else + command_status=$? + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench uninstall-app failed for '%s'." "${app_name}")" + capture_stack_site_error_log "${stack_dir}" "site-uninstall-app-error" "${uninstall_output}" >/dev/null 2>&1 || true + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${current_installed_app_lines}" \ + "uninstall-app" \ + "${EASY_DOCKER_SITE_ERROR_DETAIL}" \ + "${EASY_DOCKER_SITE_ERROR_LOG_PATH}"; then + return 99 + fi + case "${command_status}" in + 54) + return 94 + ;; + 52) + return 92 + ;; + *) + return 98 + ;; + esac + fi + + if ! get_stack_site_managed_runtime_app_lines updated_installed_app_lines "${stack_dir}" "${site_name}"; then + remove_stack_site_app_line updated_installed_app_lines "${current_installed_app_lines}" "${app_name}" + fi + + if ! persist_stack_site_app_operation_metadata \ + "${stack_dir}" \ + "${site_name}" \ + "${updated_installed_app_lines}" \ + "uninstall-app" \ + "" \ + ""; then + return 99 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/backup.sh b/scripts/easy-docker/lib/app/wizard/common/site/backup.sh new file mode 100755 index 00000000..7c7b323e --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/backup.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +load_easy_docker_site_backup_modules() { + local backup_dir="" + backup_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/backup" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh + source "${backup_dir}/lifecycle.sh" +} + +load_easy_docker_site_backup_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh new file mode 100755 index 00000000..9ae137c4 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/backup/lifecycle.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +backup_configured_stack_site() { + local stack_dir="${1}" + local site_name="" + local backup_command="" + local backup_output="" + local command_status=0 + local created_at="" + local updated_at="" + local apps_installed_lines="" + local last_backup_at="" + local existing_last_backup_at="" + local backend_status=0 + + reset_easy_docker_site_error_state + + if ! stack_supports_single_site_management "${stack_dir}"; then + return 72 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 73 + fi + + if stack_backend_service_is_running "${stack_dir}"; then + : + else + backend_status=$? + case "${backend_status}" in + 54) + return 74 + ;; + 52) + return 72 + ;; + *) + return 71 + ;; + esac + fi + + created_at="$(get_stack_site_created_at "${stack_dir}" || true)" + apps_installed_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)" + existing_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)" + + backup_command="$( + printf "bench --site %s backup --with-files" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + if run_stack_backend_bash_command_capture backup_output "${stack_dir}" "${backup_command}"; then + : + else + command_status=$? + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench backup failed for '%s'." "${site_name}")" + capture_stack_site_error_log "${stack_dir}" "site-backup-error" "${backup_output}" >/dev/null 2>&1 || true + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "${apps_installed_lines}" \ + "backup-site" \ + "${EASY_DOCKER_SITE_ERROR_DETAIL}" \ + "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" \ + "${created_at}" \ + "${updated_at}" \ + "${existing_last_backup_at}"; then + return 76 + fi + case "${command_status}" in + 54) + return 74 + ;; + 52) + return 72 + ;; + *) + return 75 + ;; + esac + fi + + last_backup_at="$(get_current_utc_timestamp)" + updated_at="${last_backup_at}" + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "${apps_installed_lines}" \ + "backup-site" \ + "" \ + "" \ + "${created_at}" \ + "${updated_at}" \ + "${last_backup_at}"; then + return 76 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh new file mode 100755 index 00000000..a40d578f --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +load_easy_docker_site_bootstrap_modules() { + local bootstrap_dir="" + bootstrap_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bootstrap" + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap/validation.sh + source "${bootstrap_dir}/validation.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh + source "${bootstrap_dir}/runtime.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh + source "${bootstrap_dir}/errors.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh + source "${bootstrap_dir}/state.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh + source "${bootstrap_dir}/lifecycle.sh" +} + +load_easy_docker_site_bootstrap_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh new file mode 100755 index 00000000..2b08c2b8 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +reset_easy_docker_site_error_state() { + EASY_DOCKER_SITE_ERROR_DETAIL="" + EASY_DOCKER_SITE_ERROR_LOG_PATH="" +} + +build_stack_site_error_log_relative_path() { + local result_var="${1}" + local action_name="${2:-site-error}" + local raw_timestamp="" + local safe_timestamp="" + local relative_path="" + + raw_timestamp="$(get_current_utc_timestamp)" + safe_timestamp="$(printf '%s' "${raw_timestamp}" | tr ':' '-')" + relative_path="$(printf 'logs/%s-%s.log' "${action_name}" "${safe_timestamp}")" + printf -v "${result_var}" "%s" "${relative_path}" +} + +write_stack_site_error_log() { + local result_var="${1}" + local stack_dir="${2}" + local action_name="${3:-site-error}" + local error_output="${4:-}" + local relative_path="" + local log_dir="" + local absolute_path="" + + if [ -z "${error_output}" ]; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + build_stack_site_error_log_relative_path relative_path "${action_name}" + log_dir="${stack_dir}/logs" + absolute_path="${stack_dir}/${relative_path}" + + if ! mkdir -p "${log_dir}"; then + return 1 + fi + + if ! printf '%s\n' "${error_output}" >"${absolute_path}"; then + return 1 + fi + + printf -v "${result_var}" "%s" "${relative_path}" + return 0 +} + +run_stack_backend_bash_command_capture() { + local result_var="${1}" + local stack_dir="${2}" + local backend_command="${3}" + local command_output="" + local command_status=0 + + reset_easy_docker_site_error_state + command_output="$(run_stack_backend_bash_command "${stack_dir}" "${backend_command}" 2>&1)" + command_status=$? + + if [ -n "${command_output}" ]; then + printf '%s\n' "${command_output}" + fi + + printf -v "${result_var}" "%s" "${command_output}" + return "${command_status}" +} + +capture_stack_site_error_log() { + local stack_dir="${1}" + local action_name="${2:-site-error}" + local error_output="${3:-}" + local log_path="" + + EASY_DOCKER_SITE_ERROR_LOG_PATH="" + if [ -z "${error_output}" ]; then + return 0 + fi + + if ! write_stack_site_error_log log_path "${stack_dir}" "${action_name}" "${error_output}"; then + EASY_DOCKER_SITE_ERROR_DETAIL="${EASY_DOCKER_SITE_ERROR_DETAIL:-Failed to write site error log.}" + return 1 + fi + + # shellcheck disable=SC2034 # Read by manage flow after site bootstrap failures. + EASY_DOCKER_SITE_ERROR_LOG_PATH="${log_path}" + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh new file mode 100755 index 00000000..0fedd62b --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh @@ -0,0 +1,536 @@ +#!/usr/bin/env bash + +drop_stack_site_database() { + local stack_dir="${1}" + local site_name="${2}" + local db_password="" + local db_root_username="" + local drop_db_command="" + local drop_db_output="" + local db_drop_status=0 + + db_password="$(get_stack_database_root_password "${stack_dir}")" + db_root_username="$(get_stack_database_root_username "${stack_dir}" || true)" + if [ -z "${db_root_username}" ]; then + return 1 + fi + + drop_db_command="$( + printf "bench drop-site %s --no-backup --force --db-root-username %s --db-root-password %s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${db_root_username}")" \ + "$(shell_quote_site_command_arg "${db_password}")" + )" + + if run_stack_backend_bash_command_capture drop_db_output "${stack_dir}" "${drop_db_command}"; then + return 0 + else + db_drop_status=$? + fi + + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench drop-site failed for '%s'." "${site_name}")" + capture_stack_site_error_log "${stack_dir}" "site-delete-error" "${drop_db_output}" >/dev/null 2>&1 || true + return "${db_drop_status}" +} + +remove_stack_site_directory() { + local stack_dir="${1}" + local site_name="${2}" + local remove_command="" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + remove_command="$( + printf "rm -rf -- sites/%s archived_sites/%s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + if ! run_stack_backend_bash_command "${stack_dir}" "${remove_command}"; then + return 1 + fi + + return 0 +} + +cleanup_partial_stack_site() { + local stack_dir="${1}" + local site_name="${2}" + local artifact_status=0 + local db_name="" + local has_site_config=1 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + if stack_site_has_partial_artifacts "${stack_dir}" "${site_name}"; then + : + else + artifact_status=$? + case "${artifact_status}" in + 61) + return 61 + ;; + 54 | 52) + return "${artifact_status}" + ;; + *) + return 0 + ;; + esac + fi + + if stack_site_config_exists "${stack_dir}" "${site_name}"; then + : + else + artifact_status=$? + case "${artifact_status}" in + 61) + return 61 + ;; + 54 | 52) + return "${artifact_status}" + ;; + *) + has_site_config=0 + ;; + esac + fi + + if [ "${has_site_config}" -eq 1 ]; then + db_name="$(get_stack_site_database_name "${stack_dir}" "${site_name}" || true)" + if [ -z "${db_name}" ]; then + return 60 + fi + fi + + if [ "${has_site_config}" -eq 1 ] && ! drop_stack_site_database "${stack_dir}" "${site_name}"; then + return 60 + fi + + if ! remove_stack_site_directory "${stack_dir}" "${site_name}"; then + return 60 + fi + + if stack_site_has_partial_artifacts "${stack_dir}" "${site_name}"; then + return 60 + fi + + artifact_status=$? + case "${artifact_status}" in + 54 | 52) + return "${artifact_status}" + ;; + esac + + return 0 +} + +delete_configured_stack_site() { + local stack_dir="${1}" + local site_name="" + local delete_status=0 + + if ! stack_supports_single_site_management "${stack_dir}"; then + return 52 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + if ! stack_backend_service_is_running "${stack_dir}"; then + return 51 + fi + + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + : + else + delete_status=$? + case "${delete_status}" in + 54 | 52 | 61) + return "${delete_status}" + ;; + *) + return 60 + ;; + esac + fi + + if ! clear_stack_site_metadata "${stack_dir}"; then + return 58 + fi + + return 0 +} + +create_first_stack_site() { + local stack_dir="${1}" + local site_name="${2}" + local admin_password="${3}" + local create_site_command="" + local create_site_output="" + local database_id="" + local db_password="" + local db_root_username="" + local db_host="db" + local db_port="3306" + + database_id="$(get_stack_database_id "${stack_dir}" || true)" + db_password="$(get_stack_database_root_password "${stack_dir}")" + db_root_username="$(get_stack_database_root_username "${stack_dir}" || true)" + if [ -z "${db_root_username}" ]; then + return 57 + fi + + case "${database_id}" in + mariadb) + create_site_command="$( + printf "bench new-site %s --mariadb-user-host-login-scope='%%' --admin-password %s --db-root-username %s --db-root-password %s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${admin_password}")" \ + "$(shell_quote_site_command_arg "${db_root_username}")" \ + "$(shell_quote_site_command_arg "${db_password}")" + )" + ;; + postgres) + db_port="5432" + create_site_command="$( + printf "bench new-site %s --db-type postgres --db-host %s --db-port %s --admin-password %s --db-root-username %s --db-root-password %s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${db_host}")" \ + "$(shell_quote_site_command_arg "${db_port}")" \ + "$(shell_quote_site_command_arg "${admin_password}")" \ + "$(shell_quote_site_command_arg "${db_root_username}")" \ + "$(shell_quote_site_command_arg "${db_password}")" + )" + ;; + *) + return 57 + ;; + esac + + if ! run_stack_backend_bash_command_capture create_site_output "${stack_dir}" "${create_site_command}"; then + EASY_DOCKER_SITE_ERROR_DETAIL="bench new-site failed." + capture_stack_site_error_log "${stack_dir}" "site-create-error" "${create_site_output}" >/dev/null 2>&1 || true + return 55 + fi + + return 0 +} + +install_stack_apps_on_site() { + local result_var="${1}" + local stack_dir="${2}" + local site_name="${3}" + local selected_app_lines="" + local installed_app_lines="" + local app_name="" + local install_app_command="" + local install_app_output="" + local available_app_lines="" + local -a selected_apps=() + + if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + if [ -z "${selected_app_lines}" ]; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + available_app_lines="$(get_stack_runtime_available_app_lines "${stack_dir}" || true)" + if [ -z "${available_app_lines}" ]; then + EASY_DOCKER_SITE_ERROR_DETAIL="Could not inspect available apps in the backend image." + capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "easy-docker could not list /home/frappe/frappe-bench/apps before install-app." >/dev/null 2>&1 || true + return 63 + fi + + mapfile -t selected_apps <<<"${selected_app_lines}" + for app_name in "${selected_apps[@]}"; do + if [ -z "${app_name}" ]; then + continue + fi + + if ! printf '%s\n' "${available_app_lines}" | grep -F -x -- "${app_name}" >/dev/null 2>&1; then + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "Selected app '%s' is not available in the backend image." "${app_name}")" + capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "$(printf "Selected app '%s' was requested in stack metadata but is missing from /home/frappe/frappe-bench/apps.\nAvailable apps:\n%s" "${app_name}" "${available_app_lines}")" >/dev/null 2>&1 || true + if [ -n "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" ]; then + printf 'Details written to %s\n' "${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}" >&2 + fi + printf -v "${result_var}" "%s" "${installed_app_lines}" + return 63 + fi + + install_app_command="$( + printf "bench --site %s install-app %s" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${app_name}")" + )" + + if ! run_stack_backend_bash_command_capture install_app_output "${stack_dir}" "${install_app_command}"; then + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench install-app failed for '%s'." "${app_name}")" + capture_stack_site_error_log "${stack_dir}" "site-install-apps-error" "${install_app_output}" >/dev/null 2>&1 || true + printf -v "${result_var}" "%s" "${installed_app_lines}" + return 56 + fi + + if [ -z "${installed_app_lines}" ]; then + installed_app_lines="${app_name}" + else + installed_app_lines="${installed_app_lines}"$'\n'"${app_name}" + fi + + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "${installed_app_lines}" \ + "install-apps" \ + "" \ + "" \ + "$(get_stack_site_created_at "${stack_dir}" || true)" \ + "$(get_current_utc_timestamp)"; then + return 58 + fi + done + + printf -v "${result_var}" "%s" "${installed_app_lines}" + return 0 +} + +stack_site_should_enable_developer_mode() { + local stack_dir="${1}" + local setup_type="" + + setup_type="$(get_stack_setup_type "${stack_dir}" || true)" + case "${setup_type}" in + development) + return 0 + ;; + *) + return 1 + ;; + esac +} + +enable_stack_site_developer_mode() { + local stack_dir="${1}" + local site_name="${2}" + local developer_mode_command="" + local developer_mode_output="" + + developer_mode_command="$( + printf "bench --site %s set-config developer_mode 1 && bench --site %s clear-cache" \ + "$(shell_quote_site_command_arg "${site_name}")" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + if run_stack_backend_bash_command_capture developer_mode_output "${stack_dir}" "${developer_mode_command}"; then + return 0 + fi + + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "Could not enable developer mode for '%s'." "${site_name}")" + capture_stack_site_error_log "${stack_dir}" "site-developer-mode-error" "${developer_mode_output}" >/dev/null 2>&1 || true + return 66 +} + +run_stack_site_migrate() { + local stack_dir="${1}" + local site_name="${2}" + local migrate_command="" + local migrate_output="" + + migrate_command="$( + printf "bench --site %s migrate" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + if ! run_stack_backend_bash_command_capture migrate_output "${stack_dir}" "${migrate_command}"; then + EASY_DOCKER_SITE_ERROR_DETAIL="$(printf "bench migrate failed for '%s'." "${site_name}")" + capture_stack_site_error_log "${stack_dir}" "site-migrate-error" "${migrate_output}" >/dev/null 2>&1 || true + return 64 + fi + + return 0 +} + +bootstrap_first_stack_site() { + local stack_dir="${1}" + local site_name="${2}" + local admin_password="${3}" + local created_at="" + local updated_at="" + local installed_app_lines="" + local site_create_status=0 + local developer_mode_status=0 + local app_install_status=0 + local site_migrate_status=0 + local cleanup_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + if ! stack_supports_single_site_management "${stack_dir}"; then + return 52 + fi + + if ! stack_site_bootstrap_supports_database "${stack_dir}"; then + return 57 + fi + + if stack_has_site_configured "${stack_dir}"; then + return 53 + fi + + if ! stack_backend_service_is_running "${stack_dir}"; then + return 51 + fi + + if ! repair_stack_site_runtime_state "${stack_dir}"; then + return $? + fi + + if ! stack_database_service_is_reachable "${stack_dir}"; then + return 59 + fi + + created_at="$(get_current_utc_timestamp)" + updated_at="${created_at}" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "" "create-site" "" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + : + else + cleanup_status=$? + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + 60) + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "cleanup-partial-site" "Partial site artifacts could not be removed automatically. Manual cleanup is required." "" "" >/dev/null 2>&1 || true + return 60 + ;; + *) + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "cleanup-partial-site" "Unexpected cleanup failure before create-site." "" "${created_at}" >/dev/null 2>&1 || true + return 60 + ;; + esac + fi + + updated_at="${created_at}" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "" "create-site" "" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + if create_first_stack_site "${stack_dir}" "${site_name}" "${admin_password}"; then + : + else + site_create_status=$? + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed. Partial site data was cleaned up automatically." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + return "${site_create_status}" + fi + + cleanup_status=$? + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "create-site" "bench new-site failed and partial site data could not be cleaned up automatically. Manual cleanup is required." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + *) + return 60 + ;; + esac + fi + + if stack_site_should_enable_developer_mode "${stack_dir}"; then + if enable_stack_site_developer_mode "${stack_dir}" "${site_name}"; then + : + else + developer_mode_status=$? + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "enable-developer-mode" "${EASY_DOCKER_SITE_ERROR_DETAIL:-Developer mode activation failed. Partial site data was cleaned up automatically.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + return "${developer_mode_status}" + fi + + cleanup_status=$? + mark_stack_site_failed "${stack_dir}" "${site_name}" "" "enable-developer-mode" "${EASY_DOCKER_SITE_ERROR_DETAIL:-Developer mode activation failed and partial site data could not be cleaned up automatically. Manual cleanup is required.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + *) + return 60 + ;; + esac + fi + fi + + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "" "create-site" "" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + if install_stack_apps_on_site installed_app_lines "${stack_dir}" "${site_name}"; then + : + else + app_install_status=$? + case "${app_install_status}" in + 56 | 63) + if cleanup_partial_stack_site "${stack_dir}" "${site_name}"; then + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed. Partial site data was cleaned up automatically.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + else + cleanup_status=$? + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "${EASY_DOCKER_SITE_ERROR_DETAIL:-App installation failed and partial site data could not be cleaned up automatically. Manual cleanup is required.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + case "${cleanup_status}" in + 54 | 52) + return "${cleanup_status}" + ;; + *) + return 60 + ;; + esac + fi + ;; + 58) + return 58 + ;; + *) + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "install-apps" "Unknown app installation failure." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + ;; + esac + return "${app_install_status}" + fi + + if run_stack_site_migrate "${stack_dir}" "${site_name}"; then + : + else + site_migrate_status=$? + case "${site_migrate_status}" in + 64) + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "migrate-site" "${EASY_DOCKER_SITE_ERROR_DETAIL:-Site migration failed.}" "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + ;; + *) + mark_stack_site_failed "${stack_dir}" "${site_name}" "${installed_app_lines}" "migrate-site" "Unknown site migration failure." "${EASY_DOCKER_SITE_ERROR_LOG_PATH}" "${created_at}" >/dev/null 2>&1 || true + ;; + esac + return "${site_migrate_status}" + fi + + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "${installed_app_lines}" "migrate-site" "" "" "${created_at}" "${updated_at}"; then + return 58 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh new file mode 100755 index 00000000..501eefe8 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash + +run_stack_backend_bash_command() { + local stack_dir="${1}" + local backend_command="${2}" + local wrapped_backend_command="" + local metadata_path="" + local env_path="" + local compose_files_lines="" + local compose_file="" + local source_compose_path="" + local env_erpnext_version="" + local fallback_erpnext_version="" + local compose_project_name="" + local stack_topology="" + local repo_root="" + local -a compose_args=() + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + compose_project_name="$(get_stack_compose_project_name "${stack_dir}")" + + if [ ! -f "${metadata_path}" ]; then + return 54 + fi + + if [ ! -f "${env_path}" ]; then + return 54 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + return 54 + fi + + case "${stack_topology}" in + single-host | split-services) ;; + *) + return 52 + ;; + esac + + env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" + if [ -z "${env_erpnext_version}" ]; then + fallback_erpnext_version="$(get_default_erpnext_version || true)" + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 54 + fi + + repo_root="$(get_easy_docker_repo_root)" + while IFS= read -r compose_file; do + if [ -z "${compose_file}" ]; then + continue + fi + + source_compose_path="${repo_root}/${compose_file}" + if [ ! -f "${source_compose_path}" ]; then + return 54 + fi + + compose_args+=(-f "${source_compose_path}") + done </dev/null 2>&1; then + return 0 + fi + + backend_ready_status=$? + if [ "${backend_ready_status}" -eq 54 ] || [ "${backend_ready_status}" -eq 52 ]; then + return "${backend_ready_status}" + fi + + # If exec fails, the backend service is not ready for site actions yet. + return 1 +} + +stack_database_service_is_reachable() { + local stack_dir="${1}" + local reachability_command="" + local db_ready_status=0 + + IFS= read -r -d '' reachability_command <<'EOF' || true +python - <<'PY' +import json +import socket +from pathlib import Path + +config_path = Path("/home/frappe/frappe-bench/sites/common_site_config.json") +with config_path.open(encoding="utf-8") as handle: + config = json.load(handle) + +db_host = config.get("db_host") +db_port = int(config.get("db_port", 3306)) +socket.create_connection((db_host, db_port), 5).close() +PY +EOF + + if run_stack_backend_bash_command "${stack_dir}" "${reachability_command}" >/dev/null 2>&1; then + return 0 + fi + + db_ready_status=$? + if [ "${db_ready_status}" -eq 54 ] || [ "${db_ready_status}" -eq 52 ]; then + return "${db_ready_status}" + fi + + return 1 +} + +get_stack_common_db_endpoint() { + local stack_dir="${1}" + local read_command="" + + read_command="$( + cat <<'EOF' +python - <<'PY' +import json +from pathlib import Path +path = Path("sites/common_site_config.json") +with path.open(encoding="utf-8") as handle: + config = json.load(handle) +print(f"{config.get('db_host', '')}|{config.get('db_port', 3306)}") +PY +EOF + )" + + run_stack_backend_bash_command "${stack_dir}" "${read_command}" +} + +get_stack_runtime_available_app_lines() { + local stack_dir="${1}" + + run_stack_backend_bash_command "${stack_dir}" "ls -1 apps" +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh new file mode 100755 index 00000000..79354c07 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash + +stack_site_exists_in_bench() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "bench list-sites | grep -F -x -- %s >/dev/null" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +stack_site_directory_exists() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "test -d sites/%s" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +stack_site_config_exists() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "test -f sites/%s/site_config.json" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +get_stack_site_database_name() { + local stack_dir="${1}" + local site_name="${2}" + local read_command="" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + read_command="$( + printf "python - <<'PY'\nimport json\nfrom pathlib import Path\npath = Path('sites') / %s / 'site_config.json'\nwith path.open(encoding='utf-8') as handle:\n print(json.load(handle).get('db_name', ''))\nPY" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + run_stack_backend_bash_command "${stack_dir}" "${read_command}" +} + +get_stack_site_runtime_app_names_lines() { + local stack_dir="${1}" + local site_name="${2}" + local list_apps_command="" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + list_apps_command="$( + printf "bench --site %s list-apps | awk 'NF { print \$1 }'" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + run_stack_backend_bash_command "${stack_dir}" "${list_apps_command}" +} + +get_stack_site_runtime_selected_apps_lines() { + local result_var="${1}" + local stack_dir="${2}" + local site_name="${3}" + local selected_app_lines="" + local runtime_app_lines="" + local selected_app_name="" + local installed_app_lines="" + + if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + if [ -z "${selected_app_lines}" ]; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + runtime_app_lines="$(get_stack_site_runtime_app_names_lines "${stack_dir}" "${site_name}" || true)" + if [ -z "${runtime_app_lines}" ]; then + printf -v "${result_var}" "%s" "" + return 1 + fi + + while IFS= read -r selected_app_name; do + if [ -z "${selected_app_name}" ]; then + continue + fi + + if ! printf '%s\n' "${runtime_app_lines}" | grep -F -x -- "${selected_app_name}" >/dev/null 2>&1; then + continue + fi + + if [ -z "${installed_app_lines}" ]; then + installed_app_lines="${selected_app_name}" + else + installed_app_lines="${installed_app_lines}"$'\n'"${selected_app_name}" + fi + done <&2 + status_text="$(printf "Stack: %s\n\nSelect branch for %s (%s)\nRepo: %s\n%s" "${stack_dir##*/}" "${app_label}" "${app_id}" "${repo_url}" "${default_hint}")" + render_box_message "${status_text}" "0 2" >&2 + + if selection="$( + gum choose \ + --height 16 \ + --header "Branch selection (${app_label})" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "${branch_options[@]}" \ + "Back to app selection" + )"; then + : + else + return 2 + fi + + case "${selection}" in + "Back to app selection" | "") + return 2 + ;; + *) + printf -v "${result_var}" "%s" "${selection}" + return 0 + ;; + esac +} + +prompt_custom_modular_apps_data() { + local result_apps_metadata_var="${1}" + local stack_dir="${2}" + local metadata_path="" + local options_lines="" + local selected_labels_csv="" + local selection_raw="" + local selection_lines="" + local prompt_status=0 + local selected_predefined_csv="" + local parsed_predefined_csv="" + local selected_label="" + local predefined_app_id="" + local predefined_app_label="" + local predefined_repo_url="" + local selected_branch="" + local preferred_branch="" + local available_branch_lines="" + local existing_branch_lines="" + local existing_custom_lines="" + local selected_branch_lines="" + local selected_app_count=0 + local assembled_apps_metadata_json_object="" + local -a predefined_catalog_entries=() + local -a selected_predefined_ids=() + + metadata_path="${stack_dir}/metadata.json" + if [ -f "${metadata_path}" ]; then + selected_predefined_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)" + existing_branch_lines="$(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true)" + existing_custom_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)" + fi + + while true; do + options_lines="" + selected_labels_csv="" + predefined_catalog_entries=() + + mapfile -t predefined_catalog_entries < <(get_predefined_apps_catalog_entries || true) + for selected_label in "${predefined_catalog_entries[@]}"; do + IFS='|' read -r predefined_app_id predefined_app_label predefined_repo_url _ _ <<<"${selected_label}" + if [ -z "${predefined_app_id}" ] || [ -z "${predefined_app_label}" ]; then + continue + fi + + if [ -z "${options_lines}" ]; then + options_lines="$(printf '%s' "${predefined_app_label}")" + else + options_lines="$(printf '%s\n%s' "${options_lines}" "${predefined_app_label}")" + fi + done + + if [ -n "${selected_predefined_csv}" ]; then + IFS=',' read -r -a selected_predefined_ids <<<"${selected_predefined_csv}" + for predefined_app_id in "${selected_predefined_ids[@]}"; do + if [ -z "${predefined_app_id}" ]; then + continue + fi + predefined_app_label="$(get_predefined_app_label_by_id "${predefined_app_id}" || true)" + if [ -z "${predefined_app_label}" ]; then + continue + fi + append_csv_unique selected_labels_csv "${selected_labels_csv}" "${predefined_app_label}" + done + fi + + if [ -z "${options_lines}" ]; then + show_warning_and_wait "No apps available in catalog." 3 + return 1 + fi + + if selection_raw="$(show_custom_modular_apps_multi_select "${stack_dir}" "${options_lines}" "${selected_labels_csv}")"; then + prompt_status=0 + else + prompt_status=$? + fi + if [ "${prompt_status}" -ne 0 ]; then + return 2 + fi + + if [ -z "${selection_raw}" ]; then + show_warning_message "Select at least one app." + continue + fi + + parsed_predefined_csv="" + # gum choose can return multiple values separated by newlines or commas + # depending on version/configuration. Normalize to one label per line. + selection_lines="$(printf '%s' "${selection_raw}" | tr ',' '\n')" + + while IFS= read -r selected_label; do + trim_predefined_catalog_field selected_label "${selected_label}" + if [ -z "${selected_label}" ]; then + continue + fi + + predefined_app_id="$(get_predefined_app_id_by_label "${selected_label}" || true)" + if [ -z "${predefined_app_id}" ]; then + continue + fi + append_csv_unique parsed_predefined_csv "${parsed_predefined_csv}" "${predefined_app_id}" + done <"${tmp_path}"; then + rm -f -- "${tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${tmp_path}" "${env_path}"; then + rm -f -- "${tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +set_stack_custom_image_tag() { + local stack_dir="${1}" + local custom_tag="${2:-}" + local env_path="" + local custom_image="" + + env_path="$(get_stack_env_path "${stack_dir}")" + if [ ! -f "${env_path}" ]; then + return 31 + fi + + if ! is_valid_docker_image_tag "${custom_tag}"; then + return 32 + fi + + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + if [ -z "${custom_image}" ]; then + return 33 + fi + + if ! persist_env_file_key_value "${env_path}" "CUSTOM_TAG" "${custom_tag}"; then + return 34 + fi + + return 0 +} + +prompt_stack_custom_image_tag_with_cancel() { + local result_var="${1}" + local stack_dir="${2}" + local current_image="" + local current_tag="" + local guidance_text="" + local custom_tag="" + local prompt_status=0 + + current_image="$(get_stack_custom_image_name "${stack_dir}" || true)" + current_tag="$(get_stack_custom_image_tag "${stack_dir}" || true)" + guidance_text="$(printf "Current custom image: %s\nCurrent custom tag: %s\n\nEnter the next CUSTOM_TAG for the rebuilt image.\nExample: v1.4.3 or 2026-04-02-appupdate.\nType /back to return." "${current_image:-n/a}" "${current_tag:-n/a}")" + + if prompt_env_value_with_validation custom_tag "${stack_dir}" "CUSTOM_TAG" "${guidance_text}" "${current_tag}" "required" "image_tag"; then + : + else + prompt_status=$? + return "${prompt_status}" + fi + + printf -v "${result_var}" "%s" "${custom_tag}" + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/env/validation.sh b/scripts/easy-docker/lib/app/wizard/env/validation.sh new file mode 100755 index 00000000..8930a76c --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/env/validation.sh @@ -0,0 +1,522 @@ +#!/usr/bin/env bash + +is_valid_email_address() { + local value="${1}" + + case "${value}" in + *@*.*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_valid_port_number() { + local value="${1}" + + if ! [[ "${value}" =~ ^[0-9]+$ ]]; then + return 1 + fi + + if [ "${value}" -lt 1 ] || [ "${value}" -gt 65535 ]; then + return 1 + fi + + return 0 +} + +is_valid_host_value() { + local value="${1}" + + if [ -z "${value}" ] || [ "${#value}" -gt 253 ]; then + return 1 + fi + + if printf '%s' "${value}" | grep -Eq '[[:space:]/,:;?!@#]'; then + return 1 + fi + + case "${value}" in + .* | *. | *..*) + return 1 + ;; + esac + + case "${value}" in + [A-Za-z0-9]*) ;; + *) + return 1 + ;; + esac + + case "${value}" in + *[A-Za-z0-9]) ;; + *) + return 1 + ;; + esac + + return 0 +} + +is_valid_host_port_value() { + local value="${1}" + local host="" + local port="" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *:*) + host="${value%:*}" + port="${value##*:}" + ;; + *) + return 1 + ;; + esac + + if [ -z "${host}" ] || [ -z "${port}" ]; then + return 1 + fi + + if ! is_valid_host_value "${host}"; then + return 1 + fi + + if ! is_valid_port_number "${port}"; then + return 1 + fi + + return 0 +} + +EASY_DOCKER_LAST_INVALID_DOMAIN="" + +reset_domain_validation_feedback() { + EASY_DOCKER_LAST_INVALID_DOMAIN="" +} + +trim_domain_token() { + local result_var="${1}" + local value="${2}" + + while true; do + case "${value}" in + ' '*) + value="${value# }" + ;; + *' ') + value="${value% }" + ;; + $'\t'*) + value="${value#$'\t'}" + ;; + *$'\t') + value="${value%$'\t'}" + ;; + *) + break + ;; + esac + done + + printf -v "${result_var}" "%s" "${value}" + return 0 +} + +normalize_domain_token() { + local result_var="${1}" + local raw_token="${2}" + local token="${raw_token}" + + trim_domain_token token "${token}" + if [ -z "${token}" ]; then + return 1 + fi + + while true; do + case "${token}" in + \"*\") + token="${token#\"}" + token="${token%\"}" + ;; + \'*\') + token="${token#\'}" + token="${token%\'}" + ;; + \`*\`) + token="${token#\`}" + token="${token%\`}" + ;; + \[*\]) + token="${token#\[}" + token="${token%\]}" + ;; + \(*\)) + token="${token#(}" + token="${token%)}" + ;; + \"*) + token="${token#\"}" + ;; + \'*) + token="${token#\'}" + ;; + \`*) + token="${token#\`}" + ;; + \[*) + token="${token#\[}" + ;; + \(*) + token="${token#(}" + ;; + *) + break + ;; + esac + done + + while true; do + case "${token}" in + *\") + token="${token%\"}" + ;; + *\') + token="${token%\'}" + ;; + *\`) + token="${token%\`}" + ;; + *\]) + token="${token%\]}" + ;; + *\)) + token="${token%)}" + ;; + *) + break + ;; + esac + done + + trim_domain_token token "${token}" + if [ -z "${token}" ]; then + return 1 + fi + + printf -v "${result_var}" "%s" "${token}" + return 0 +} + +is_valid_domain_name() { + local domain="${1}" + local normalized_domain="" + local label="" + local tld="" + local last_index=0 + local -a labels=() + + if ! normalize_domain_token normalized_domain "${domain}"; then + return 1 + fi + + case "${normalized_domain}" in + *[[:space:],:/?#!@]* | *";"* | .* | *. | *..* | *\**) + return 1 + ;; + esac + + if [ "${normalized_domain}" = "localhost" ]; then + return 0 + fi + + if [ "${#normalized_domain}" -lt 5 ] || [ "${#normalized_domain}" -gt 253 ]; then + return 1 + fi + + local IFS='.' + read -r -a labels <<<"${normalized_domain}" + if [ "${#labels[@]}" -lt 2 ]; then + return 1 + fi + + for label in "${labels[@]}"; do + if [ -z "${label}" ]; then + return 1 + fi + + if [ "${#label}" -gt 63 ]; then + return 1 + fi + + case "${label}" in + [A-Za-z0-9]*) ;; + *) + return 1 + ;; + esac + + case "${label}" in + *[A-Za-z0-9]) ;; + *) + return 1 + ;; + esac + + case "${label}" in + *[!A-Za-z0-9-]*) + return 1 + ;; + esac + done + + last_index=$((${#labels[@]} - 1)) + tld="${labels[last_index]}" + if [ "${tld}" = "localhost" ]; then + return 0 + fi + + if ! [[ "${tld}" =~ ^[A-Za-z]{2,63}$ ]]; then + return 1 + fi + + return 0 +} + +parse_domains_input_to_lines() { + local result_var="${1}" + local raw_value="${2}" + local sanitized_value="" + local token="" + local normalized_token="" + local parsed_domain_lines="" + local -a tokens=() + local IFS=$' \t\n' + + reset_domain_validation_feedback + + sanitized_value="${raw_value//$'\r'/ }" + sanitized_value="${sanitized_value//$'\n'/ }" + sanitized_value="${sanitized_value//$'\t'/ }" + sanitized_value="${sanitized_value//,/ }" + sanitized_value="${sanitized_value//;/ }" + + read -r -a tokens <<<"${sanitized_value}" + if [ "${#tokens[@]}" -eq 0 ]; then + EASY_DOCKER_LAST_INVALID_DOMAIN="${raw_value}" + return 1 + fi + + for token in "${tokens[@]}"; do + if ! normalize_domain_token normalized_token "${token}"; then + EASY_DOCKER_LAST_INVALID_DOMAIN="${token}" + return 1 + fi + + if ! is_valid_domain_name "${normalized_token}"; then + EASY_DOCKER_LAST_INVALID_DOMAIN="${normalized_token}" + return 1 + fi + + if [ -z "${parsed_domain_lines}" ]; then + parsed_domain_lines="${normalized_token}" + else + parsed_domain_lines="${parsed_domain_lines}"$'\n'"${normalized_token}" + fi + done + + if [ -z "${parsed_domain_lines}" ]; then + return 1 + fi + + printf -v "${result_var}" "%s" "${parsed_domain_lines}" + return 0 +} + +domain_lines_to_csv() { + local domain_lines="${1}" + local domain="" + local csv_value="" + + while IFS= read -r domain; do + if [ -z "${domain}" ]; then + continue + fi + + if [ -z "${csv_value}" ]; then + csv_value="${domain}" + else + csv_value="${csv_value},${domain}" + fi + done < ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6 + ;; + 25) + show_warning_and_wait "Custom image build failed: jq is required for stack metadata and apps.json processing." 4 + ;; + *) + show_warning_and_wait "Custom image build failed (${build_image_status})." 4 + ;; + esac + + return "${build_image_status}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh new file mode 100755 index 00000000..c39c9922 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +prompt_manage_stack_site_name_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local stack_dir="${3}" + local input_site_name="" + local suggestion="" + local prompt_status=0 + + suggestion="$(get_stack_primary_site_name_suggestion "${stack_dir}" || true)" + while true; do + input_site_name="$(prompt_stack_site_name "${stack_name}" "${suggestion}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + input_site_name="$(printf '%s' "${input_site_name}" | tr -d '\r\n')" + case "${input_site_name}" in + "") + show_warning_and_wait "Site name is required." 2 + ;; + /back | /Back | /BACK) + return "${FLOW_ABORT_INPUT}" + ;; + *) + if ! is_valid_stack_site_name "${input_site_name}"; then + show_warning_and_wait "Site name may only contain letters, numbers, dots, dashes, and underscores." 3 + continue + fi + printf -v "${result_var}" "%s" "${input_site_name}" + return "${FLOW_CONTINUE}" + ;; + esac + done +} + +prompt_manage_stack_site_admin_password_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local input_admin_password="" + local prompt_status=0 + + while true; do + input_admin_password="$(prompt_stack_site_admin_password "${stack_name}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + input_admin_password="$(printf '%s' "${input_admin_password}" | tr -d '\r\n')" + case "${input_admin_password}" in + "") + show_warning_and_wait "Administrator password is required." 2 + ;; + /back | /Back | /BACK) + return "${FLOW_ABORT_INPUT}" + ;; + *) + printf -v "${result_var}" "%s" "${input_admin_password}" + return "${FLOW_CONTINUE}" + ;; + esac + done +} + +prompt_manage_stack_delete_keyword_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local delete_confirmation="" + local prompt_status=0 + + while true; do + delete_confirmation="$(prompt_manage_stack_delete_keyword "${stack_name}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + delete_confirmation="$(printf '%s' "${delete_confirmation}" | tr -d '\r\n')" + case "${delete_confirmation}" in + /back | /Back | /BACK | "") + return "${FLOW_ABORT_INPUT}" + ;; + delete) + printf -v "${result_var}" "%s" "${delete_confirmation}" + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Type exactly delete to confirm stack removal." 3 + ;; + esac + done +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh new file mode 100755 index 00000000..a01ee508 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh @@ -0,0 +1,569 @@ +#!/usr/bin/env bash + +migrate_configured_stack_site() { + local stack_dir="${1}" + local site_name="" + local current_site_apps_lines="" + local created_at="" + local updated_at="" + local backend_status=0 + + reset_easy_docker_site_error_state + + if ! stack_supports_single_site_management "${stack_dir}"; then + return 52 + fi + + site_name="$(get_stack_site_name "${stack_dir}" || true)" + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 53 + fi + + if stack_backend_service_is_running "${stack_dir}"; then + : + else + backend_status=$? + case "${backend_status}" in + 54) + return 54 + ;; + 52) + return 52 + ;; + *) + return 51 + ;; + esac + fi + + if ! run_stack_site_migrate "${stack_dir}" "${site_name}"; then + return $? + fi + + current_site_apps_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)" + created_at="$(get_stack_site_created_at "${stack_dir}" || true)" + updated_at="$(get_current_utc_timestamp)" + if ! persist_stack_site_metadata \ + "${stack_dir}" \ + "single-site" \ + "${site_name}" \ + "${current_site_apps_lines}" \ + "migrate-site" \ + "" \ + "" \ + "${created_at}" \ + "${updated_at}"; then + return 65 + fi + + return 0 +} + +handle_manage_stack_site_flow() { + local stack_name="${1}" + local stack_dir="${2}" + local site_action="" + local site_name="" + local admin_password="" + local site_flow_status=0 + local existing_site_entry="" + local existing_site_name="" + local existing_site_created_at="" + local existing_site_apps_lines="" + local existing_site_apps_csv="" + local existing_site_last_backup_at="" + local existing_site_details_action="" + local existing_site_apps_action="" + local existing_site_migrate_confirm="" + local existing_site_app_lines="" + local existing_site_app_selection="" + local existing_site_app_confirmation="" + local site_delete_confirmation="" + + while true; do + existing_site_entry="" + get_stack_site_menu_entry existing_site_entry "${stack_dir}" || true + + site_action="$(show_manage_stack_site_menu "${stack_name}" "${stack_dir}" "${existing_site_entry}" || true)" + case "${site_action}" in + "Create new site") + if ! prompt_manage_stack_site_name_with_cancel site_name "${stack_name}" "${stack_dir}"; then + continue + fi + + if ! prompt_manage_stack_site_admin_password_with_cancel admin_password "${stack_name}"; then + continue + fi + + show_warning_message "Creating the first site for stack: ${stack_name}" + if bootstrap_first_stack_site "${stack_dir}" "${site_name}" "${admin_password}"; then + show_warning_and_wait "Site created successfully, selected stack apps were installed, and bench migrate completed: ${site_name}" 3 + continue + else + site_flow_status=$? + fi + + case "${site_flow_status}" in + 51) + show_warning_and_wait "Cannot manage site: backend service is not running yet. Start the stack first." 4 + ;; + 52) + show_warning_and_wait "Cannot manage site for this topology yet. Only single-host stacks are supported." 4 + ;; + 53) + show_warning_and_wait "A site is already configured for this stack. Phase 1 supports one site per stack." 4 + ;; + 54) + show_warning_and_wait "Cannot manage site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 55) + show_warning_and_wait "Could not create the site. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above for bench new-site details.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 56) + show_warning_and_wait "The site was created, but app installation failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 57) + show_warning_and_wait "Site bootstrap currently supports only supported single-host database stacks." 4 + ;; + 58) + show_warning_and_wait "The site metadata could not be written to metadata.json." 4 + ;; + 59) + show_warning_and_wait "Cannot create site: stack services are not ready yet. Wait and try again." 4 + ;; + 60) + show_warning_and_wait "Site creation failed and automatic cleanup could not remove all partial data. Manual cleanup is required." 5 + ;; + 61) + show_warning_and_wait "Cannot create site because the site name was empty or unsafe for cleanup operations." 4 + ;; + 62) + show_warning_and_wait "Cannot prepare the stack for site creation because the bench runtime files could not be repaired." 4 + ;; + 63) + show_warning_and_wait "Cannot install the selected stack apps because at least one app is missing from the backend image. ${EASY_DOCKER_SITE_ERROR_DETAIL} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7 + ;; + 64) + show_warning_and_wait "The site was created and apps were installed, but bench migrate failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7 + ;; + 66) + show_warning_and_wait "Site bootstrap failed while enabling developer mode for this development stack. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 7 + ;; + *) + show_warning_and_wait "Site bootstrap failed (${site_flow_status})." 4 + ;; + esac + ;; + "Back" | "") + return "${FLOW_CONTINUE}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + if [ -n "${existing_site_entry}" ] && [ "${site_action}" = "${existing_site_entry}" ]; then + existing_site_name="$(get_stack_site_name "${stack_dir}" || true)" + existing_site_created_at="$(get_stack_site_created_at "${stack_dir}" || true)" + existing_site_apps_lines="$(get_stack_site_apps_installed_lines "${stack_dir}" || true)" + existing_site_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)" + if stack_backend_service_is_running "${stack_dir}" >/dev/null 2>&1; then + get_stack_site_managed_runtime_app_lines existing_site_apps_lines "${stack_dir}" "${existing_site_name}" || true + fi + if [ -n "${existing_site_apps_lines}" ]; then + existing_site_apps_csv="$(printf '%s' "${existing_site_apps_lines}" | tr '\n' ',' | sed 's/,$//')" + else + existing_site_apps_csv="None" + fi + + existing_site_details_action="$( + show_manage_stack_site_details \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "${existing_site_created_at}" \ + "${existing_site_apps_csv}" \ + "${existing_site_last_backup_at}" || true + )" + case "${existing_site_details_action}" in + "Manage apps on this site") + while true; do + existing_site_apps_action="$( + show_manage_stack_site_apps_menu \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" || true + )" + case "${existing_site_apps_action}" in + "Install app on this site") + if ! get_configured_stack_site_installable_app_lines existing_site_app_lines "${stack_dir}"; then + site_flow_status=$? + case "${site_flow_status}" in + 81) + show_warning_and_wait "Cannot install app: backend service is not running yet. Start the stack first." 4 + ;; + 82) + show_warning_and_wait "Cannot install app for this topology yet. Only single-host stacks are supported." 4 + ;; + 83) + show_warning_and_wait "Cannot install app because no configured site was found in metadata.json." 4 + ;; + 84) + show_warning_and_wait "Cannot install app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 87) + show_warning_and_wait "Cannot inspect installable apps on this site right now. Check backend readiness and try again." 4 + ;; + *) + show_warning_and_wait "Could not prepare installable site apps (${site_flow_status})." 4 + ;; + esac + continue + fi + + if [ -z "${existing_site_app_lines}" ]; then + show_warning_and_wait "No additional selected stack apps are available to install on this site. No further apps are currently available in this stack environment." 4 + continue + fi + + existing_site_app_selection="$( + show_manage_stack_site_app_selection \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "Install app on this site" \ + "${existing_site_app_lines}" || true + )" + case "${existing_site_app_selection}" in + "Back" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_message "Installing app on site: ${existing_site_app_selection}" + if install_app_on_configured_stack_site "${stack_dir}" "${existing_site_app_selection}"; then + show_warning_and_wait "App installed successfully on site ${existing_site_name}: ${existing_site_app_selection}" 4 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 81) + show_warning_and_wait "Cannot install app: backend service is not running yet. Start the stack first." 4 + ;; + 82) + show_warning_and_wait "Cannot install app for this topology yet. Only single-host stacks are supported." 4 + ;; + 83) + show_warning_and_wait "Cannot install app because no configured site was found in metadata.json." 4 + ;; + 84) + show_warning_and_wait "Cannot install app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 85) + show_warning_and_wait "No additional selected stack apps are available to install on this site. No further apps are currently available in this stack environment." 4 + ;; + 86) + show_warning_and_wait "The selected app is not currently installable on this site." 4 + ;; + 87) + show_warning_and_wait "Cannot inspect current site apps right now. Check backend readiness and try again." 4 + ;; + 88) + show_warning_and_wait "App installation failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 89) + show_warning_and_wait "The app command finished, but site metadata could not be written to metadata.json." 5 + ;; + *) + show_warning_and_wait "App installation failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + esac + ;; + "Uninstall app from this site") + if ! get_configured_stack_site_uninstallable_app_lines existing_site_app_lines "${stack_dir}"; then + site_flow_status=$? + case "${site_flow_status}" in + 91) + show_warning_and_wait "Cannot uninstall app: backend service is not running yet. Start the stack first." 4 + ;; + 92) + show_warning_and_wait "Cannot uninstall app for this topology yet. Only single-host stacks are supported." 4 + ;; + 93) + show_warning_and_wait "Cannot uninstall app because no configured site was found in metadata.json." 4 + ;; + 94) + show_warning_and_wait "Cannot uninstall app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 97) + show_warning_and_wait "Cannot inspect uninstallable apps on this site right now. Check backend readiness and try again." 4 + ;; + *) + show_warning_and_wait "Could not prepare uninstallable site apps (${site_flow_status})." 4 + ;; + esac + continue + fi + + if [ -z "${existing_site_app_lines}" ]; then + show_warning_and_wait "No installed site apps are currently available for uninstall." 4 + continue + fi + + existing_site_app_selection="$( + show_manage_stack_site_app_selection \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "Uninstall app from this site" \ + "${existing_site_app_lines}" || true + )" + case "${existing_site_app_selection}" in + "Back" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + existing_site_app_confirmation="$( + show_manage_stack_site_app_uninstall_confirmation \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" \ + "${existing_site_app_selection}" || true + )" + case "${existing_site_app_confirmation}" in + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + "Yes") + show_warning_message "Uninstalling app from site: ${existing_site_app_selection}" + if uninstall_app_from_configured_stack_site "${stack_dir}" "${existing_site_app_selection}"; then + show_warning_and_wait "App uninstalled successfully from site ${existing_site_name}: ${existing_site_app_selection}" 4 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 91) + show_warning_and_wait "Cannot uninstall app: backend service is not running yet. Start the stack first." 4 + ;; + 92) + show_warning_and_wait "Cannot uninstall app for this topology yet. Only single-host stacks are supported." 4 + ;; + 93) + show_warning_and_wait "Cannot uninstall app because no configured site was found in metadata.json." 4 + ;; + 94) + show_warning_and_wait "Cannot uninstall app because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 95) + show_warning_and_wait "No installed site apps are currently available for uninstall." 4 + ;; + 96) + show_warning_and_wait "The selected app cannot be uninstalled here. frappe stays blocked, but erpnext is allowed." 4 + ;; + 97) + show_warning_and_wait "Cannot inspect current site apps right now. Check backend readiness and try again." 4 + ;; + 98) + show_warning_and_wait "App uninstall failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 99) + show_warning_and_wait "The app command finished, but site metadata could not be written to metadata.json." 5 + ;; + *) + show_warning_and_wait "App uninstall failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + *) + show_warning_and_wait "Unknown uninstall app confirmation action: ${existing_site_app_confirmation}" 2 + continue + ;; + esac + ;; + esac + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site apps action: ${existing_site_apps_action}" 2 + continue + ;; + esac + done + continue + ;; + "Migrate site now") + existing_site_migrate_confirm="$( + show_manage_stack_site_migrate_confirmation \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" || true + )" + case "${existing_site_migrate_confirm}" in + "Yes") + show_warning_message "Migrating site for stack: ${stack_name}" + if migrate_configured_stack_site "${stack_dir}"; then + show_warning_and_wait "Site migrated successfully: ${existing_site_name}" 3 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 51) + show_warning_and_wait "Cannot migrate site: backend service is not running yet. Start the stack first." 4 + ;; + 52) + show_warning_and_wait "Cannot migrate site for this topology yet. Only single-host stacks are supported." 4 + ;; + 53) + show_warning_and_wait "Cannot migrate site because the configured site name was empty or unsafe for cleanup operations." 4 + ;; + 54) + show_warning_and_wait "Cannot migrate site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 64) + show_warning_and_wait "Site migration failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 65) + show_warning_and_wait "The migrate command finished, but the site metadata could not be written to metadata.json." 5 + ;; + *) + show_warning_and_wait "Site migration failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site migrate confirmation action: ${existing_site_migrate_confirm}" 2 + continue + ;; + esac + ;; + "Backup site now") + show_warning_message "Creating backup for site: ${existing_site_name}" + if backup_configured_stack_site "${stack_dir}"; then + existing_site_last_backup_at="$(get_stack_site_last_backup_at "${stack_dir}" || true)" + show_warning_and_wait "Site backup completed successfully: ${existing_site_name}${existing_site_last_backup_at:+ (last backup at ${existing_site_last_backup_at})}" 4 + continue + fi + + site_flow_status=$? + case "${site_flow_status}" in + 71) + show_warning_and_wait "Cannot back up site: backend service is not running yet. Start the stack first." 4 + ;; + 72) + show_warning_and_wait "Cannot back up site for this topology yet. Only single-host stacks are supported." 4 + ;; + 73) + show_warning_and_wait "Cannot back up site because no configured site was found in metadata.json." 4 + ;; + 74) + show_warning_and_wait "Cannot back up site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 75) + show_warning_and_wait "Site backup failed. ${EASY_DOCKER_SITE_ERROR_DETAIL:-Check the output above.} ${EASY_DOCKER_SITE_ERROR_LOG_PATH:+See ${stack_dir}/${EASY_DOCKER_SITE_ERROR_LOG_PATH}}" 6 + ;; + 76) + show_warning_and_wait "The backup command finished, but the backup metadata could not be written to metadata.json." 5 + ;; + *) + show_warning_and_wait "Site backup failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + "Delete site") + site_delete_confirmation="$( + show_manage_stack_site_delete_confirmation \ + "${stack_name}" \ + "${stack_dir}" \ + "${existing_site_name}" || true + )" + case "${site_delete_confirmation}" in + "Yes") + show_warning_message "Deleting site for stack: ${stack_name}" + if delete_configured_stack_site "${stack_dir}"; then + show_warning_and_wait "Site deleted successfully with its database: ${existing_site_name}" 3 + continue + else + site_flow_status=$? + fi + case "${site_flow_status}" in + 51) + show_warning_and_wait "Cannot delete site: backend service is not running yet. Start the stack first." 4 + ;; + 52) + show_warning_and_wait "Cannot delete site for this topology yet. Only single-host stacks are supported." 4 + ;; + 54) + show_warning_and_wait "Cannot delete site because stack metadata, env, or compose inputs are incomplete." 4 + ;; + 58) + show_warning_and_wait "The cleared site metadata could not be written to metadata.json." 4 + ;; + 60) + show_warning_and_wait "Site deletion could not remove all site or database data automatically. Manual cleanup is required." 5 + ;; + 61) + show_warning_and_wait "Cannot delete site because the configured site name was empty or unsafe for cleanup operations." 4 + ;; + *) + show_warning_and_wait "Site deletion failed (${site_flow_status})." 4 + ;; + esac + continue + ;; + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site delete confirmation action: ${site_delete_confirmation}" 2 + continue + ;; + esac + ;; + "Back" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown site details action: ${existing_site_details_action}" 2 + ;; + esac + continue + fi + + show_warning_and_wait "Unknown site action: ${site_action}" 2 + ;; + esac + done +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh new file mode 100755 index 00000000..4f8cfd40 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh @@ -0,0 +1,451 @@ +#!/usr/bin/env bash + +handle_manage_selected_stack_flow() { + local stack_name="${1}" + local stack_dir="" + local stack_action="" + local apps_action="" + local updates_action="" + local stack_metadata_path="" + local stack_apps_path="" + local stack_env_path="" + local custom_apps_update_status=0 + local compose_start_status=0 + local stack_runtime_status="" + local stack_frappe_branch="" + local stack_custom_image_ref="" + local stack_custom_tag="" + local custom_tag_prompt_status=0 + local custom_tag_update_status=0 + local missing_custom_image_action="" + local delete_stack_confirmation_action="" + local delete_stack_keyword="" + + stack_dir="$(get_stack_dir_by_name "${stack_name}" || true)" + if [ -z "${stack_dir}" ]; then + show_warning_and_wait "Could not resolve stack directory for '${stack_name}'." 2 + return "${FLOW_CONTINUE}" + fi + + while true; do + get_stack_compose_runtime_status_label stack_runtime_status "${stack_dir}" + stack_frappe_branch="$(get_stack_frappe_branch "${stack_dir}" || true)" + stack_custom_image_ref="$(get_stack_custom_image_ref "${stack_dir}" || true)" + stack_action="$(show_manage_stack_actions_menu "${stack_name}" "${stack_dir}" "${stack_runtime_status}" || true)" + case "${stack_action}" in + "Apps") + while true; do + apps_action="$(show_manage_stack_apps_menu "${stack_name}" "${stack_dir}" "${stack_frappe_branch}" || true)" + case "${apps_action}" in + "Select apps and branches") + if update_stack_custom_modular_apps "${stack_dir}"; then + : + else + custom_apps_update_status=$? + case "${custom_apps_update_status}" in + 2 | 130) + continue + ;; + 3) + stack_metadata_path="${stack_dir}/metadata.json" + show_warning_and_wait "Cannot update app selection because metadata is missing: ${stack_metadata_path}" 3 + continue + ;; + *) + show_warning_and_wait "Could not update app selection (${custom_apps_update_status}) for stack: ${stack_name}" 3 + continue + ;; + esac + fi + + stack_apps_path="${stack_dir}/apps.json" + show_warning_and_wait "App selection updated in ${stack_dir}/metadata.json and ${stack_apps_path}." 3 + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown apps action: ${apps_action}" + ;; + esac + done + ;; + "Updates") + while true; do + stack_custom_image_ref="$(get_stack_custom_image_ref "${stack_dir}" || true)" + updates_action="$(show_manage_stack_updates_menu "${stack_name}" "${stack_dir}" "${stack_frappe_branch}" "${stack_custom_image_ref}" || true)" + case "${updates_action}" in + "Update selected app branches") + if update_stack_selected_app_branches "${stack_dir}"; then + stack_apps_path="${stack_dir}/apps.json" + show_warning_and_wait "Selected app branches updated in ${stack_dir}/metadata.json and ${stack_apps_path}. Set the next custom image tag, build the updated image, restart the stack, and migrate the site to apply the update." 6 + else + custom_apps_update_status=$? + case "${custom_apps_update_status}" in + 2 | 130) + continue + ;; + 3) + stack_metadata_path="${stack_dir}/metadata.json" + show_warning_and_wait "Cannot update selected app branches because metadata is missing: ${stack_metadata_path}" 3 + continue + ;; + 4) + show_warning_and_wait "No selected stack apps were found for branch updates. Select apps for the stack first." 4 + continue + ;; + *) + show_warning_and_wait "Could not update selected app branches (${custom_apps_update_status}) for stack: ${stack_name}" 3 + continue + ;; + esac + fi + ;; + "Set next custom image tag") + stack_env_path="$(get_stack_env_path "${stack_dir}")" + if [ ! -f "${stack_env_path}" ]; then + show_warning_and_wait "Cannot update CUSTOM_TAG because the stack env file is missing: ${stack_env_path}" 4 + continue + fi + + if [ -z "$(get_stack_custom_image_name "${stack_dir}" || true)" ]; then + show_warning_and_wait "Cannot update CUSTOM_TAG because CUSTOM_IMAGE is missing in the stack env file." 4 + continue + fi + + if prompt_stack_custom_image_tag_with_cancel stack_custom_tag "${stack_dir}"; then + : + else + custom_tag_prompt_status=$? + case "${custom_tag_prompt_status}" in + 2 | 130) + continue + ;; + *) + show_warning_and_wait "Could not collect the next custom image tag (${custom_tag_prompt_status})." 3 + continue + ;; + esac + fi + + if set_stack_custom_image_tag "${stack_dir}" "${stack_custom_tag}"; then + stack_custom_image_ref="$(get_stack_custom_image_ref "${stack_dir}" || true)" + show_warning_message "Custom image tag updated successfully: ${stack_custom_image_ref:-${stack_custom_tag}}" + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then + : + else + continue + fi + else + custom_tag_update_status=$? + case "${custom_tag_update_status}" in + 31) + show_warning_and_wait "Cannot update CUSTOM_TAG because the stack env file is missing: ${stack_env_path}" 4 + ;; + 32) + show_warning_and_wait "Cannot update CUSTOM_TAG because the value is not a valid Docker image tag." 4 + ;; + 33) + show_warning_and_wait "Cannot update CUSTOM_TAG because CUSTOM_IMAGE is missing in the stack env file." 4 + ;; + 34) + show_warning_and_wait "Could not write the updated CUSTOM_TAG to ${stack_env_path}." 4 + ;; + *) + show_warning_and_wait "Could not update CUSTOM_TAG (${custom_tag_update_status})." 4 + ;; + esac + fi + ;; + "Build updated image") + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then + : + else + continue + fi + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown update action: ${updates_action}" + ;; + esac + done + ;; + "Start stack in Docker Compose") + while true; do + show_warning_message "Starting stack with docker compose: ${stack_name}" + if start_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack started successfully with docker compose: ${stack_name}" 3 + break + else + compose_start_status=$? + fi + case "${compose_start_status}" in + 31) + show_warning_and_wait "Cannot start stack: metadata.json is missing in ${stack_dir}." 4 + break + ;; + 32) + show_warning_and_wait "Cannot start stack: stack env file not found in ${stack_dir}." 4 + break + ;; + 33) + show_warning_and_wait "Cannot start stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + break + ;; + 34) + show_warning_and_wait "Cannot start stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + break + ;; + 35) + show_warning_and_wait "Cannot start stack: no compose files configured in metadata.json." 4 + break + ;; + 36) + show_warning_and_wait "Cannot start stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + break + ;; + 37) + show_warning_and_wait "docker compose up failed. Check the output above for details." 4 + break + ;; + 38) + missing_custom_image_action="$( + show_missing_custom_image_start_menu "${stack_name}" "${stack_dir}" "${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" || true + )" + case "${missing_custom_image_action}" in + "Build custom image now") + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then + continue + fi + break + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown missing-image action: ${missing_custom_image_action}" 2 + break + ;; + esac + ;; + 39) + show_warning_and_wait "Cannot inspect custom image before start. Check Docker and try again. Details: ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + break + ;; + *) + show_warning_and_wait "Cannot start stack with docker compose (${compose_start_status})." 4 + break + ;; + esac + done + ;; + "Restart stack in Docker Compose") + while true; do + show_warning_message "Restarting stack with docker compose: ${stack_name}" + if restart_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack restarted successfully with docker compose: ${stack_name}" 3 + break + else + compose_start_status=$? + fi + case "${compose_start_status}" in + 57) + show_warning_and_wait "Cannot restart stack: metadata.json is missing in ${stack_dir}." 4 + break + ;; + 58) + show_warning_and_wait "Cannot restart stack: stack env file not found in ${stack_dir}." 4 + break + ;; + 59) + show_warning_and_wait "Cannot restart stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + break + ;; + 60) + show_warning_and_wait "Cannot restart stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + break + ;; + 61) + show_warning_and_wait "Cannot restart stack: no compose files configured in metadata.json." 4 + break + ;; + 62) + show_warning_and_wait "Cannot restart stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + break + ;; + 63) + show_warning_and_wait "docker compose restart failed. Check the output above for details." 4 + break + ;; + 64) + missing_custom_image_action="$( + show_missing_custom_image_start_menu "${stack_name}" "${stack_dir}" "${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" || true + )" + case "${missing_custom_image_action}" in + "Build custom image now") + if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then + continue + fi + break + ;; + "Back" | "") + break + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown missing-image action: ${missing_custom_image_action}" 2 + break + ;; + esac + ;; + 65) + show_warning_and_wait "Cannot inspect custom image before restart. Check Docker and try again. Details: ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + break + ;; + *) + show_warning_and_wait "Cannot restart stack with docker compose (${compose_start_status})." 4 + break + ;; + esac + done + ;; + "Stop stack in Docker Compose") + show_warning_message "Stopping stack with docker compose: ${stack_name}" + if stop_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack stopped successfully with docker compose: ${stack_name}" 3 + continue + else + compose_start_status=$? + fi + case "${compose_start_status}" in + 41) + show_warning_and_wait "Cannot stop stack: metadata.json is missing in ${stack_dir}." 4 + ;; + 42) + show_warning_and_wait "Cannot stop stack: stack env file not found in ${stack_dir}." 4 + ;; + 43) + show_warning_and_wait "Cannot stop stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + ;; + 44) + show_warning_and_wait "Cannot stop stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + ;; + 45) + show_warning_and_wait "Cannot stop stack: no compose files configured in metadata.json." 4 + ;; + 46) + show_warning_and_wait "Cannot stop stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + ;; + 47) + show_warning_and_wait "docker compose stop failed. Check the output above for details." 4 + ;; + *) + show_warning_and_wait "Cannot stop stack with docker compose (${compose_start_status})." 4 + ;; + esac + ;; + "Delete stack") + delete_stack_confirmation_action="$( + show_manage_stack_delete_confirmation "${stack_name}" "${stack_dir}" || true + )" + case "${delete_stack_confirmation_action}" in + "Yes") + if ! prompt_manage_stack_delete_keyword_with_cancel delete_stack_keyword "${stack_name}"; then + continue + fi + if [ "${delete_stack_keyword}" != "delete" ]; then + continue + fi + + show_warning_message "Deleting stack with docker compose resources: ${stack_name}" + if delete_stack_with_compose_from_metadata "${stack_dir}"; then + show_warning_and_wait "Stack deleted successfully with containers, networks, volumes, image, and stack directory: ${stack_name}" 5 + return "${FLOW_CONTINUE}" + else + compose_start_status=$? + fi + case "${compose_start_status}" in + 48) + show_warning_and_wait "Cannot delete stack: metadata.json is missing in ${stack_dir}." 4 + ;; + 49) + show_warning_and_wait "Cannot delete stack: stack env file not found in ${stack_dir}." 4 + ;; + 50) + show_warning_and_wait "Cannot delete stack: topology is missing in metadata.json. Re-run the topology wizard for this stack." 4 + ;; + 51) + show_warning_and_wait "Cannot delete stack via docker compose for topology '${EASY_DOCKER_COMPOSE_ERROR_DETAIL}'. Use the topology-specific runbook path." 5 + ;; + 52) + show_warning_and_wait "Cannot delete stack: no compose files configured in metadata.json." 4 + ;; + 53) + show_warning_and_wait "Cannot delete stack: compose file is missing -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 4 + ;; + 54) + show_warning_and_wait "docker compose down failed. Check the output above for details." 4 + ;; + 55) + show_warning_and_wait "Stack resources were removed, but the configured custom image could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + ;; + 56) + show_warning_and_wait "Docker resources were removed, but the stack directory could not be deleted -> ${EASY_DOCKER_COMPOSE_ERROR_DETAIL}" 5 + ;; + *) + show_warning_and_wait "Cannot delete stack with docker compose (${compose_start_status})." 4 + ;; + esac + ;; + "No" | "") + continue + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown delete-stack action: ${delete_stack_confirmation_action}" 2 + ;; + esac + ;; + "Site") + if handle_manage_stack_site_flow "${stack_name}" "${stack_dir}"; then + : + else + compose_start_status=$? + case "${compose_start_status}" in + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) + continue + ;; + esac + fi + ;; + "Back" | "") + return "${FLOW_CONTINUE}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown stack action: ${stack_action}" + ;; + esac + done +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/navigation.sh b/scripts/easy-docker/lib/app/wizard/flows/navigation.sh new file mode 100755 index 00000000..dab887d5 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/navigation.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +handle_abort_wizard_flow() { + local stack_dir="${1}" + local abort_action="" + local rollback_status=0 + + abort_action="$(show_abort_wizard_prompt "${stack_dir}" || true)" + case "${abort_action}" in + "Rollback files and return to main menu") + if rollback_stack_directory "${stack_dir}"; then + return "${FLOW_BACK_TO_MAIN}" + fi + + rollback_status=$? + if [ "${rollback_status}" -eq 2 ]; then + show_warning_and_wait "Refused rollback for unsafe path: ${stack_dir}" 2 + else + show_warning_and_wait "Could not rollback stack files: ${stack_dir}" 2 + fi + return "${FLOW_CONTINUE}" + ;; + "Keep files and return to main menu") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Unknown abort action: ${abort_action}" + return "${FLOW_CONTINUE}" + ;; + esac +} + +handle_stack_topology_flow() { + local stack_dir="${1}" + local topology_action="" + local abort_status=0 + local single_host_status=0 + local split_services_status=0 + local manage_status=0 + local stack_name="" + + while true; do + topology_action="$(show_stack_topology_menu "${stack_dir}" || true)" + case "${topology_action}" in + "Single-host" | "Single-host (recommended)") + if handle_single_host_stack_flow "${stack_dir}"; then + single_host_status="${FLOW_CONTINUE}" + else + single_host_status=$? + fi + + case "${single_host_status}" in + "${FLOW_OPEN_MANAGE_STACK}") + stack_name="${stack_dir##*/}" + if handle_manage_selected_stack_flow "${stack_name}"; then + manage_status="${FLOW_CONTINUE}" + else + manage_status=$? + fi + + case "${manage_status}" in + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) + return "${FLOW_CONTINUE}" + ;; + esac + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) ;; + esac + ;; + "Split services") + if handle_split_services_stack_flow "${stack_dir}"; then + split_services_status="${FLOW_CONTINUE}" + else + split_services_status=$? + fi + + case "${split_services_status}" in + "${FLOW_OPEN_MANAGE_STACK}") + stack_name="${stack_dir##*/}" + if handle_manage_selected_stack_flow "${stack_name}"; then + manage_status="${FLOW_CONTINUE}" + else + manage_status=$? + fi + + case "${manage_status}" in + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) + return "${FLOW_CONTINUE}" + ;; + esac + ;; + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) ;; + esac + ;; + "Abort wizard to main menu") + handle_abort_wizard_flow "${stack_dir}" + abort_status=$? + case "${abort_status}" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + *) ;; + esac + ;; + "") + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Unknown topology selection: ${topology_action}" + ;; + esac + done +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/setup.sh b/scripts/easy-docker/lib/app/wizard/flows/setup.sh new file mode 100755 index 00000000..49504a69 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/setup.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash + +handle_create_new_stack_flow() { + local setup_type="${1:-production}" + local stack_name="" + local frappe_branch="" + local stack_dir="" + local create_stack_status=0 + local stack_input_status=0 + local branch_select_status=0 + local topology_status=0 + + case "${setup_type}" in + production | development) ;; + *) + show_warning_and_wait "Unknown setup type: ${setup_type}" 2 + return "${FLOW_CONTINUE}" + ;; + esac + + while true; do + stack_name="" + if prompt_stack_name_with_cancel stack_name; then + : + else + stack_input_status=$? + if [ "${stack_input_status}" -eq "${FLOW_ABORT_INPUT}" ]; then + return "${FLOW_CONTINUE}" + fi + + show_warning_and_wait "Input canceled." + return "${FLOW_CONTINUE}" + fi + + if [ -z "${stack_name}" ]; then + return "${FLOW_CONTINUE}" + fi + + if ! is_valid_stack_name "${stack_name}"; then + show_warning_and_wait "Invalid stack name. Use letters, numbers, dot, underscore, or hyphen." 2 + continue + fi + + frappe_branch="" + if prompt_frappe_branch_with_cancel frappe_branch "${stack_name}"; then + : + else + branch_select_status=$? + if [ "${branch_select_status}" -eq "${FLOW_ABORT_INPUT}" ]; then + continue + fi + + show_warning_and_wait "Could not select Frappe branch profile." 2 + return "${FLOW_CONTINUE}" + fi + + stack_dir="" + if create_stack_directory_with_metadata stack_dir "${stack_name}" "${setup_type}" "${frappe_branch}"; then + handle_stack_topology_flow "${stack_dir}" + topology_status=$? + case "${topology_status}" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) + return "${FLOW_CONTINUE}" + ;; + esac + else + create_stack_status=$? + if [ "${create_stack_status}" -eq 2 ]; then + show_warning_and_wait "Stack already exists: ${stack_name}" 2 + continue + fi + + show_warning_and_wait "Could not create stack directory for: ${stack_name}" 2 + return "${FLOW_CONTINUE}" + fi + done +} + +handle_manage_existing_stacks_flow() { + local setup_type="${1:-production}" + local manage_action="" + local selected_stack_status=0 + local stack_names_raw="" + local -a stack_names=() + + while true; do + stack_names_raw="$(list_existing_stack_names "${setup_type}")" + if [ -z "${stack_names_raw}" ]; then + manage_action="$(show_manage_stacks_placeholder "${setup_type}" || true)" + else + mapfile -t stack_names <<<"${stack_names_raw}" + manage_action="$(show_manage_stacks_menu "${setup_type}" "${stack_names[@]}" || true)" + fi + + case "${manage_action}" in + "Back" | "") + return "${FLOW_CONTINUE}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + if [ -n "${stack_names_raw}" ] && stack_name_in_array "${manage_action}" "${stack_names[@]}"; then + if handle_manage_selected_stack_flow "${manage_action}"; then + selected_stack_status="${FLOW_CONTINUE}" + else + selected_stack_status=$? + fi + + case "${selected_stack_status}" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) ;; + esac + else + show_warning_and_wait "Unknown manage-stacks action: ${manage_action}" + fi + ;; + esac + done +} + +handle_setup_flow() { + local setup_type="${1}" + local setup_action="" + + case "${setup_type}" in + production | development) ;; + *) + show_warning_and_wait "Unknown setup type: ${setup_type}" 2 + return "${FLOW_CONTINUE}" + ;; + esac + + while true; do + case "${setup_type}" in + production) + setup_action="$(show_production_setup_menu || true)" + ;; + development) + setup_action="$(show_development_setup_menu || true)" + ;; + esac + + case "${setup_action}" in + "Create new stack") + if handle_create_new_stack_flow "${setup_type}"; then + : + else + case "$?" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) ;; + esac + fi + ;; + "Manage existing stacks") + if handle_manage_existing_stacks_flow "${setup_type}"; then + : + else + case "$?" in + "${FLOW_BACK_TO_MAIN}") + return "${FLOW_BACK_TO_MAIN}" + ;; + "${FLOW_EXIT_APP}") + return "${FLOW_EXIT_APP}" + ;; + *) ;; + esac + fi + ;; + "Back" | "") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown ${setup_type} action: ${setup_action}" + ;; + esac + done +} + +handle_production_setup_flow() { + handle_setup_flow "production" +} + +handle_development_setup_flow() { + handle_setup_flow "development" +} + +handle_environment_check_flow() { + local environment_action="" + + environment_action="$(show_environment_status || true)" + case "${environment_action}" in + "Back to main menu" | "") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown environment action: ${environment_action}" + return "${FLOW_CONTINUE}" + ;; + esac +} + +run_easy_docker_app() { + local action="" + local handler_status=0 + + enter_alt_screen + render_main_screen 1 + + while true; do + action="$(show_main_menu || true)" + + if [ -z "${action}" ]; then + return 0 + fi + + case "${action}" in + "Production Stack") + if handle_production_setup_flow; then + handler_status="${FLOW_CONTINUE}" + else + handler_status=$? + fi + case "${handler_status}" in + "${FLOW_BACK_TO_MAIN}") + render_main_screen 1 + ;; + "${FLOW_EXIT_APP}") + return 0 + ;; + *) ;; + esac + ;; + "Development Stack") + if handle_development_setup_flow; then + handler_status="${FLOW_CONTINUE}" + else + handler_status=$? + fi + case "${handler_status}" in + "${FLOW_BACK_TO_MAIN}") + render_main_screen 1 + ;; + "${FLOW_EXIT_APP}") + return 0 + ;; + *) ;; + esac + ;; + "Tools") + if handle_tools_flow; then + handler_status="${FLOW_CONTINUE}" + else + handler_status=$? + fi + case "${handler_status}" in + "${FLOW_BACK_TO_MAIN}") + render_main_screen 1 + ;; + "${FLOW_EXIT_APP}") + return 0 + ;; + *) ;; + esac + ;; + "Environment check") + if handle_environment_check_flow; then + handler_status="${FLOW_CONTINUE}" + else + handler_status=$? + fi + case "${handler_status}" in + "${FLOW_BACK_TO_MAIN}") + render_main_screen 1 + ;; + "${FLOW_EXIT_APP}") + return 0 + ;; + *) ;; + esac + ;; + "Exit") + return 0 + ;; + *) + show_warning_and_wait "Unknown action: ${action}" + ;; + esac + done +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/single_host.sh b/scripts/easy-docker/lib/app/wizard/flows/single_host.sh new file mode 100755 index 00000000..fa3e7436 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/single_host.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +handle_single_host_stack_flow() { + local stack_dir="${1}" + local proxy_mode="" + local database_choice="" + local redis_choice="" + local stack_env_path="" + local stack_apps_path="" + local generated_compose_path="" + local save_selection_status=0 + local render_compose_status=0 + + proxy_mode="$(show_single_host_proxy_menu "${stack_dir}" || true)" + case "${proxy_mode}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_single_host_proxy_mode_id "${proxy_mode}" >/dev/null; then + show_warning_and_wait "Unknown proxy mode: ${proxy_mode}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + database_choice="$(show_single_host_database_menu "${stack_dir}" || true)" + case "${database_choice}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_single_host_database_id "${database_choice}" >/dev/null; then + show_warning_and_wait "Unknown database choice: ${database_choice}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + redis_choice="$(show_single_host_redis_menu "${stack_dir}" || true)" + case "${redis_choice}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_single_host_redis_id "${redis_choice}" >/dev/null; then + show_warning_and_wait "Unknown Redis choice: ${redis_choice}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + if save_single_host_selection "${stack_dir}" "${proxy_mode}" "${database_choice}" "${redis_choice}"; then + : + else + save_selection_status=$? + if [ "${save_selection_status}" -eq 2 ] || [ "${save_selection_status}" -eq 130 ]; then + return "${FLOW_CONTINUE}" + fi + + show_warning_and_wait "Could not save single-host selection for stack: ${stack_dir}" 2 + return "${FLOW_CONTINUE}" + fi + + if render_stack_compose_from_metadata "${stack_dir}"; then + : + else + render_compose_status=$? + stack_env_path="$(get_stack_env_path "${stack_dir}")" + stack_apps_path="${stack_dir}/apps.json" + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + show_warning_and_wait "Selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}, but compose rendering failed (${render_compose_status}) for ${generated_compose_path}." 3 + return "${FLOW_CONTINUE}" + fi + + stack_env_path="$(get_stack_env_path "${stack_dir}")" + stack_apps_path="${stack_dir}/apps.json" + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + show_warning_and_wait "Single-host selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}. Rendered compose: ${generated_compose_path}." 3 + return "${FLOW_OPEN_MANAGE_STACK}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/split_services.sh b/scripts/easy-docker/lib/app/wizard/flows/split_services.sh new file mode 100755 index 00000000..354d214c --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/split_services.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash + +handle_split_services_stack_flow() { + local stack_dir="${1}" + local data_mode="" + local database_choice="" + local redis_choice="" + local proxy_mode="" + local summary_action="" + local stack_env_path="" + local stack_apps_path="" + local generated_compose_path="" + local save_selection_status=0 + local render_compose_status=0 + + data_mode="$(show_split_services_data_mode_menu "${stack_dir}" || true)" + case "${data_mode}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_split_services_data_mode_id "${data_mode}" >/dev/null; then + show_warning_and_wait "Unknown data services mode: ${data_mode}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + database_choice="$(show_split_services_database_menu "${stack_dir}" || true)" + case "${database_choice}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_single_host_database_id "${database_choice}" >/dev/null; then + show_warning_and_wait "Unknown database choice: ${database_choice}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + redis_choice="$(show_split_services_redis_mode_menu "${stack_dir}" || true)" + case "${redis_choice}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_split_services_redis_id "${redis_choice}" >/dev/null; then + show_warning_and_wait "Unknown Redis choice: ${redis_choice}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + proxy_mode="$(show_split_services_proxy_mode_menu "${stack_dir}" || true)" + case "${proxy_mode}" in + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + *) + if ! get_single_host_proxy_mode_id "${proxy_mode}" >/dev/null; then + show_warning_and_wait "Unknown reverse proxy mode: ${proxy_mode}" + return "${FLOW_CONTINUE}" + fi + ;; + esac + + summary_action="$(show_split_services_summary_menu "${stack_dir}" "${data_mode}" "${database_choice}" "${redis_choice}" "${proxy_mode}" || true)" + case "${summary_action}" in + "Yes, write stack files") ;; + "Back to topology selection" | "") + return "${FLOW_CONTINUE}" + ;; + "Abort wizard to main menu") + handle_abort_wizard_flow "${stack_dir}" + return $? + ;; + *) + show_warning_and_wait "Unknown split-services summary action: ${summary_action}" + return "${FLOW_CONTINUE}" + ;; + esac + + if save_split_services_selection "${stack_dir}" "${proxy_mode}" "${data_mode}" "${database_choice}" "${redis_choice}"; then + : + else + save_selection_status=$? + if [ "${save_selection_status}" -eq 2 ] || [ "${save_selection_status}" -eq 130 ]; then + return "${FLOW_CONTINUE}" + fi + case "${save_selection_status}" in + 31) + show_warning_and_wait "Could not write the split-services env file for stack: ${stack_dir}" 3 + ;; + 32) + show_warning_and_wait "Could not write the split-services wizard metadata in ${stack_dir}/metadata.json." 3 + ;; + 33) + show_warning_and_wait "Split-services app selection is empty. Select at least one app before writing stack files." 3 + ;; + 34) + show_warning_and_wait "Could not write the selected app metadata in ${stack_dir}/metadata.json." 3 + ;; + 35) + show_warning_and_wait "Could not generate ${stack_dir}/apps.json from the selected split-services apps." 3 + ;; + *) + show_warning_and_wait "Could not save split-services selection for stack: ${stack_dir} (${save_selection_status})." 3 + ;; + esac + return "${FLOW_CONTINUE}" + fi + + if render_stack_compose_from_metadata "${stack_dir}"; then + : + else + render_compose_status=$? + stack_env_path="$(get_stack_env_path "${stack_dir}")" + stack_apps_path="${stack_dir}/apps.json" + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + show_warning_and_wait "Selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}, but compose rendering failed (${render_compose_status}) for ${generated_compose_path}." 3 + return "${FLOW_CONTINUE}" + fi + + stack_env_path="$(get_stack_env_path "${stack_dir}")" + stack_apps_path="${stack_dir}/apps.json" + generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")" + show_warning_and_wait "Split-services selection saved in ${stack_dir}/metadata.json, ${stack_env_path}, and ${stack_apps_path}. Rendered compose: ${generated_compose_path}." 3 + return "${FLOW_OPEN_MANAGE_STACK}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/tools.sh b/scripts/easy-docker/lib/app/wizard/flows/tools.sh new file mode 100755 index 00000000..088824c6 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/tools.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash + +prompt_tools_apps_catalog_value_with_back() { + local result_var="${1}" + local field_label="${2}" + local help_text="${3}" + local placeholder="${4:-}" + local input_value="" + local input_status=0 + + input_value="$(prompt_tools_apps_catalog_input "${field_label}" "${help_text}" "${placeholder}")" + input_status=$? + if [ "${input_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + input_value="$(printf '%s' "${input_value}" | tr -d '\r\n')" + case "${input_value}" in + /back | /BACK | /Back) + return "${FLOW_ABORT_INPUT}" + ;; + esac + + printf -v "${result_var}" "%s" "${input_value}" + return "${FLOW_CONTINUE}" +} + +prompt_tools_apps_default_branch_from_csv_with_back() { + local result_var="${1}" + local branches_csv="${2}" + local selection="" + local selection_status=0 + local branch="" + local -a branch_options=() + + IFS=',' read -r -a branch_options <<<"${branches_csv}" + if [ "${#branch_options[@]}" -eq 0 ]; then + return 1 + fi + + selection="$(show_tools_apps_default_branch_menu "${branch_options[@]}")" + selection_status=$? + if [ "${selection_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + case "${selection}" in + "" | "Back") + return "${FLOW_ABORT_INPUT}" + ;; + esac + + branch="${selection}" + if ! is_valid_predefined_app_branch "${branch}"; then + return 1 + fi + if ! csv_contains_branch "${branches_csv}" "${branch}"; then + return 1 + fi + + printf -v "${result_var}" "%s" "${branch}" + return "${FLOW_CONTINUE}" +} + +run_add_app_catalog_entry_wizard() { + local app_id="" + local app_label="" + local app_repo="" + local app_branches_csv="" + local normalized_branches_csv="" + local app_default_branch="" + local input_status=0 + + if ! get_predefined_apps_catalog_entries >/dev/null 2>&1; then + show_warning_and_wait "Could not load scripts/easy-docker/config/apps.tsv. Check format before adding new entries." 3 + return 1 + fi + + while true; do + if prompt_tools_apps_catalog_value_with_back \ + app_label \ + "App Label" \ + "Display name used in the app selection list." \ + "My Custom App"; then + : + else + input_status=$? + return "${input_status}" + fi + + trim_predefined_catalog_field app_label "${app_label}" + if [ -z "${app_label}" ]; then + show_warning_and_wait "App label is required." 2 + continue + fi + + if predefined_app_catalog_has_label "${app_label}"; then + show_warning_and_wait "App label already exists in apps.tsv: ${app_label}" 2 + continue + fi + + if ! generate_predefined_app_id_from_label app_id "${app_label}"; then + show_warning_and_wait "Could not generate a valid app id from label. Use letters/numbers and simple separators." 2 + continue + fi + + if predefined_app_catalog_has_id "${app_id}"; then + show_warning_and_wait "Generated app id already exists (${app_id}). Choose a different label." 2 + continue + fi + + break + done + + while true; do + if prompt_tools_apps_catalog_value_with_back \ + app_repo \ + "Repository URL" \ + "Git repository URL for this app." \ + "https://github.com/acme/my-custom-app"; then + : + else + input_status=$? + return "${input_status}" + fi + + if ! is_valid_predefined_app_repo "${app_repo}"; then + show_warning_and_wait "Invalid repository URL. Use https/http/ssh/git formats." 2 + continue + fi + + break + done + + while true; do + if prompt_tools_apps_catalog_value_with_back \ + app_branches_csv \ + "Branches (CSV)" \ + "Comma-separated branches for selection. Example: version-15,version-16,develop" \ + "version-15,version-16,develop"; then + : + else + input_status=$? + return "${input_status}" + fi + + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + show_warning_and_wait "Invalid branch list. Use a comma-separated list with valid branch names." 2 + continue + fi + + break + done + + while true; do + if prompt_tools_apps_default_branch_from_csv_with_back app_default_branch "${normalized_branches_csv}"; then + : + else + input_status=$? + if [ "${input_status}" -eq "${FLOW_ABORT_INPUT}" ]; then + return "${FLOW_ABORT_INPUT}" + fi + show_warning_and_wait "Could not select default branch from branch list." 2 + return "${input_status}" + fi + break + done + + if ! append_predefined_app_catalog_entry "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}"; then + show_warning_and_wait "Could not append app entry to scripts/easy-docker/config/apps.tsv." 3 + return 1 + fi + + show_warning_and_wait "App added to apps.tsv: ${app_label} (${app_id})" 2 + return 0 +} + +handle_tools_flow() { + local tools_action="" + + while true; do + tools_action="$(show_tools_menu || true)" + + case "${tools_action}" in + "Add Apps for App Selection") + run_add_app_catalog_entry_wizard || true + ;; + "Back to main menu" | "") + return "${FLOW_BACK_TO_MAIN}" + ;; + "Exit and close easy-docker") + return "${FLOW_EXIT_APP}" + ;; + *) + show_warning_and_wait "Unknown tools action: ${tools_action}" + ;; + esac + done +} diff --git a/scripts/easy-docker/lib/app/wizard/single_host.sh b/scripts/easy-docker/lib/app/wizard/single_host.sh new file mode 100755 index 00000000..1a99dd71 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/single_host.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash + +get_single_host_proxy_mode_id() { + local proxy_mode="${1}" + + case "${proxy_mode}" in + "Traefik (HTTP, built-in proxy)") + printf 'traefik-http\n' + ;; + "Traefik (HTTPS + Let's Encrypt)") + printf 'traefik-https\n' + ;; + "nginx-proxy (HTTP)") + printf 'nginxproxy-http\n' + ;; + "nginx-proxy + acme-companion (HTTPS)") + printf 'nginxproxy-https\n' + ;; + "Caddy (external reverse proxy)") + printf 'caddy-external\n' + ;; + "No reverse proxy (direct :8080)") + printf 'no-proxy\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_proxy_overrides() { + local proxy_mode="${1}" + + case "${proxy_mode}" in + "Traefik (HTTP, built-in proxy)") + printf 'overrides/compose.proxy.yaml\n' + ;; + "Traefik (HTTPS + Let's Encrypt)") + printf 'overrides/compose.https.yaml\n' + ;; + "nginx-proxy (HTTP)") + printf 'overrides/compose.nginxproxy.yaml\n' + ;; + "nginx-proxy + acme-companion (HTTPS)") + printf 'overrides/compose.nginxproxy.yaml\noverrides/compose.nginxproxy-ssl.yaml\n' + ;; + "Caddy (external reverse proxy)" | "No reverse proxy (direct :8080)") + printf 'overrides/compose.noproxy.yaml\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_database_id() { + local database_choice="${1}" + + case "${database_choice}" in + "MariaDB (recommended)") + printf 'mariadb\n' + ;; + "PostgreSQL") + printf 'postgres\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_database_override() { + local database_choice="${1}" + + case "${database_choice}" in + "MariaDB (recommended)") + printf 'overrides/compose.mariadb.yaml\n' + ;; + "PostgreSQL") + printf 'overrides/compose.postgres.yaml\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_redis_id() { + local redis_choice="${1}" + + case "${redis_choice}" in + "Include Redis (recommended)") + printf 'enabled\n' + ;; + "Skip Redis (experienced users only)") + printf 'disabled\n' + ;; + *) + return 1 + ;; + esac +} + +get_single_host_redis_override() { + local redis_choice="${1}" + + case "${redis_choice}" in + "Include Redis (recommended)") + printf 'overrides/compose.redis.yaml\n' + ;; + "Skip Redis (experienced users only)") + printf '' + ;; + *) + return 1 + ;; + esac +} + +persist_single_host_env_file() { + local stack_dir="${1}" + local env_lines="${2}" + local env_path="" + local env_tmp_path="" + local generated_at="" + + env_path="$(get_stack_env_path "${stack_dir}")" + env_tmp_path="${env_path}.tmp" + generated_at="$(get_current_utc_timestamp)" + + if ! { + printf '# Generated by easy-docker wizard at %s\n' "${generated_at}" + printf '# Adjust values as needed for this stack.\n' + if [ -n "${env_lines}" ]; then + printf '\n%s\n' "${env_lines}" + else + printf '\n# No additional environment variables configured by the wizard.\n' + fi + } >"${env_tmp_path}"; then + rm -f -- "${env_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${env_tmp_path}" "${env_path}"; then + rm -f -- "${env_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +persist_single_host_selection_metadata() { + local stack_dir="${1}" + local proxy_mode_id="${2}" + local database_id="${3}" + local redis_id="${4}" + local compose_files_lines="${5}" + local env_lines="${6}" + local updated_at="" + local compose_files_json="" + local env_json_object="" + local wizard_json_object="" + + updated_at="$(get_current_utc_timestamp)" + compose_files_json="$(build_compose_files_json_array "${compose_files_lines}")" + env_json_object="$(build_env_json_object "${env_lines}")" + + if ! wizard_json_object="$( + cat </dev/null 2>&1 +} + +docker_daemon_running() { + docker info >/dev/null 2>&1 +} + +docker_supports_command() { + docker "$@" --help >/dev/null 2>&1 +} + +get_missing_docker_commands() { + local missing=() + local subcommand="" + + for subcommand in ps exec inspect cp build; do + if ! docker_supports_command "${subcommand}"; then + missing+=("docker ${subcommand}") + fi + done + + for subcommand in config up down logs exec pull ps; do + if ! docker_supports_command compose "${subcommand}"; then + missing+=("docker compose ${subcommand}") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + return 0 + fi + + printf '%s\n' "${missing[@]}" + return 1 +} + +format_missing_commands_list() { + local missing_commands="${1}" + local missing_list="" + + missing_list="$(printf '%s' "${missing_commands}" | tr '\n' ',')" + missing_list="${missing_list%,}" + missing_list="${missing_list#,}" + printf '%s\n' "${missing_list}" +} + +ensure_docker() { + local missing_commands="" + local missing_list="" + + if ! command_exists docker; then + echo "docker is not installed." + print_docker_install_guidance + return 1 + fi + + if ! docker_compose_available; then + echo "docker compose (Compose v2 command) is not available." + print_docker_compose_install_guidance + return 1 + fi + + if ! docker_daemon_running; then + echo "docker daemon is not running." + print_docker_daemon_start_guidance + return 1 + fi + + if ! missing_commands="$(get_missing_docker_commands)"; then + missing_list="$(format_missing_commands_list "${missing_commands}")" + echo "Missing required docker commands: ${missing_list}" + print_docker_command_support_guidance + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/checks/jq.sh b/scripts/easy-docker/lib/checks/jq.sh new file mode 100755 index 00000000..e259eb43 --- /dev/null +++ b/scripts/easy-docker/lib/checks/jq.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +jq_check_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=scripts/easy-docker/lib/install/jq/load.sh +source "${jq_check_dir}/../install/jq/load.sh" diff --git a/scripts/easy-docker/lib/core/commands.sh b/scripts/easy-docker/lib/core/commands.sh new file mode 100755 index 00000000..6933bf03 --- /dev/null +++ b/scripts/easy-docker/lib/core/commands.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +command_exists() { + command -v "${1}" >/dev/null 2>&1 || command -v "${1}.exe" >/dev/null 2>&1 +} + +run_with_privileges() { + if command_exists sudo; then + sudo "$@" + return + fi + + "$@" +} + +copy_binary() { + local source_path="${1}" + local target_path="${2}" + + if command_exists install; then + install -m 0755 "${source_path}" "${target_path}" + return $? + fi + + cp "${source_path}" "${target_path}" && chmod +x "${target_path}" +} + +copy_binary_with_privileges() { + local source_path="${1}" + local target_path="${2}" + + if command_exists install; then + run_with_privileges install -m 0755 "${source_path}" "${target_path}" + return $? + fi + + run_with_privileges cp "${source_path}" "${target_path}" && + run_with_privileges chmod +x "${target_path}" +} diff --git a/scripts/easy-docker/lib/core/json.sh b/scripts/easy-docker/lib/core/json.sh new file mode 100755 index 00000000..a053338f --- /dev/null +++ b/scripts/easy-docker/lib/core/json.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +get_easy_docker_jq_command() { + if command -v jq >/dev/null 2>&1; then + printf 'jq\n' + return 0 + fi + + if command -v jq.exe >/dev/null 2>&1; then + printf 'jq.exe\n' + return 0 + fi + + return 1 +} + +easy_docker_require_jq() { + get_easy_docker_jq_command >/dev/null 2>&1 +} + +easy_docker_run_jq() { + local jq_command="" + + jq_command="$(get_easy_docker_jq_command)" || return 127 + "${jq_command}" "$@" +} diff --git a/scripts/easy-docker/lib/core/messages.sh b/scripts/easy-docker/lib/core/messages.sh new file mode 100755 index 00000000..fca2524a --- /dev/null +++ b/scripts/easy-docker/lib/core/messages.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +print_manual_gum_install_guidance() { + echo "Install gum manually: https://github.com/charmbracelet/gum#installation" + echo "If installed into ~/.local/bin, add it to PATH first." +} + +print_docker_install_guidance() { + echo "Install Docker first: https://docs.docker.com/get-started/get-docker/" +} + +print_docker_compose_install_guidance() { + echo "This script requires Docker Compose v2 via the 'docker compose' command." + echo "Docker Desktop includes it by default." + echo "On Linux Engine-only setups, install the Docker Compose CLI plugin package (commonly 'docker-compose-plugin')." + echo "Setup docs:" + echo "https://docs.docker.com/compose/install/" + echo "Note: this script uses 'docker compose' (Compose v2), not the old standalone 'docker-compose'." +} + +print_docker_daemon_start_guidance() { + echo "Start the Docker daemon/service and retry." + echo "If you use Docker Desktop, ensure it is running." +} + +print_docker_command_support_guidance() { + echo "Update Docker to a recent version and ensure Compose v2 is available as 'docker compose'." + echo "Standard 'docker' and 'docker compose' commands are required." +} + +print_jq_install_guidance() { + print_manual_jq_install_guidance +} + +print_manual_jq_install_guidance() { + echo "Install jq first: https://jqlang.org/download/" + echo "On Windows, you can also use: winget install jqlang.jq" + echo "Ensure the 'jq' or 'jq.exe' command is available on PATH before running easy-docker." +} diff --git a/scripts/easy-docker/lib/install/gum/assets.sh b/scripts/easy-docker/lib/install/gum/assets.sh new file mode 100755 index 00000000..977b5975 --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/assets.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash + +get_gum_checksums_path() { + local gum_lib_dir="" + + gum_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + printf '%s/../../../config/gum-checksums.tsv\n' "${gum_lib_dir}" +} + +get_pinned_gum_version() { + local checksums_path="" + local release_version="" + + checksums_path="$(get_gum_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + return 1 + fi + + release_version="$( + awk -F '\t' ' + /^[[:space:]]*#/ { next } + NF < 3 { next } + { + print $1 + exit + } + ' "${checksums_path}" + )" + if [ -z "${release_version}" ]; then + return 1 + fi + + printf '%s\n' "${release_version}" +} + +get_pinned_gum_asset_checksum() { + local release_version="${1}" + local asset_name="${2}" + local checksums_path="" + local expected_checksum="" + + checksums_path="$(get_gum_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + return 1 + fi + + expected_checksum="$( + awk -F '\t' -v release_version="${release_version}" -v asset_name="${asset_name}" ' + /^[[:space:]]*#/ { next } + NF < 3 { next } + $1 == release_version && $2 == asset_name { + print $3 + exit + } + ' "${checksums_path}" + )" + if [ -z "${expected_checksum}" ]; then + return 1 + fi + + printf '%s\n' "${expected_checksum}" +} + +sha256_verification_available() { + command_exists sha256sum || + command_exists shasum || + command_exists openssl || + command_exists certutil +} + +compute_file_sha256() { + local file_path="${1}" + local hash_input_path="${file_path}" + + if command_exists sha256sum; then + sha256sum "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists shasum; then + shasum -a 256 "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists openssl; then + openssl dgst -sha256 -r "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists certutil; then + if command_exists cygpath; then + hash_input_path="$(cygpath -w "${file_path}" 2>/dev/null || printf '%s' "${file_path}")" + fi + + certutil -hashfile "${hash_input_path}" SHA256 2>/dev/null | + awk 'NR == 2 { gsub(/ /, "", $0); print tolower($0); exit }' + return $? + fi + + return 1 +} + +verify_file_sha256() { + local file_path="${1}" + local expected_checksum="${2}" + local actual_checksum="" + + actual_checksum="$(compute_file_sha256 "${file_path}" || true)" + if [ -z "${actual_checksum}" ]; then + return 1 + fi + + if [ "${actual_checksum}" != "$(printf '%s' "${expected_checksum}" | tr '[:upper:]' '[:lower:]')" ]; then + return 1 + fi + + return 0 +} + +get_gum_asset_candidates() { + local release_version="${1}" + local gum_os="${2}" + local gum_arch="${3}" + local os_alias="" + local arch_alias="" + local ext="" + + while IFS= read -r os_alias; do + while IFS= read -r arch_alias; do + for ext in tar.gz zip; do + printf 'gum_%s_%s_%s.%s\n' "${release_version}" "${os_alias}" "${arch_alias}" "${ext}" + done + done < <(get_arch_aliases "${gum_arch}") + done < <(get_os_aliases "${gum_os}") +} + +extract_gum_asset() { + local asset_path="${1}" + local extract_dir="${2}" + + mkdir -p "${extract_dir}" + + case "${asset_path}" in + *.tar.gz) + if ! command_exists tar; then + echo "tar is required to extract gum tar.gz assets." + return 1 + fi + tar -xzf "${asset_path}" -C "${extract_dir}" + ;; + *.zip) + if ! command_exists unzip; then + echo "unzip is required to extract gum zip assets." + return 1 + fi + unzip -q "${asset_path}" -d "${extract_dir}" + ;; + *) + return 1 + ;; + esac +} + +find_gum_binary() { + local search_dir="${1}" + local found_path="" + + found_path="$( + find "${search_dir}" -type f \( -name "gum" -o -name "gum.exe" \) 2>/dev/null | + head -n 1 + )" + + if [ -n "${found_path}" ]; then + printf '%s\n' "${found_path}" + return 0 + fi + + return 1 +} diff --git a/scripts/easy-docker/lib/install/gum/ensure.sh b/scripts/easy-docker/lib/install/gum/ensure.sh new file mode 100755 index 00000000..e69e2feb --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/ensure.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +should_use_github_fallback() { + local answer="" + + if [ ! -t 0 ]; then + echo "GitHub fallback prompt requires an interactive terminal." + return 1 + fi + + printf "Use GitHub binary fallback for gum? [y/N]: " + read -r answer + + case "${answer}" in + y | Y | yes | YES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +ensure_gum() { + local disable_installation_fallback="${1:-0}" + + if command_exists gum; then + return 0 + fi + + echo "gum is not installed. Trying package manager installation..." + + if install_gum_with_package_manager; then + hash -r + fi + + if command_exists gum; then + echo "gum was installed successfully." + return 0 + fi + + if [ "${disable_installation_fallback}" = "1" ]; then + echo "Installation fallback is disabled." + print_manual_gum_install_guidance + return 1 + fi + + if should_use_github_fallback; then + echo "Trying pinned GitHub release fallback..." + if install_gum_from_github_release; then + hash -r + fi + else + echo "GitHub fallback was not selected." + print_manual_gum_install_guidance + return 1 + fi + + if command_exists gum; then + echo "gum was installed successfully." + return 0 + fi + + echo "Automatic installation failed." + print_manual_gum_install_guidance + return 1 +} diff --git a/scripts/easy-docker/lib/install/gum/github_release.sh b/scripts/easy-docker/lib/install/gum/github_release.sh new file mode 100755 index 00000000..8ad33d09 --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/github_release.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +cleanup_gum_tmp_dir() { + local tmp_dir="${1:-}" + + if [ -n "${tmp_dir}" ] && [ -d "${tmp_dir}" ]; then + rm -rf "${tmp_dir}" + fi +} + +install_gum_from_github_release() { + local release_version="" + local checksums_path="" + local asset_name="" + local asset_path="" + local download_url="" + local tmp_dir="" + local extract_dir="" + local target_dir="" + local gum_binary_path="" + local target_binary_name="gum" + local gum_os="" + local gum_arch="" + local expected_checksum="" + + if ! command_exists curl; then + echo "curl is required for the GitHub fallback." + return 1 + fi + + if ! read -r gum_os gum_arch < <(detect_gum_platform); then + echo "Unsupported platform for automatic GitHub fallback." + return 1 + fi + + release_version="$(get_pinned_gum_version || true)" + if [ -z "${release_version}" ]; then + echo "Could not determine the pinned gum release version." + return 1 + fi + + checksums_path="$(get_gum_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + echo "Pinned gum checksum file is missing: ${checksums_path}" + return 1 + fi + + if ! sha256_verification_available; then + echo "A SHA256 verification tool is required for the GitHub fallback." + return 1 + fi + + tmp_dir="$(mktemp -d 2>/dev/null || true)" + if [ -z "${tmp_dir}" ] || [ ! -d "${tmp_dir}" ]; then + echo "Failed to create temporary directory for gum installation." + return 1 + fi + extract_dir="${tmp_dir}/extract" + + while IFS= read -r asset_name; do + expected_checksum="$(get_pinned_gum_asset_checksum "${release_version}" "${asset_name}" || true)" + if [ -z "${expected_checksum}" ]; then + continue + fi + + asset_path="${tmp_dir}/${asset_name}" + download_url="https://github.com/charmbracelet/gum/releases/download/v${release_version}/${asset_name}" + + if ! curl -fsSL "${download_url}" -o "${asset_path}"; then + continue + fi + + if ! verify_file_sha256 "${asset_path}" "${expected_checksum}"; then + echo "Checksum verification failed for ${asset_name}." + continue + fi + + rm -rf "${extract_dir}" + mkdir -p "${extract_dir}" + + if ! extract_gum_asset "${asset_path}" "${extract_dir}"; then + continue + fi + + gum_binary_path="$(find_gum_binary "${extract_dir}" || true)" + if [ -n "${gum_binary_path}" ]; then + break + fi + done < <(get_gum_asset_candidates "${release_version}" "${gum_os}" "${gum_arch}") + + if [ -z "${gum_binary_path}" ]; then + cleanup_gum_tmp_dir "${tmp_dir}" + echo "No compatible, verified gum binary was found in the pinned GitHub release assets." + return 1 + fi + + if [[ "${gum_binary_path}" == *.exe ]]; then + target_binary_name="gum.exe" + fi + + if [ "${gum_os}" != "Windows" ] && [ -w "/usr/local/bin" ]; then + target_dir="/usr/local/bin" + if copy_binary "${gum_binary_path}" "${target_dir}/${target_binary_name}"; then + cleanup_gum_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + if [ "${gum_os}" != "Windows" ] && command_exists sudo; then + if copy_binary_with_privileges "${gum_binary_path}" "/usr/local/bin/${target_binary_name}"; then + cleanup_gum_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + if [ -n "${HOME:-}" ]; then + target_dir="${HOME}/.local/bin" + mkdir -p "${target_dir}" + if copy_binary "${gum_binary_path}" "${target_dir}/${target_binary_name}"; then + cleanup_gum_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + cleanup_gum_tmp_dir "${tmp_dir}" + echo "Failed to install gum binary from GitHub release." + return 1 +} diff --git a/scripts/easy-docker/lib/install/gum/load.sh b/scripts/easy-docker/lib/install/gum/load.sh new file mode 100755 index 00000000..d3983a57 --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/load.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +load_gum_install_modules() { + local gum_lib_dir="" + gum_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/install/gum/platform.sh + source "${gum_lib_dir}/platform.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/assets.sh + source "${gum_lib_dir}/assets.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/package_manager.sh + source "${gum_lib_dir}/package_manager.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/github_release.sh + source "${gum_lib_dir}/github_release.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/ensure.sh + source "${gum_lib_dir}/ensure.sh" +} + +load_gum_install_modules diff --git a/scripts/easy-docker/lib/install/gum/package_manager.sh b/scripts/easy-docker/lib/install/gum/package_manager.sh new file mode 100755 index 00000000..8d6884c1 --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/package_manager.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +install_gum_with_package_manager() { + local pm_attempted=0 + + if command_exists brew; then + pm_attempted=1 + if brew install gum; then + return 0 + fi + fi + + if command_exists apt-get; then + pm_attempted=1 + if run_with_privileges apt-get update && run_with_privileges apt-get install -y gum; then + return 0 + fi + fi + + if command_exists dnf; then + pm_attempted=1 + if run_with_privileges dnf install -y gum; then + return 0 + fi + fi + + if command_exists pacman; then + pm_attempted=1 + if run_with_privileges pacman -Sy --noconfirm gum; then + return 0 + fi + fi + + if command_exists zypper; then + pm_attempted=1 + if run_with_privileges zypper --non-interactive install gum; then + return 0 + fi + fi + + if command_exists winget; then + pm_attempted=1 + if winget install --id Charmbracelet.Gum -e --accept-source-agreements --accept-package-agreements; then + return 0 + fi + fi + + if command_exists choco; then + pm_attempted=1 + if choco install gum -y; then + return 0 + fi + fi + + if [ "${pm_attempted}" -eq 0 ]; then + echo "No supported package manager was found." + else + echo "Package manager installation did not succeed." + fi + + return 1 +} diff --git a/scripts/easy-docker/lib/install/gum/platform.sh b/scripts/easy-docker/lib/install/gum/platform.sh new file mode 100755 index 00000000..5f145aac --- /dev/null +++ b/scripts/easy-docker/lib/install/gum/platform.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +detect_gum_platform() { + local raw_os="" + local raw_arch="" + local gum_os="" + local gum_arch="" + + raw_os="$(uname -s 2>/dev/null || echo unknown)" + raw_arch="$(uname -m 2>/dev/null || echo unknown)" + + case "${raw_os}" in + Linux*) + gum_os="Linux" + ;; + Darwin*) + gum_os="Darwin" + ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + gum_os="Windows" + ;; + *) + return 1 + ;; + esac + + case "${raw_arch}" in + x86_64 | amd64) + gum_arch="x86_64" + ;; + aarch64 | arm64) + gum_arch="arm64" + ;; + armv7l | armv7) + gum_arch="armv7" + ;; + *) + return 1 + ;; + esac + + printf '%s %s\n' "${gum_os}" "${gum_arch}" + return 0 +} + +get_os_aliases() { + local os_name="${1}" + local os_lower="" + + os_lower="$(printf '%s' "${os_name}" | tr '[:upper:]' '[:lower:]')" + + if [ "${os_lower}" = "${os_name}" ]; then + printf '%s\n' "${os_name}" + return + fi + + printf '%s\n%s\n' "${os_name}" "${os_lower}" +} + +get_arch_aliases() { + case "${1}" in + x86_64) + printf '%s\n%s\n' "x86_64" "amd64" + ;; + arm64) + printf '%s\n%s\n' "arm64" "aarch64" + ;; + armv7) + printf '%s\n%s\n' "armv7" "armv7l" + ;; + *) + printf '%s\n' "${1}" + ;; + esac +} diff --git a/scripts/easy-docker/lib/install/jq/assets.sh b/scripts/easy-docker/lib/install/jq/assets.sh new file mode 100755 index 00000000..786ed3a6 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/assets.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash + +get_jq_checksums_path() { + local jq_lib_dir="" + + jq_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + printf '%s/../../../config/jq-checksums.tsv\n' "${jq_lib_dir}" +} + +get_pinned_jq_version() { + local checksums_path="" + local release_version="" + + checksums_path="$(get_jq_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + return 1 + fi + + release_version="$( + awk -F '\t' ' + /^[[:space:]]*#/ { next } + NF < 3 { next } + { + print $1 + exit + } + ' "${checksums_path}" + )" + if [ -z "${release_version}" ]; then + return 1 + fi + + printf '%s\n' "${release_version}" +} + +get_pinned_jq_asset_checksum() { + local release_version="${1}" + local asset_name="${2}" + local checksums_path="" + local expected_checksum="" + + checksums_path="$(get_jq_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + return 1 + fi + + expected_checksum="$( + awk -F '\t' -v release_version="${release_version}" -v asset_name="${asset_name}" ' + /^[[:space:]]*#/ { next } + NF < 3 { next } + $1 == release_version && $2 == asset_name { + print $3 + exit + } + ' "${checksums_path}" + )" + if [ -z "${expected_checksum}" ]; then + return 1 + fi + + printf '%s\n' "${expected_checksum}" +} + +sha256_verification_available_for_jq() { + command_exists sha256sum || + command_exists shasum || + command_exists openssl || + command_exists certutil +} + +compute_file_sha256_for_jq() { + local file_path="${1}" + local hash_input_path="${file_path}" + + if command_exists sha256sum; then + sha256sum "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists shasum; then + shasum -a 256 "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists openssl; then + openssl dgst -sha256 -r "${file_path}" | awk '{print tolower($1)}' + return $? + fi + + if command_exists certutil; then + if command_exists cygpath; then + hash_input_path="$(cygpath -w "${file_path}" 2>/dev/null || printf '%s' "${file_path}")" + fi + + certutil -hashfile "${hash_input_path}" SHA256 2>/dev/null | + awk 'NR == 2 { gsub(/ /, "", $0); print tolower($0); exit }' + return $? + fi + + return 1 +} + +verify_file_sha256_for_jq() { + local file_path="${1}" + local expected_checksum="${2}" + local actual_checksum="" + + actual_checksum="$(compute_file_sha256_for_jq "${file_path}" || true)" + if [ -z "${actual_checksum}" ]; then + return 1 + fi + + if [ "${actual_checksum}" != "$(printf '%s' "${expected_checksum}" | tr '[:upper:]' '[:lower:]')" ]; then + return 1 + fi + + return 0 +} + +get_jq_asset_candidates() { + local jq_os="${1}" + local jq_arch="${2}" + + case "${jq_os}:${jq_arch}" in + linux:amd64) + printf '%s\n%s\n' "jq-linux-amd64" "jq-linux64" + ;; + linux:arm64) + printf '%s\n' "jq-linux-arm64" + ;; + linux:armhf) + printf '%s\n' "jq-linux-armhf" + ;; + macos:amd64) + printf '%s\n%s\n' "jq-macos-amd64" "jq-osx-amd64" + ;; + macos:arm64) + printf '%s\n' "jq-macos-arm64" + ;; + windows:amd64) + printf '%s\n%s\n' "jq-windows-amd64.exe" "jq-win64.exe" + ;; + esac +} diff --git a/scripts/easy-docker/lib/install/jq/ensure.sh b/scripts/easy-docker/lib/install/jq/ensure.sh new file mode 100755 index 00000000..6033fcc8 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/ensure.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +should_use_jq_github_fallback() { + local answer="" + + if [ ! -t 0 ]; then + echo "GitHub fallback prompt requires an interactive terminal." + return 1 + fi + + printf "Use GitHub binary fallback for jq? [y/N]: " + read -r answer + + case "${answer}" in + y | Y | yes | YES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +ensure_jq() { + local disable_installation_fallback="${1:-0}" + + if get_easy_docker_jq_command >/dev/null 2>&1; then + return 0 + fi + + echo "jq is not installed. Trying package manager installation..." + + if install_jq_with_package_manager; then + hash -r + fi + + if get_easy_docker_jq_command >/dev/null 2>&1; then + echo "jq was installed successfully." + return 0 + fi + + if [ "${disable_installation_fallback}" = "1" ]; then + echo "Installation fallback is disabled." + print_manual_jq_install_guidance + return 1 + fi + + if should_use_jq_github_fallback; then + echo "Trying pinned GitHub release fallback..." + if install_jq_from_github_release; then + hash -r + fi + else + echo "GitHub fallback was not selected." + print_manual_jq_install_guidance + return 1 + fi + + if get_easy_docker_jq_command >/dev/null 2>&1; then + echo "jq was installed successfully." + return 0 + fi + + echo "Automatic installation failed." + print_manual_jq_install_guidance + return 1 +} diff --git a/scripts/easy-docker/lib/install/jq/github_release.sh b/scripts/easy-docker/lib/install/jq/github_release.sh new file mode 100755 index 00000000..19dfab92 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/github_release.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +cleanup_jq_tmp_dir() { + local tmp_dir="${1:-}" + + if [ -n "${tmp_dir}" ] && [ -d "${tmp_dir}" ]; then + rm -rf "${tmp_dir}" + fi +} + +install_jq_from_github_release() { + local release_version="" + local checksums_path="" + local jq_os="" + local jq_arch="" + local asset_name="" + local asset_path="" + local download_url="" + local tmp_dir="" + local target_dir="" + local target_binary_name="jq" + local expected_checksum="" + + if ! command_exists curl; then + echo "curl is required for the GitHub fallback." + return 1 + fi + + if ! read -r jq_os jq_arch < <(detect_jq_platform); then + echo "Unsupported platform for automatic GitHub fallback." + return 1 + fi + + release_version="$(get_pinned_jq_version || true)" + if [ -z "${release_version}" ]; then + echo "Could not determine the pinned jq release version." + return 1 + fi + + checksums_path="$(get_jq_checksums_path)" + if [ ! -f "${checksums_path}" ]; then + echo "Pinned jq checksum file is missing: ${checksums_path}" + return 1 + fi + + if ! sha256_verification_available_for_jq; then + echo "A SHA256 verification tool is required for the GitHub fallback." + return 1 + fi + + tmp_dir="$(mktemp -d 2>/dev/null || true)" + if [ -z "${tmp_dir}" ] || [ ! -d "${tmp_dir}" ]; then + echo "Failed to create temporary directory for jq installation." + return 1 + fi + + while IFS= read -r asset_name; do + expected_checksum="$(get_pinned_jq_asset_checksum "${release_version}" "${asset_name}" || true)" + if [ -z "${expected_checksum}" ]; then + continue + fi + + asset_path="${tmp_dir}/${asset_name}" + download_url="https://github.com/jqlang/jq/releases/download/jq-${release_version}/${asset_name}" + + if ! curl -fsSL "${download_url}" -o "${asset_path}"; then + continue + fi + + if ! verify_file_sha256_for_jq "${asset_path}" "${expected_checksum}"; then + echo "Checksum verification failed for ${asset_name}." + continue + fi + + if [[ "${asset_name}" == *.exe ]]; then + target_binary_name="jq.exe" + else + target_binary_name="jq" + fi + + if [ "${jq_os}" != "windows" ] && [ -w "/usr/local/bin" ]; then + target_dir="/usr/local/bin" + if copy_binary "${asset_path}" "${target_dir}/${target_binary_name}"; then + cleanup_jq_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + if [ "${jq_os}" != "windows" ] && command_exists sudo; then + if copy_binary_with_privileges "${asset_path}" "/usr/local/bin/${target_binary_name}"; then + cleanup_jq_tmp_dir "${tmp_dir}" + return 0 + fi + fi + + if [ -n "${HOME:-}" ]; then + target_dir="${HOME}/.local/bin" + mkdir -p "${target_dir}" + if copy_binary "${asset_path}" "${target_dir}/${target_binary_name}"; then + cleanup_jq_tmp_dir "${tmp_dir}" + return 0 + fi + fi + done < <(get_jq_asset_candidates "${jq_os}" "${jq_arch}") + + cleanup_jq_tmp_dir "${tmp_dir}" + echo "No compatible, verified jq binary was installed from the pinned GitHub release." + return 1 +} diff --git a/scripts/easy-docker/lib/install/jq/load.sh b/scripts/easy-docker/lib/install/jq/load.sh new file mode 100755 index 00000000..b7c844d3 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/load.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +load_jq_install_modules() { + local jq_lib_dir="" + + jq_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/install/jq/platform.sh + source "${jq_lib_dir}/platform.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/assets.sh + source "${jq_lib_dir}/assets.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/package_manager.sh + source "${jq_lib_dir}/package_manager.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/github_release.sh + source "${jq_lib_dir}/github_release.sh" + # shellcheck source=scripts/easy-docker/lib/install/jq/ensure.sh + source "${jq_lib_dir}/ensure.sh" +} + +load_jq_install_modules diff --git a/scripts/easy-docker/lib/install/jq/package_manager.sh b/scripts/easy-docker/lib/install/jq/package_manager.sh new file mode 100755 index 00000000..26716bec --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/package_manager.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +install_jq_with_package_manager() { + local pm_attempted=0 + + if command_exists brew; then + pm_attempted=1 + if brew install jq; then + return 0 + fi + fi + + if command_exists apt-get; then + pm_attempted=1 + if run_with_privileges apt-get update && run_with_privileges apt-get install -y jq; then + return 0 + fi + fi + + if command_exists dnf; then + pm_attempted=1 + if run_with_privileges dnf install -y jq; then + return 0 + fi + fi + + if command_exists pacman; then + pm_attempted=1 + if run_with_privileges pacman -Sy --noconfirm jq; then + return 0 + fi + fi + + if command_exists zypper; then + pm_attempted=1 + if run_with_privileges zypper --non-interactive install jq; then + return 0 + fi + fi + + if command_exists winget; then + pm_attempted=1 + if winget install --id jqlang.jq -e --accept-source-agreements --accept-package-agreements; then + return 0 + fi + fi + + if command_exists choco; then + pm_attempted=1 + if choco install jq -y; then + return 0 + fi + fi + + if [ "${pm_attempted}" -eq 0 ]; then + echo "No supported package manager was found." + else + echo "Package manager installation did not succeed." + fi + + return 1 +} diff --git a/scripts/easy-docker/lib/install/jq/platform.sh b/scripts/easy-docker/lib/install/jq/platform.sh new file mode 100755 index 00000000..54426dc3 --- /dev/null +++ b/scripts/easy-docker/lib/install/jq/platform.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +detect_jq_platform() { + local raw_os="" + local raw_arch="" + local jq_os="" + local jq_arch="" + + raw_os="$(uname -s 2>/dev/null || echo unknown)" + raw_arch="$(uname -m 2>/dev/null || echo unknown)" + + case "${raw_os}" in + Linux*) + jq_os="linux" + ;; + Darwin*) + jq_os="macos" + ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + jq_os="windows" + ;; + *) + return 1 + ;; + esac + + case "${raw_arch}" in + x86_64 | amd64) + jq_arch="amd64" + ;; + aarch64 | arm64) + jq_arch="arm64" + ;; + armv7l | armv7) + jq_arch="armhf" + ;; + *) + return 1 + ;; + esac + + if [ "${jq_os}" = "windows" ] && [ "${jq_arch}" != "amd64" ]; then + return 1 + fi + + if [ "${jq_os}" = "macos" ] && [ "${jq_arch}" = "armhf" ]; then + return 1 + fi + + printf '%s %s\n' "${jq_os}" "${jq_arch}" + return 0 +} diff --git a/scripts/easy-docker/lib/load.sh b/scripts/easy-docker/lib/load.sh new file mode 100755 index 00000000..7a84d309 --- /dev/null +++ b/scripts/easy-docker/lib/load.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +load_easy_docker_modules() { + local lib_dir="" + lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/core/commands.sh + source "${lib_dir}/core/commands.sh" + # shellcheck source=scripts/easy-docker/lib/core/messages.sh + source "${lib_dir}/core/messages.sh" + # shellcheck source=scripts/easy-docker/lib/core/json.sh + source "${lib_dir}/core/json.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/load.sh + source "${lib_dir}/install/gum/load.sh" + # shellcheck source=scripts/easy-docker/lib/checks/docker.sh + source "${lib_dir}/checks/docker.sh" + # shellcheck source=scripts/easy-docker/lib/checks/jq.sh + source "${lib_dir}/checks/jq.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens.sh + source "${lib_dir}/ui/screens.sh" + # shellcheck source=scripts/easy-docker/lib/app/screen.sh + source "${lib_dir}/app/screen.sh" + # shellcheck source=scripts/easy-docker/lib/app/options.sh + source "${lib_dir}/app/options.sh" + # shellcheck source=scripts/easy-docker/lib/app/run.sh + source "${lib_dir}/app/run.sh" +} + +load_easy_docker_modules diff --git a/scripts/easy-docker/lib/ui/screens.sh b/scripts/easy-docker/lib/ui/screens.sh new file mode 100755 index 00000000..d6d917ad --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +load_ui_screen_modules() { + local screens_dir="" + screens_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/screens" + + # shellcheck source=scripts/easy-docker/lib/ui/screens/base.sh + source "${screens_dir}/base.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens/production.sh + source "${screens_dir}/production.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens/environment.sh + source "${screens_dir}/environment.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens/tools.sh + source "${screens_dir}/tools.sh" +} + +load_ui_screen_modules diff --git a/scripts/easy-docker/lib/ui/screens/base.sh b/scripts/easy-docker/lib/ui/screens/base.sh new file mode 100755 index 00000000..18215e7b --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/base.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +get_terminal_cols() { + local cols="80" + + if command_exists tput; then + cols="$(tput cols 2>/dev/null || printf "80")" + fi + + if ! [[ "${cols}" =~ ^[0-9]+$ ]] || [ "${cols}" -le 0 ]; then + cols="80" + fi + + printf '%s\n' "${cols}" +} + +get_box_wrap_width() { + local cols="" + local width="" + + cols="$(get_terminal_cols)" + width="$((cols - 16))" + + if [ "${width}" -lt 12 ]; then + width="12" + fi + + printf '%s\n' "${width}" +} + +wrap_box_text() { + local raw_text="${1}" + local wrap_width="" + + wrap_width="$(get_box_wrap_width)" + + if command_exists fold; then + printf '%s' "${raw_text}" | fold -s -w "${wrap_width}" + return + fi + + printf '%s' "${raw_text}" +} + +render_box_message() { + local raw_text="${1}" + local margin="${2:-0 2}" + local padding="${3:-0 1}" + local wrapped_text="" + + wrapped_text="$(wrap_box_text "${raw_text}")" + + gum style \ + --border rounded \ + --border-foreground 63 \ + --padding "${padding}" \ + --margin "${margin}" \ + --foreground 252 \ + "${wrapped_text}" +} + +render_main_screen() { + local clear_screen="${1:-0}" + local header_text="" + + if [ "${clear_screen}" = "1" ]; then + clear + fi + + header_text="$(printf "Easy Frappe Docker\nManage Docker setups quickly and easily")" + + render_box_message "${header_text}" "1 2" "0 1" +} + +show_main_menu() { + gum choose \ + --height 10 \ + --header "Choose an action" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Production Stack" \ + "Development Stack" \ + "Tools" \ + "Environment check" \ + "Exit" +} + +show_warning_message() { + local message="${1}" + gum style --foreground 214 "${message}" +} diff --git a/scripts/easy-docker/lib/ui/screens/environment.sh b/scripts/easy-docker/lib/ui/screens/environment.sh new file mode 100755 index 00000000..c5248e89 --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/environment.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +show_environment_status() { + local docker_status="not installed" + local docker_daemon_status="not running" + local status_text="" + + if command_exists docker; then + docker_status="installed" + + if docker_daemon_running; then + docker_daemon_status="running" + fi + fi + + render_main_screen 1 >&2 + + status_text="$(printf "Environment status\n\n- docker: %s\n- docker daemon: %s" "${docker_status}" "${docker_daemon_status}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 6 \ + --header "Environment actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Back to main menu" \ + "Exit and close easy-docker" +} diff --git a/scripts/easy-docker/lib/ui/screens/production.sh b/scripts/easy-docker/lib/ui/screens/production.sh new file mode 100755 index 00000000..faae2dc4 --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/production.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +load_production_screen_modules() { + local screen_dir="" + screen_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + # shellcheck source=scripts/easy-docker/lib/ui/screens/production/setup.sh + source "${screen_dir}/production/setup.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens/production/topology.sh + source "${screen_dir}/production/topology.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens/production/split_services.sh + source "${screen_dir}/production/split_services.sh" + # shellcheck source=scripts/easy-docker/lib/ui/screens/production/manage.sh + source "${screen_dir}/production/manage.sh" +} + +load_production_screen_modules diff --git a/scripts/easy-docker/lib/ui/screens/production/manage.sh b/scripts/easy-docker/lib/ui/screens/production/manage.sh new file mode 100755 index 00000000..dc8cc75a --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/production/manage.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash + +show_manage_stacks_menu() { + local setup_type="${1}" + shift + local stack_count="${#}" + local setup_label="" + local status_text="" + + render_main_screen 1 >&2 + + setup_label="$(get_setup_display_label "${setup_type}")" + if [ "${stack_count}" -eq 1 ]; then + status_text="$(printf "Manage existing %s stacks\n\n1 stack found. Select a stack." "${setup_label}")" + else + status_text="$(printf "Manage existing %s stacks\n\n%s stacks found. Select a stack." "${setup_label}" "${stack_count}")" + fi + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 14 \ + --header "Existing stacks" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "$@" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stacks_placeholder() { + local setup_type="${1}" + local setup_label="" + local status_text="" + + render_main_screen 1 >&2 + + setup_label="$(get_setup_display_label "${setup_type}")" + status_text="$(printf "Manage existing %s stacks\n\nNo stacks found in .easy-docker/stacks yet." "${setup_label}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 7 \ + --header "Manage stacks actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_actions_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local stack_runtime_status="${3:-Unknown}" + local stack_topology="" + local menu_header="" + local status_text="" + local -a menu_options=() + + render_main_screen 1 >&2 + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + status_text="$(printf "Manage stack\n\nStack: %s\nDirectory: %s\nRuntime status: %s\nTopology: %s\n\nChoose an action for this stack." "${stack_name}" "${stack_dir}" "${stack_runtime_status}" "${stack_topology:-unknown}")" + + render_box_message "${status_text}" "0 2" >&2 + + menu_header="$(printf "Stack actions | %s" "${stack_runtime_status}")" + + menu_options=( + "Apps" + "Updates" + ) + case "${stack_topology}" in + single-host) + menu_options+=("Site") + ;; + esac + menu_options+=( + "Start stack in Docker Compose" + "Restart stack in Docker Compose" + "Stop stack in Docker Compose" + "Delete stack" + "Back" + "Exit and close easy-docker" + ) + + gum choose \ + --height 12 \ + --header "${menu_header}" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "${menu_options[@]}" +} + +show_manage_stack_apps_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local frappe_branch="${3:-n/a}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack apps\n\nStack: %s\nDirectory: %s\nFrappe branch: %s\n\nChoose an app-related action for this stack." "${stack_name}" "${stack_dir}" "${frappe_branch}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Stack apps actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Select apps and branches" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_updates_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local frappe_branch="${3:-n/a}" + local custom_image_ref="${4:-n/a}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack updates\n\nStack: %s\nDirectory: %s\nFrappe branch: %s\nCustom image: %s\n\nChoose an update-related action for this stack." "${stack_name}" "${stack_dir}" "${frappe_branch}" "${custom_image_ref}")" + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 9 \ + --header "Stack update actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Update selected app branches" \ + "Set next custom image tag" \ + "Build updated image" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_site_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local existing_site_entry="${3:-}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack site\n\nStack: %s\nDirectory: %s\n\nCreate a new site or select an existing site for this stack." "${stack_name}" "${stack_dir}")" + render_box_message "${status_text}" "0 2" >&2 + + if [ -n "${existing_site_entry}" ]; then + gum choose \ + --height 10 \ + --header "Stack site actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Create new site" \ + "${existing_site_entry}" \ + "Back" \ + "Exit and close easy-docker" + return 0 + fi + + gum choose \ + --height 8 \ + --header "Stack site actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Create new site" \ + "Back" \ + "Exit and close easy-docker" +} + +prompt_stack_site_name() { + local stack_name="${1}" + local placeholder="${2:-}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack site\n\nStack: %s\n\nEnter the site name for the first site.\nType /back or press Ctrl+C to cancel." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Site name" \ + --prompt "site> " \ + --placeholder "${placeholder}" +} + +prompt_stack_site_admin_password() { + local stack_name="${1}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage stack site\n\nStack: %s\n\nEnter the Administrator password for the new site.\nType /back or press Ctrl+C to cancel." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Administrator password" \ + --prompt "password> " \ + --password +} + +show_manage_stack_site_details() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local created_at="${4:-}" + local installed_apps="${5:-None}" + local last_backup_at="${6:-}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage existing site\n\nStack: %s\nDirectory: %s\nSite: %s\nCreated at: %s\nInstalled apps: %s\nLast backup: %s" "${stack_name}" "${stack_dir}" "${site_name}" "${created_at:-n/a}" "${installed_apps}" "${last_backup_at:-n/a}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 10 \ + --header "Site details" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Manage apps on this site" \ + "Migrate site now" \ + "Backup site now" \ + "Delete site" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_site_apps_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Manage site apps\n\nStack: %s\nDirectory: %s\nSite: %s\n\nInstall or uninstall apps for this existing site." "${stack_name}" "${stack_dir}" "${site_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 9 \ + --header "Site app actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Install app on this site" \ + "Uninstall app from this site" \ + "Back" \ + "Exit and close easy-docker" +} + +show_manage_stack_site_app_selection() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local action_label="${4}" + local app_lines="${5:-}" + local status_text="" + local app_name="" + local -a menu_options=() + + render_main_screen 1 >&2 + + status_text="$(printf "%s\n\nStack: %s\nDirectory: %s\nSite: %s\n\nSelect one app." "${action_label}" "${stack_name}" "${stack_dir}" "${site_name}")" + render_box_message "${status_text}" "0 2" >&2 + + while IFS= read -r app_name; do + if [ -z "${app_name}" ]; then + continue + fi + menu_options+=("${app_name}") + done <&2 + + status_text="$(printf "Uninstall app from site\n\nStack: %s\nDirectory: %s\nSite: %s\nApp: %s\n\nThis removes the app from the site. frappe itself cannot be removed here." "${stack_name}" "${stack_dir}" "${site_name}" "${app_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Confirm uninstall app" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes" \ + "No" \ + "Exit and close easy-docker" +} + +show_manage_stack_site_migrate_confirmation() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Migrate site\n\nStack: %s\nDirectory: %s\nSite: %s\n\nRun bench migrate for this existing site now?" "${stack_name}" "${stack_dir}" "${site_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Confirm migrate site" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes" \ + "No" \ + "Exit and close easy-docker" +} + +show_manage_stack_site_delete_confirmation() { + local stack_name="${1}" + local stack_dir="${2}" + local site_name="${3}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Delete site\n\nStack: %s\nDirectory: %s\nSite: %s\n\nAll site data and the site database will be permanently deleted." "${stack_name}" "${stack_dir}" "${site_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Confirm delete site" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes" \ + "No" \ + "Exit and close easy-docker" +} + +show_manage_stack_delete_confirmation() { + local stack_name="${1}" + local stack_dir="${2}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Delete stack\n\nStack: %s\nDirectory: %s\n\nThis will permanently remove the stack directory, Docker containers, networks, volumes, and configured custom image." "${stack_name}" "${stack_dir}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Confirm delete stack" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes" \ + "No" \ + "Exit and close easy-docker" +} + +prompt_manage_stack_delete_keyword() { + local stack_name="${1}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Delete stack\n\nStack: %s\n\nFinal confirmation required.\nType delete to permanently remove the stack and all its data.\nType /back or press Ctrl+C to cancel." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Type delete to confirm" \ + --prompt "confirm> " \ + --placeholder "delete" +} + +show_missing_custom_image_start_menu() { + local stack_name="${1}" + local stack_dir="${2}" + local image_ref="${3}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Custom image missing\n\nStack: %s\nDirectory: %s\nImage: %s\n\nBuild the custom image now before starting the stack?" "${stack_name}" "${stack_dir}" "${image_ref}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Missing custom image" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Build custom image now" \ + "Back" \ + "Exit and close easy-docker" +} diff --git a/scripts/easy-docker/lib/ui/screens/production/setup.sh b/scripts/easy-docker/lib/ui/screens/production/setup.sh new file mode 100755 index 00000000..91a0c417 --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/production/setup.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash + +get_setup_display_label() { + local setup_type="${1}" + + case "${setup_type}" in + development) + printf 'Development' + ;; + production | *) + printf 'Production' + ;; + esac +} + +show_setup_menu() { + local setup_type="${1}" + local setup_label="" + local status_text="" + + render_main_screen 1 >&2 + + setup_label="$(get_setup_display_label "${setup_type}")" + case "${setup_type}" in + development) + status_text="$(printf "%s stack\n\nChoose whether to create a new stack or manage an existing one.\nNew sites created in this stack enable developer mode automatically." "${setup_label}")" + ;; + *) + status_text="$(printf "%s stack\n\nChoose whether to create a new stack or manage an existing one." "${setup_label}")" + ;; + esac + + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "${setup_label} stack actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Create new stack" \ + "Manage existing stacks" \ + "Back" \ + "Exit and close easy-docker" +} + +show_production_setup_menu() { + show_setup_menu "production" +} + +show_development_setup_menu() { + show_setup_menu "development" +} + +prompt_new_stack_name() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Create new stack\n\nEnter a stack name.\nType /cancel or press Ctrl+C to abort.")" + + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "Stack name (/cancel to abort)" \ + --prompt "name> " \ + --placeholder "my-production-stack" +} + +show_frappe_version_profile_menu() { + local stack_name="${1}" + local options_lines="${2:-}" + local selected_label="${3:-}" + local status_text="" + local option_line="" + local -a menu_options=() + local -a gum_args=() + + render_main_screen 1 >&2 + + status_text="$(printf "Create stack: %s\n\nSelect the Frappe branch profile from frappe.tsv.\nThis sets the stack default for branch suggestions." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + while IFS= read -r option_line; do + if [ -z "${option_line}" ]; then + continue + fi + menu_options+=("${option_line}") + done <&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nSplit-services setup (step 1/5)\nApplication Services run the Frappe image, workers, scheduler, and frontend.\nData Services provide the database and Redis layer.\n\nChoose how the data layer should be handled." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 9 \ + --header "Data Services" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Managed Data Services" \ + "External Data Services" \ + "Back to topology selection" +} + +show_split_services_database_menu() { + local stack_dir="${1}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nSplit-services setup (step 2/5)\nChoose the database engine for the data layer.\nMariaDB is the default choice for most users.\nPostgreSQL is available if that is the database you want to run with this stack." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Data Services: database engine" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "MariaDB (recommended)" \ + "PostgreSQL" \ + "Back to topology selection" +} + +show_split_services_redis_mode_menu() { + local stack_dir="${1}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nSplit-services setup (step 3/5)\nChoose how Redis should be handled.\nManaged Redis keeps the Redis services inside the generated stack.\nExternal Redis uses endpoints you provide manually.\nChoose no Redis services only if you know you want to handle Redis yourself." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 9 \ + --header "Redis Services" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Managed Redis Services" \ + "External Redis Services" \ + "No Redis Services" \ + "Back to topology selection" +} + +show_split_services_proxy_mode_menu() { + local stack_dir="${1}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nSplit-services setup (step 4/5)\nChoose the reverse proxy mode.\nThe reverse proxy is optional and can stay outside the stack if you already manage it elsewhere." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 11 \ + --header "Reverse proxy mode" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Traefik (HTTP, built-in proxy)" \ + "Traefik (HTTPS + Let's Encrypt)" \ + "nginx-proxy (HTTP)" \ + "nginx-proxy + acme-companion (HTTPS)" \ + "Caddy (external reverse proxy)" \ + "No reverse proxy (direct :8080)" \ + "Back to topology selection" +} + +show_split_services_summary_menu() { + local stack_dir="${1}" + local data_mode_label="${2}" + local database_label="${3}" + local redis_label="${4}" + local proxy_label="${5}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nSplit-services setup (step 5/5)\nReview the selected layout before the stack files are written.\n\nApplication Services: managed in this stack\nData Services: %s\nDatabase engine: %s\nRedis Services: %s\nReverse Proxy: %s" "${stack_name}" "${data_mode_label}" "${database_label}" "${redis_label}" "${proxy_label}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Split-services summary" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Yes, write stack files" \ + "Back to topology selection" \ + "Abort wizard to main menu" +} diff --git a/scripts/easy-docker/lib/ui/screens/production/topology.sh b/scripts/easy-docker/lib/ui/screens/production/topology.sh new file mode 100755 index 00000000..9fb8f68a --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/production/topology.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash + +show_stack_topology_menu() { + local stack_dir="${1}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack created: %s\nDirectory: %s\n\nChoose the deployment topology.\n\n- Single-host: easiest setup on one server.\n- Split services: separate application services, data services, and an optional reverse proxy." "${stack_name}" "${stack_dir}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Topology" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Single-host (recommended)" \ + "Split services" \ + "Abort wizard to main menu" +} + +show_single_host_proxy_menu() { + local stack_dir="${1}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nChoose the reverse proxy mode.\n\n- Traefik and nginx-proxy run inside compose.\n- Caddy is external and uses the no-proxy compose mode." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 11 \ + --header "Reverse proxy mode" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Traefik (HTTP, built-in proxy)" \ + "Traefik (HTTPS + Let's Encrypt)" \ + "nginx-proxy (HTTP)" \ + "nginx-proxy + acme-companion (HTTPS)" \ + "Caddy (external reverse proxy)" \ + "No reverse proxy (direct :8080)" \ + "Back to topology selection" +} + +show_single_host_database_menu() { + local stack_dir="${1}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nChoose the database engine." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Database engine" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "MariaDB (recommended)" \ + "PostgreSQL" \ + "Back to topology selection" +} + +show_single_host_redis_menu() { + local stack_dir="${1}" + local stack_name="" + local status_text="" + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nChoose whether Redis services should be included in this stack." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Redis services" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Include Redis (recommended)" \ + "Skip Redis (experienced users only)" \ + "Back to topology selection" +} + +show_custom_modular_apps_multi_select() { + local stack_dir="${1}" + local options_lines="${2:-}" + local selected_labels_csv="${3:-}" + local stack_name="" + local status_text="" + local option_line="" + local selected_label="" + local -a menu_options=() + local -a selected_labels=() + local -a gum_args=() + + render_main_screen 1 >&2 + + stack_name="${stack_dir##*/}" + status_text="$(printf "Stack: %s\n\nApps\nUse Space to toggle apps from apps.tsv. Press Enter to continue to branch selection per app.\nUse Ctrl+C to go back." "${stack_name}")" + render_box_message "${status_text}" "0 2" >&2 + + while IFS= read -r option_line; do + if [ -z "${option_line}" ]; then + continue + fi + menu_options+=("${option_line}") + done <&2 + + stack_name="${stack_dir##*/}" + guidance_text="${guidance_text//\\n/$'\n'}" + status_text="$(printf "Stack: %s\n\nConfigure %s\n\n%s" "${stack_name}" "${variable_name}" "${guidance_text}")" + render_box_message "${status_text}" "0 2" >&2 + fi + + if [ -n "${input_feedback}" ]; then + gum style --foreground 214 "${input_feedback}" >&2 + fi + + gum input \ + --header "${variable_name}" \ + --prompt "value> " \ + --placeholder "${placeholder}" +} + +show_abort_wizard_prompt() { + local stack_dir="${1}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Abort wizard\n\nStack directory:\n%s\n\nRollback created files before returning to main menu?" "${stack_dir}")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 8 \ + --header "Abort options" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Rollback files and return to main menu" \ + "Keep files and return to main menu" \ + "Back to topology selection" +} diff --git a/scripts/easy-docker/lib/ui/screens/tools.sh b/scripts/easy-docker/lib/ui/screens/tools.sh new file mode 100755 index 00000000..4a22a7f2 --- /dev/null +++ b/scripts/easy-docker/lib/ui/screens/tools.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +show_tools_menu() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Tools\n\nManage helper wizards for easy-docker.\nUse this area to maintain the app catalog shown in app selection.")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 9 \ + --header "Tools - App Catalog Utilities" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Add Apps for App Selection" \ + "Back to main menu" \ + "Exit and close easy-docker" +} + +prompt_tools_apps_catalog_input() { + local field_label="${1}" + local help_text="${2}" + local placeholder="${3:-}" + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Tools\n\nAdd Apps for App Selection\nThis wizard updates scripts/easy-docker/config/apps.tsv used by app selection.\n\n%s\nType /back or press Ctrl+C to cancel." "${help_text}")" + render_box_message "${status_text}" "0 2" >&2 + + gum input \ + --header "${field_label}" \ + --prompt "value> " \ + --placeholder "${placeholder}" +} + +show_tools_apps_default_branch_menu() { + local status_text="" + + render_main_screen 1 >&2 + + status_text="$(printf "Tools\n\nAdd Apps for App Selection\nSelect the default branch from the configured branch list.\nUse Ctrl+C or choose Back to return.")" + render_box_message "${status_text}" "0 2" >&2 + + gum choose \ + --height 14 \ + --header "Default Branch - Choose from List" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "$@" \ + "Back" +} diff --git a/scripts/easy-docker/main.sh b/scripts/easy-docker/main.sh new file mode 100755 index 00000000..88633c61 --- /dev/null +++ b/scripts/easy-docker/main.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=scripts/easy-docker/lib/load.sh +source "${SCRIPT_DIR}/lib/load.sh" + +disable_installation_fallback=0 +if parse_cli_options disable_installation_fallback "$@"; then + : +else + parse_status=$? + if [ "${parse_status}" -eq 2 ]; then + exit 0 + fi + exit "${parse_status}" +fi + +if ! ensure_gum "${disable_installation_fallback}"; then + exit 1 +fi + +if ! ensure_docker; then + exit 1 +fi + +if ! ensure_jq "${disable_installation_fallback}"; then + exit 1 +fi + +trap 'leave_alt_screen; exit 0' INT TERM +trap 'leave_alt_screen' EXIT + +run_easy_docker_app diff --git a/tests/easy-docker/10_cli_smoke.bats b/tests/easy-docker/10_cli_smoke.bats new file mode 100755 index 00000000..5e18ace5 --- /dev/null +++ b/tests/easy-docker/10_cli_smoke.bats @@ -0,0 +1,106 @@ +#!/usr/bin/env bats + +setup() { + ROOT_DIR="$(cd "${BATS_TEST_DIRNAME}/../.." && pwd)" + MAIN_SCRIPT="${ROOT_DIR}/scripts/easy-docker/main.sh" + + SYSTEM_BASH="$(command -v bash)" + SYSTEM_CAT="$(command -v cat)" + SYSTEM_CHMOD="$(command -v chmod)" + SYSTEM_DIRNAME="$(command -v dirname)" + SYSTEM_ENV="$(command -v env)" + SYSTEM_MKDIR="$(command -v mkdir)" + SYSTEM_MKTEMP="$(command -v mktemp)" + SYSTEM_RM="$(command -v rm)" + + TEST_TMPDIR="$("${SYSTEM_MKTEMP}" -d)" + STUB_BIN="${TEST_TMPDIR}/bin" + "${SYSTEM_MKDIR}" -p "${STUB_BIN}" + + write_passthrough_stub cat "${SYSTEM_CAT}" + write_passthrough_stub dirname "${SYSTEM_DIRNAME}" +} + +teardown() { + if [ -n "${TEST_TMPDIR:-}" ] && [ -d "${TEST_TMPDIR}" ]; then + "${SYSTEM_RM}" -rf "${TEST_TMPDIR}" + fi +} + +write_stub() { + local name="$1" + shift + + { + printf '#!%s\n' "${SYSTEM_BASH}" + printf '%s\n' "$@" + } >"${STUB_BIN}/${name}" + "${SYSTEM_CHMOD}" +x "${STUB_BIN}/${name}" +} + +write_passthrough_stub() { + local name="$1" + local target="$2" + + { + printf '#!%s\n' "${SYSTEM_BASH}" + printf 'exec "%s" "$@"\n' "${target}" + } >"${STUB_BIN}/${name}" + "${SYSTEM_CHMOD}" +x "${STUB_BIN}/${name}" +} + +@test "help prints usage and exits cleanly" { + run "${SYSTEM_ENV}" "PATH=${STUB_BIN}" "${SYSTEM_BASH}" "${MAIN_SCRIPT}" --help + + [ "${status}" -eq 0 ] + [[ "${output}" == *"Usage: bash easy-docker.sh [options]"* ]] + [[ "${output}" == *"--no-installation-fallback"* ]] +} + +@test "unknown option is rejected before startup" { + run "${SYSTEM_ENV}" "PATH=${STUB_BIN}" "${SYSTEM_BASH}" "${MAIN_SCRIPT}" --definitely-unknown + + [ "${status}" -eq 1 ] + [[ "${output}" == *"Unknown option: --definitely-unknown"* ]] + [[ "${output}" == *"Usage: bash easy-docker.sh [options]"* ]] +} + +@test "missing gum fails without interactive fallback when disabled" { + run "${SYSTEM_ENV}" "PATH=${STUB_BIN}" "${SYSTEM_BASH}" "${MAIN_SCRIPT}" --no-installation-fallback + + [ "${status}" -eq 1 ] + [[ "${output}" == *"gum is not installed. Trying package manager installation..."* ]] + [[ "${output}" == *"No supported package manager was found."* ]] + [[ "${output}" == *"Installation fallback is disabled."* ]] + [[ "${output}" == *"Install gum manually:"* ]] +} + +@test "missing docker stops after gum dependency succeeds" { + write_stub gum 'exit 0' + + run "${SYSTEM_ENV}" "PATH=${STUB_BIN}" "${SYSTEM_BASH}" "${MAIN_SCRIPT}" --no-installation-fallback + + [ "${status}" -eq 1 ] + [[ "${output}" == *"docker is not installed."* ]] + [[ "${output}" == *"Install Docker first:"* ]] +} + +@test "missing jq stops after gum and docker dependencies succeed" { + write_stub gum 'exit 0' + # shellcheck disable=SC2016 + write_stub docker \ + 'if [ "${1:-}" = "compose" ] && [ "${2:-}" = "version" ]; then exit 0; fi' \ + 'if [ "${1:-}" = "info" ]; then exit 0; fi' \ + 'case "$*" in' \ + ' "ps --help"| "exec --help"| "inspect --help"| "cp --help"| "build --help"| "compose config --help"| "compose up --help"| "compose down --help"| "compose logs --help"| "compose exec --help"| "compose pull --help"| "compose ps --help") exit 0 ;;' \ + 'esac' \ + 'exit 0' + + run "${SYSTEM_ENV}" "PATH=${STUB_BIN}" "${SYSTEM_BASH}" "${MAIN_SCRIPT}" --no-installation-fallback + + [ "${status}" -eq 1 ] + [[ "${output}" == *"jq is not installed. Trying package manager installation..."* ]] + [[ "${output}" == *"No supported package manager was found."* ]] + [[ "${output}" == *"Installation fallback is disabled."* ]] + [[ "${output}" == *"Install jq first:"* ]] +} diff --git a/tests/easy-docker/20_core_render.bats b/tests/easy-docker/20_core_render.bats new file mode 100755 index 00000000..e4018584 --- /dev/null +++ b/tests/easy-docker/20_core_render.bats @@ -0,0 +1,212 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_core_render_modules + easy_docker_test_install_jq_stub + unset ERPNEXT_VERSION + unset FRAPPE_BRANCH +} + +teardown() { + easy_docker_test_end +} + +@test "is_valid_stack_name accepts safe names" { + local name="" + + for name in alpha alpha-1 alpha_1 alpha.1; do + run is_valid_stack_name "${name}" + [ "${status}" -eq 0 ] + done +} + +@test "is_valid_stack_name rejects empty and unsafe names" { + local name="" + + for name in "" "bad name" "bad/name" "bad:name" "bad*name"; do + run is_valid_stack_name "${name}" + [ "${status}" -eq 1 ] + done +} + +@test "get_env_file_key_value parses exported and quoted values" { + local sandbox_root="" + local stack_dir="" + local env_file="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "env-parse")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "env-parse")" + mkdir -p "${stack_dir}" + env_file="${stack_dir}/stack.env" + + cat >"${env_file}" <<'EOF' +# comment +export ERPNEXT_VERSION=15.9.0-test +STACK_NAME="My Stack" +IGNORED=value +EOF + + run get_env_file_key_value "${env_file}" ERPNEXT_VERSION + [ "${status}" -eq 0 ] + [ "${output}" = "15.9.0-test" ] + + run get_env_file_key_value "${env_file}" STACK_NAME + [ "${status}" -eq 0 ] + [ "${output}" = "My Stack" ] +} + +@test "get_stack_compose_project_name normalizes metadata stack names" { + local sandbox_root="" + local stack_dir="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "project-name")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "project-name")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "My Stack! 01" +} +EOF + + run get_stack_compose_project_name "${stack_dir}" + [ "${status}" -eq 0 ] + [ "${output}" = "easydocker-my-stack-01" ] +} + +@test "get_metadata_compose_files_lines returns compose file entries" { + local sandbox_root="" + local stack_dir="" + local expected="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "compose-lines")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "compose-lines")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "compose-lines", + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml", + "overrides/compose.redis.yaml" + ] +} +EOF + + expected=$'compose.yaml\noverrides/compose.proxy.yaml\noverrides/compose.redis.yaml' + + run get_metadata_compose_files_lines "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected}" ] +} + +@test "get_metadata_compose_files_lines keeps the first compose_files array only" { + local sandbox_root="" + local stack_dir="" + local expected="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "compose-lines-first-array")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "compose-lines-first-array")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "compose-lines-first-array", + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml" + ], + "wizard": { + "compose_files": [ + "should-not-appear.yaml" + ] + } +} +EOF + + expected=$'compose.yaml\noverrides/compose.proxy.yaml' + + run get_metadata_compose_files_lines "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected}" ] +} + +@test "render_stack_compose_from_metadata writes generated compose with stubbed docker config" { + local sandbox_root="" + local stack_dir="" + local env_path="" + local generated_compose_path="" + local invocation_log="" + + easy_docker_test_install_docker_stub + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-smoke")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-smoke")" + mkdir -p "${stack_dir}" + + cat >"${sandbox_root}/compose.yaml" <<'EOF' +services: + backend: + image: frappe/backend +EOF + + cat >"${sandbox_root}/overrides/compose.proxy.yaml" <<'EOF' +services: + frontend: + image: frappe/frontend +EOF + + cat >"${sandbox_root}/overrides/compose.redis.yaml" <<'EOF' +services: + redis-cache: + image: redis:7 +EOF + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "My Stack! 01", + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml", + "overrides/compose.redis.yaml" + ] +} +EOF + + env_path="${stack_dir}/My Stack! 01.env" + cat >"${env_path}" <<'EOF' +DB_HOST=localhost +EOF + + generated_compose_path="${stack_dir}/compose.generated.yaml" + invocation_log="${EASY_DOCKER_TEST_TMPDIR}/docker.invocations" + + export ERPNEXT_VERSION="15.9.0-test" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 0 ] + [ -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + + run cat "${generated_compose_path}" + [ "${status}" -eq 0 ] + [[ "${output}" == *"invocation=docker compose --project-name easydocker-my-stack-01 --env-file ${env_path}"* ]] + [[ "${output}" == *"-f ${sandbox_root}/compose.yaml"* ]] + [[ "${output}" == *"-f ${sandbox_root}/overrides/compose.proxy.yaml"* ]] + [[ "${output}" == *"-f ${sandbox_root}/overrides/compose.redis.yaml"* ]] + [[ "${output}" == *"erpnext=15.9.0-test"* ]] + + run cat "${invocation_log}" + [ "${status}" -eq 0 ] + [[ "${output}" == *"docker compose --project-name easydocker-my-stack-01 --env-file ${env_path} -f "* ]] + [[ "${output}" == *"config"* ]] +} diff --git a/tests/easy-docker/25_screen_terminal.bats b/tests/easy-docker/25_screen_terminal.bats new file mode 100755 index 00000000..57ce2775 --- /dev/null +++ b/tests/easy-docker/25_screen_terminal.bats @@ -0,0 +1,49 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_screen_modules_with_tty_stdout +} + +teardown() { + easy_docker_test_end +} + +@test "enter_alt_screen suppresses tput stderr when terminfo is unavailable" { + easy_docker_test_write_bin_command tput \ + 'echo '"'"'tput: unknown terminal "xterm-256color"'"'"' >&2' \ + 'exit 1' + easy_docker_test_prepend_bin_dir + + run enter_alt_screen + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} + +@test "enter_alt_screen and leave_alt_screen track successful terminal state" { + local log_file="" + local expected_log="" + + log_file="${EASY_DOCKER_TEST_TMPDIR}/tput.log" + + easy_docker_test_write_bin_command tput \ + "printf '%s\\n' \"\${1:-}\" >>\"${log_file}\"" \ + 'exit 0' + easy_docker_test_prepend_bin_dir + + enter_alt_screen + [ "${ALT_SCREEN_ACTIVE}" = "1" ] + [ "${CURSOR_HIDDEN}" = "1" ] + + leave_alt_screen + [ "${ALT_SCREEN_ACTIVE}" = "0" ] + [ "${CURSOR_HIDDEN}" = "0" ] + + expected_log=$'smcup\ncivis\ncnorm\nrmcup' + + run cat "${log_file}" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected_log}" ] +} diff --git a/tests/easy-docker/30_gum_ensure.bats b/tests/easy-docker/30_gum_ensure.bats new file mode 100755 index 00000000..cb91a6d5 --- /dev/null +++ b/tests/easy-docker/30_gum_ensure.bats @@ -0,0 +1,120 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_gum_modules +} + +teardown() { + easy_docker_test_end +} + +@test "should_use_github_fallback rejects non-interactive terminals" { + local script_path="" + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + script_path="${EASY_DOCKER_TEST_TMPDIR}/run-should-use-github-fallback" + easy_docker_test_write_executable "${script_path}" \ + "source \"${repo_root}/scripts/easy-docker/lib/install/gum/ensure.sh\"" \ + 'should_use_github_fallback' + + run "${script_path}" "${metadata_path}" + + result_stack_dir="" + if create_stack_directory_with_metadata result_stack_dir "duplicate-stack" "production" "version-15"; then + status_code=0 + else + status_code=$? + fi + [ "${status_code}" -eq 2 ] + [ "${result_stack_dir}" = "" ] + [ "$(cat "${metadata_path}")" = "original" ] +} + +@test "create_stack_directory_with_metadata does not leave a partial stack behind when frappe_branch is missing" { + local sandbox_root="" + local stack_dir="" + local result_stack_dir="" + local status_code="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "missing-frappe-branch")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "missing-frappe-branch")" + + if create_stack_directory_with_metadata result_stack_dir "missing-frappe-branch" "production" ""; then + status_code=0 + else + status_code=$? + fi + + [ "${status_code}" -eq 1 ] + [ "${result_stack_dir}" = "" ] + [ ! -d "${stack_dir}" ] +} + +@test "rollback_stack_directory removes managed stack directories" { + local sandbox_root="" + local stack_dir="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "rollback-stack")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "rollback-stack")" + mkdir -p "${stack_dir}/nested" + printf '%s\n' "payload" >"${stack_dir}/nested/file.txt" + + if ! rollback_stack_directory "${stack_dir}"; then + false + fi + [ ! -d "${stack_dir}" ] +} + +@test "rollback_stack_directory rejects paths outside the managed stacks tree" { + local sandbox_root="" + local outside_dir="" + local status_code="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "rollback-outside")" + easy_docker_test_override_repo_root "${sandbox_root}" + outside_dir="$(mktemp -d)" + + if rollback_stack_directory "${outside_dir}"; then + status_code=0 + else + status_code=$? + fi + [ "${status_code}" -eq 2 ] + [ -d "${outside_dir}" ] + + rm -rf "${outside_dir}" +} + +@test "get_stack_dir_by_name returns the matching stack directory and ignores junk entries" { + local sandbox_root="" + local stacks_dir="" + local matching_stack_dir="" + local junk_dir="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "stack-lookup")" + easy_docker_test_override_repo_root "${sandbox_root}" + stacks_dir="${sandbox_root}/.easy-docker/stacks" + + junk_dir="${stacks_dir}/not-a-stack" + matching_stack_dir="${stacks_dir}/target-stack" + + mkdir -p "${junk_dir}" "${matching_stack_dir}" + printf '%s\n' '{ "stack_name": "target-stack" }' >"${matching_stack_dir}/metadata.json" + + run get_stack_dir_by_name "target-stack" + [ "${status}" -eq 0 ] + [ "${output}" = "${matching_stack_dir}" ] +} + +@test "get_stack_dir_by_name fails when the stack is absent" { + local sandbox_root="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "stack-missing")" + easy_docker_test_override_repo_root "${sandbox_root}" + + run get_stack_dir_by_name "missing-stack" + [ "${status}" -eq 1 ] + [ -z "${output}" ] +} diff --git a/tests/easy-docker/56_site_metadata_read.bats b/tests/easy-docker/56_site_metadata_read.bats new file mode 100755 index 00000000..14715992 --- /dev/null +++ b/tests/easy-docker/56_site_metadata_read.bats @@ -0,0 +1,124 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + local repo_root="" + + easy_docker_test_begin + easy_docker_test_source_apps_modules + easy_docker_test_install_jq_stub + + repo_root="$(easy_docker_test_repo_root)" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/metadata.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/site/metadata.sh" +} + +teardown() { + easy_docker_test_end +} + +@test "site metadata readers keep existing values independent of JSON layout" { + local sandbox_root="" + local stack_dir="" + local expected_apps="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "site-metadata-read")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "site-metadata-read")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "site": { + "name": "site-a.local", + "last_error": "", + "created_at": "2026-04-20T10:00:00Z", + "last_backup_at": "2026-04-20T12:00:00Z", + "apps_installed": [ + "erpnext", + "crm", + "my_custom_app" + ] + } +} +EOF + expected_apps=$'erpnext\ncrm\nmy_custom_app' + + run get_stack_site_name "${stack_dir}" + [ "${status}" -eq 0 ] + [ "${output}" = "site-a.local" ] + + run get_stack_site_created_at "${stack_dir}" + [ "${status}" -eq 0 ] + [ "${output}" = "2026-04-20T10:00:00Z" ] + + run get_stack_site_last_backup_at "${stack_dir}" + [ "${status}" -eq 0 ] + [ "${output}" = "2026-04-20T12:00:00Z" ] + + run get_stack_site_apps_installed_lines "${stack_dir}" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected_apps}" ] +} + +@test "persist_stack_site_metadata keeps the canonical site layout and preserves top-level metadata order" { + local sandbox_root="" + local stack_dir="" + local expected_metadata="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "site-metadata-write")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "site-metadata-write")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "schema_version": 1, + "stack_name": "site-metadata-write", + "setup_type": "production", + "frappe_branch": "version-16", + "created_at": "2026-04-20T10:00:00Z", + "wizard": { + "topology": "single-host" + } +} +EOF + + if ! persist_stack_site_metadata "${stack_dir}" "single-site" "site-a.local" $'erpnext\ncrm' "create-site" "" "" "2026-04-20T10:00:00Z" "2026-04-20T12:00:00Z" ""; then + false + fi + + expected_metadata="$( + cat <<'EOF' +{ + "schema_version": 1, + "stack_name": "site-metadata-write", + "setup_type": "production", + "frappe_branch": "version-16", + "created_at": "2026-04-20T10:00:00Z", + "wizard": { + "topology": "single-host" + }, + "site": { + "mode": "single-site", + "name": "site-a.local", + "apps_installed": [ + "erpnext", + "crm" + ], + "last_action": "create-site", + "last_error": "", + "error_log_path": "", + "created_at": "2026-04-20T10:00:00Z", + "updated_at": "2026-04-20T12:00:00Z", + "last_backup_at": "" + } +} +EOF + )" + + run cat "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected_metadata}" ] +} diff --git a/tests/easy-docker/60_compose_render_failures.bats b/tests/easy-docker/60_compose_render_failures.bats new file mode 100755 index 00000000..49761fac --- /dev/null +++ b/tests/easy-docker/60_compose_render_failures.bats @@ -0,0 +1,208 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_core_render_modules + easy_docker_test_install_jq_stub +} + +teardown() { + easy_docker_test_end +} + +write_docker_stub() { + local body="${1}" + + easy_docker_test_write_bin_command docker \ + 'set -euo pipefail' \ + "${body}" + easy_docker_test_prepend_bin_dir +} + +@test "render_stack_compose_from_metadata fails when metadata.json is missing" { + local sandbox_root="" + local stack_dir="" + local generated_compose_path="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-missing-metadata")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-missing-metadata")" + mkdir -p "${stack_dir}" + + # shellcheck disable=SC2016 + write_docker_stub 'echo "docker should not have been called" >&2; exit 99' + + generated_compose_path="${stack_dir}/compose.generated.yaml" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 1 ] + [ ! -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + [ ! -f "${docker_log}" ] +} + +@test "render_stack_compose_from_metadata fails when the env file is missing" { + local sandbox_root="" + local stack_dir="" + local generated_compose_path="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-missing-env")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-missing-env")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "render-missing-env", + "compose_files": [ + "compose.yaml" + ] +} +EOF + + # shellcheck disable=SC2016 + write_docker_stub 'echo "docker should not have been called" >&2; exit 99' + + generated_compose_path="${stack_dir}/compose.generated.yaml" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 1 ] + [ ! -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + [ ! -f "${docker_log}" ] +} + +@test "render_stack_compose_from_metadata fails when compose_files are missing" { + local sandbox_root="" + local stack_dir="" + local generated_compose_path="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-missing-compose-files")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-missing-compose-files")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "render-missing-compose-files" +} +EOF + + cat >"${stack_dir}/render-missing-compose-files.env" <<'EOF' +ERPNEXT_VERSION=15.9.0-test +EOF + + # shellcheck disable=SC2016 + write_docker_stub 'echo "docker should not have been called" >&2; exit 99' + + generated_compose_path="${stack_dir}/compose.generated.yaml" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 1 ] + [ ! -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + [ ! -f "${docker_log}" ] +} + +@test "render_stack_compose_from_metadata fails when a referenced compose file is missing" { + local sandbox_root="" + local stack_dir="" + local generated_compose_path="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-missing-source")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-missing-source")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "render-missing-source", + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml" + ] +} +EOF + + cat >"${stack_dir}/render-missing-source.env" <<'EOF' +ERPNEXT_VERSION=15.9.0-test +EOF + + cat >"${sandbox_root}/compose.yaml" <<'EOF' +services: + backend: + image: frappe/backend +EOF + + # shellcheck disable=SC2016 + write_docker_stub 'echo "docker should not have been called" >&2; exit 99' + + generated_compose_path="${stack_dir}/compose.generated.yaml" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 1 ] + [ ! -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + [ ! -f "${docker_log}" ] +} + +@test "render_stack_compose_from_metadata removes its temporary file after a docker config failure" { + local sandbox_root="" + local stack_dir="" + local generated_compose_path="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "render-docker-failure")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "render-docker-failure")" + mkdir -p "${stack_dir}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "stack_name": "render-docker-failure", + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml" + ] +} +EOF + + cat >"${stack_dir}/render-docker-failure.env" <<'EOF' +ERPNEXT_VERSION=15.9.0-test +EOF + + cat >"${sandbox_root}/compose.yaml" <<'EOF' +services: + backend: + image: frappe/backend +EOF + mkdir -p "${sandbox_root}/overrides" + cat >"${sandbox_root}/overrides/compose.proxy.yaml" <<'EOF' +services: + frontend: + image: frappe/frontend +EOF + + # shellcheck disable=SC2016 + write_docker_stub 'printf "%s\n" "docker $*" >>"${EASY_DOCKER_TEST_TMPDIR}/docker.log"; if [ "${*: -1}" = "config" ]; then echo "simulated docker compose config failure" >&2; exit 23; fi; exit 0' + + generated_compose_path="${stack_dir}/compose.generated.yaml" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + run render_stack_compose_from_metadata "${stack_dir}" + [ "${status}" -eq 1 ] + [ ! -f "${generated_compose_path}" ] + [ ! -f "${generated_compose_path}.tmp" ] + [ -f "${docker_log}" ] + [ "$(cat "${docker_log}")" != "" ] +} diff --git a/tests/easy-docker/65_apps_jq_migration.bats b/tests/easy-docker/65_apps_jq_migration.bats new file mode 100755 index 00000000..3a8205b0 --- /dev/null +++ b/tests/easy-docker/65_apps_jq_migration.bats @@ -0,0 +1,270 @@ +#!/usr/bin/env bats + +load 'test_helper.bash' + +setup() { + easy_docker_test_begin + easy_docker_test_source_apps_modules + easy_docker_test_install_jq_stub +} + +teardown() { + easy_docker_test_end +} + +write_predefined_apps_catalog() { + local sandbox_root="${1}" + + mkdir -p "${sandbox_root}/scripts/easy-docker/config" + cat >"${sandbox_root}/scripts/easy-docker/config/apps.tsv" <<'EOF' +erpnext ERPNext https://github.com/frappe/erpnext version-16 version-16,version-15 +crm CRM https://github.com/frappe/crm main main,develop +EOF +} + +write_containerfile_fixture() { + local sandbox_root="${1}" + + mkdir -p "${sandbox_root}/images/layered" + cat >"${sandbox_root}/images/layered/Containerfile" <<'EOF' +FROM scratch +EOF +} + +write_stack_metadata_fixture() { + local stack_dir="${1}" + + cat >"${stack_dir}/metadata.json" <<'EOF' +{ + "schema_version": 1, + "stack_name": "my-production-stack", + "setup_type": "production", + "frappe_branch": "version-16", + "created_at": "2026-04-08T16:12:09Z", + "apps": { + "predefined": [ + "erpnext", + "crm" + ], + "predefined_branches": { + "erpnext": "version-16", + "crm": "main" + }, + "custom": [ + { + "repo": "https://github.com/example/custom-app", + "branch": "stable" + } + ] + }, + "wizard": { + "topology": "single-host", + "selection": { + "proxy_mode_id": "traefik-http" + }, + "env": { + "CUSTOM_IMAGE": "production_image", + "CUSTOM_TAG": "v1.0.0" + }, + "compose_files": [ + "compose.yaml", + "overrides/compose.proxy.yaml" + ], + "updated_at": "2026-04-08T16:19:02Z" + } +} +EOF +} + +@test "metadata app readers use jq and keep expected line formats" { + local sandbox_root="" + local stack_dir="" + local expected_custom="" + local expected_branches="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "apps-reader")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "apps-reader")" + mkdir -p "${stack_dir}" + write_stack_metadata_fixture "${stack_dir}" + + expected_custom='https://github.com/example/custom-app|stable' + expected_branches=$'erpnext|version-16\ncrm|main' + + run get_metadata_apps_predefined_csv "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "erpnext,crm" ] + + run get_metadata_apps_custom_lines "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected_custom}" ] + + run get_metadata_apps_predefined_branch_lines "${stack_dir}/metadata.json" + [ "${status}" -eq 0 ] + [ "${output}" = "${expected_branches}" ] +} + +@test "persist_stack_metadata_apps_object keeps apps before wizard with legacy formatting" { + local sandbox_root="" + local stack_dir="" + local metadata_path="" + local apps_json_object="" + local expected="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "apps-persist")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "apps-persist")" + mkdir -p "${stack_dir}" + metadata_path="${stack_dir}/metadata.json" + + cat >"${metadata_path}" <<'EOF' +{ + "schema_version": 1, + "stack_name": "my-production-stack", + "wizard": { + "topology": "single-host" + } +} +EOF + + build_metadata_apps_json_object apps_json_object "erpnext,crm" $'erpnext|version-16\ncrm|main' "" + + run persist_stack_metadata_apps_object "${stack_dir}" "${apps_json_object}" + [ "${status}" -eq 0 ] + + expected=$'{\n "schema_version": 1,\n "stack_name": "my-production-stack",\n "apps": {\n "predefined": [\n "erpnext",\n "crm"\n ],\n "predefined_branches": {\n "erpnext": "version-16",\n "crm": "main"\n },\n "custom": [\n\n ]\n },\n "wizard": {\n "topology": "single-host"\n }\n}\n' + + run cat "${metadata_path}" + [ "${status}" -eq 0 ] + [ "${output}"$'\n' = "${expected}" ] +} + +@test "persist_stack_metadata_wizard_object preserves existing apps formatting" { + local sandbox_root="" + local stack_dir="" + local metadata_path="" + local wizard_json_object="" + local expected="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "wizard-persist")" + easy_docker_test_override_repo_root "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "wizard-persist")" + mkdir -p "${stack_dir}" + metadata_path="${stack_dir}/metadata.json" + write_stack_metadata_fixture "${stack_dir}" + + wizard_json_object=$'{\n "topology": "single-host",\n "selection": {\n "proxy_mode_id": "traefik-https"\n },\n "env": {\n "CUSTOM_IMAGE": "production_image",\n "CUSTOM_TAG": "v2.0.0"\n }\n }' + + run persist_stack_metadata_wizard_object "${stack_dir}" "${wizard_json_object}" + [ "${status}" -eq 0 ] + + expected=$'{\n "schema_version": 1,\n "stack_name": "my-production-stack",\n "setup_type": "production",\n "frappe_branch": "version-16",\n "created_at": "2026-04-08T16:12:09Z",\n "apps": {\n "predefined": [\n "erpnext",\n "crm"\n ],\n "predefined_branches": {\n "erpnext": "version-16",\n "crm": "main"\n },\n "custom": [\n {\n "repo": "https://github.com/example/custom-app",\n "branch": "stable"\n }\n ]\n },\n "wizard": {\n "topology": "single-host",\n "selection": {\n "proxy_mode_id": "traefik-https"\n },\n "env": {\n "CUSTOM_IMAGE": "production_image",\n "CUSTOM_TAG": "v2.0.0"\n }\n }\n}\n' + + run cat "${metadata_path}" + [ "${status}" -eq 0 ] + [ "${output}"$'\n' = "${expected}" ] +} + +@test "build_stack_apps_json_content_from_metadata_apps keeps apps.json output format" { + local sandbox_root="" + local stack_dir="" + local apps_json_content="" + local expected="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "apps-json")" + easy_docker_test_override_repo_root "${sandbox_root}" + write_predefined_apps_catalog "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "apps-json")" + mkdir -p "${stack_dir}" + write_stack_metadata_fixture "${stack_dir}" + + if ! build_stack_apps_json_content_from_metadata_apps apps_json_content "${stack_dir}"; then + false + fi + + expected=$'[\n {"url": "https://github.com/frappe/erpnext", "branch": "version-16"},\n {"url": "https://github.com/frappe/crm", "branch": "main"},\n {"url": "https://github.com/example/custom-app", "branch": "stable"}\n]\n' + + [ "${apps_json_content}" = "${expected}" ] +} + +@test "build_stack_custom_image fails clearly when jq is unavailable" { + local sandbox_root="" + local stack_dir="" + local env_path="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "build-no-jq")" + easy_docker_test_override_repo_root "${sandbox_root}" + write_predefined_apps_catalog "${sandbox_root}" + write_containerfile_fixture "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "build-no-jq")" + mkdir -p "${stack_dir}" + write_stack_metadata_fixture "${stack_dir}" + env_path="${stack_dir}/build-no-jq.env" + + cat >"${env_path}" <<'EOF' +CUSTOM_IMAGE=production_image +CUSTOM_TAG=v1.0.0 +EOF + + get_easy_docker_jq_command() { + return 1 + } + + run build_stack_custom_image "${stack_dir}" + [ "${status}" -eq 25 ] +} + +@test "build_stack_custom_image parses apps.json with jq before git branch checks and passes apps.json as a build secret" { + local sandbox_root="" + local stack_dir="" + local env_path="" + local git_log="" + local docker_log="" + + sandbox_root="$(easy_docker_test_create_repo_sandbox "build-with-jq")" + easy_docker_test_override_repo_root "${sandbox_root}" + write_predefined_apps_catalog "${sandbox_root}" + write_containerfile_fixture "${sandbox_root}" + stack_dir="$(easy_docker_test_stack_dir "build-with-jq")" + mkdir -p "${stack_dir}" + write_stack_metadata_fixture "${stack_dir}" + env_path="${stack_dir}/my-production-stack.env" + + cat >"${env_path}" <<'EOF' +CUSTOM_IMAGE=production_image +CUSTOM_TAG=v1.0.0 +EOF + + git_log="${EASY_DOCKER_TEST_TMPDIR}/git.log" + docker_log="${EASY_DOCKER_TEST_TMPDIR}/docker.log" + + # shellcheck disable=SC2016 + easy_docker_test_write_bin_command git \ + 'set -euo pipefail' \ + "printf '%s\n' \"git \$*\" >>\"${git_log}\"" \ + 'if [ "${1:-}" = "ls-remote" ]; then' \ + ' exit 0' \ + 'fi' \ + 'exit 64' + + easy_docker_test_write_bin_command docker \ + 'set -euo pipefail' \ + "printf '%s\n' \"docker \$*\" >>\"${docker_log}\"" \ + 'exit 0' + + easy_docker_test_prepend_bin_dir + + run build_stack_custom_image "${stack_dir}" + [ "${status}" -eq 0 ] + + run cat "${git_log}" + [ "${status}" -eq 0 ] + [[ "${output}" == *'git ls-remote --exit-code --heads https://github.com/frappe/erpnext version-16'* ]] + [[ "${output}" == *'git ls-remote --exit-code --heads https://github.com/frappe/crm main'* ]] + [[ "${output}" == *'git ls-remote --exit-code --heads https://github.com/example/custom-app stable'* ]] + + run cat "${docker_log}" + [ "${status}" -eq 0 ] + [[ "${output}" == *'docker build -f '* ]] + [[ "${output}" == *"--secret id=apps_json,src=${stack_dir}/apps.json"* ]] +} diff --git a/tests/easy-docker/test_helper.bash b/tests/easy-docker/test_helper.bash new file mode 100755 index 00000000..5d5cb6f5 --- /dev/null +++ b/tests/easy-docker/test_helper.bash @@ -0,0 +1,445 @@ +#!/usr/bin/env bash + +easy_docker_test_repo_root() { + local helper_dir="" + + helper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "${helper_dir}/../.." && pwd) +} + +easy_docker_test_begin() { + EASY_DOCKER_TEST_TMPDIR="$(mktemp -d)" + export EASY_DOCKER_TEST_TMPDIR + unset EASY_DOCKER_REPO_ROOT_OVERRIDE +} + +easy_docker_test_end() { + if [ -n "${EASY_DOCKER_TEST_TMPDIR:-}" ] && [ -d "${EASY_DOCKER_TEST_TMPDIR}" ]; then + rm -rf "${EASY_DOCKER_TEST_TMPDIR}" + fi +} + +easy_docker_test_bin_dir() { + printf '%s/bin\n' "${EASY_DOCKER_TEST_TMPDIR}" +} + +easy_docker_test_write_executable() { + local target_path="${1}" + local system_bash="" + shift + + system_bash="$(command -v bash)" + mkdir -p "$(dirname "${target_path}")" + + { + printf '#!%s\n' "${system_bash}" + printf '%s\n' "$@" + } >"${target_path}" + chmod +x "${target_path}" +} + +easy_docker_test_write_bin_command() { + local command_name="${1}" + local target_path="" + shift + + target_path="$(easy_docker_test_bin_dir)/${command_name}" + easy_docker_test_write_executable "${target_path}" "$@" +} + +easy_docker_test_prepend_bin_dir() { + PATH="$(easy_docker_test_bin_dir):${PATH}" + export PATH +} + +easy_docker_test_source_common_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + # shellcheck source=scripts/easy-docker/lib/core/commands.sh + source "${repo_root}/scripts/easy-docker/lib/core/commands.sh" + # shellcheck source=scripts/easy-docker/lib/core/messages.sh + source "${repo_root}/scripts/easy-docker/lib/core/messages.sh" + # shellcheck source=scripts/easy-docker/lib/core/json.sh + source "${repo_root}/scripts/easy-docker/lib/core/json.sh" +} + +easy_docker_test_source_core_render_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/core.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/core.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/render.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/compose/render.sh" +} + +easy_docker_test_source_apps_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/core.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/core.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/helpers.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/helpers.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh" + # shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/build.sh + source "${repo_root}/scripts/easy-docker/lib/app/wizard/common/compose/build.sh" +} + +easy_docker_test_source_docker_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/checks/docker.sh + source "${repo_root}/scripts/easy-docker/lib/checks/docker.sh" +} + +easy_docker_test_source_jq_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/checks/jq.sh + source "${repo_root}/scripts/easy-docker/lib/checks/jq.sh" +} + +easy_docker_test_source_gum_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/install/gum/package_manager.sh + source "${repo_root}/scripts/easy-docker/lib/install/gum/package_manager.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/github_release.sh + source "${repo_root}/scripts/easy-docker/lib/install/gum/github_release.sh" + # shellcheck source=scripts/easy-docker/lib/install/gum/ensure.sh + source "${repo_root}/scripts/easy-docker/lib/install/gum/ensure.sh" +} + +easy_docker_test_source_screen_modules() { + local repo_root="" + + repo_root="$(easy_docker_test_repo_root)" + + easy_docker_test_source_common_modules + + # shellcheck source=scripts/easy-docker/lib/app/screen.sh + source "${repo_root}/scripts/easy-docker/lib/app/screen.sh" +} + +easy_docker_test_source_screen_modules_with_tty_stdout() { + easy_docker_test_source_screen_modules + + # shellcheck disable=SC2317 + stdout_is_terminal() { + return 0 + } +} + +easy_docker_test_create_repo_sandbox() { + local sandbox_name="${1}" + local sandbox_root="" + + sandbox_root="${EASY_DOCKER_TEST_TMPDIR}/repo-${sandbox_name}" + mkdir -p "${sandbox_root}/.easy-docker/stacks" "${sandbox_root}/overrides" + printf '%s\n' "${sandbox_root}" +} + +easy_docker_test_override_repo_root() { + EASY_DOCKER_REPO_ROOT_OVERRIDE="${1}" + export EASY_DOCKER_REPO_ROOT_OVERRIDE +} + +easy_docker_test_stack_dir() { + local stack_name="${1}" + + printf '%s/.easy-docker/stacks/%s\n' "${EASY_DOCKER_REPO_ROOT_OVERRIDE}" "${stack_name}" +} + +easy_docker_test_install_docker_stub() { + local log_file="" + + log_file="${EASY_DOCKER_TEST_TMPDIR}/docker.invocations" + + # shellcheck disable=SC2016 + easy_docker_test_write_bin_command docker \ + 'set -euo pipefail' \ + "log_file=\"${log_file}\"" \ + 'printf '"'"'%s\n'"'"' "docker $*" >>"${log_file}"' \ + 'if [ "${1:-}" != "compose" ]; then' \ + ' echo "unexpected docker subcommand: ${1:-}" >&2' \ + ' exit 64' \ + 'fi' \ + 'if [ "${!#}" != "config" ]; then' \ + ' echo "expected docker compose config invocation" >&2' \ + ' exit 65' \ + 'fi' \ + 'printf '"'"'invocation=%s\n'"'"' "docker $*"' \ + 'printf '"'"'erpnext=%s\n'"'"' "${ERPNEXT_VERSION:-}"' + + easy_docker_test_prepend_bin_dir +} + +easy_docker_test_install_jq_stub() { + # shellcheck disable=SC2016 + easy_docker_test_write_bin_command jq \ + 'set -euo pipefail' \ + 'raw_output=0' \ + 'exit_status=0' \ + 'filter_expr=""' \ + 'file_path=""' \ + 'arg_field_name=""' \ + 'arg_key=""' \ + 'arg_app_id=""' \ + 'while [ "$#" -gt 0 ]; do' \ + ' case "${1}" in' \ + ' -r)' \ + ' raw_output=1' \ + ' shift' \ + ' ;;' \ + ' -e)' \ + ' exit_status=1' \ + ' shift' \ + ' ;;' \ + ' --arg)' \ + ' case "${2}" in' \ + ' field_name) arg_field_name="${3}" ;;' \ + ' key) arg_key="${3}" ;;' \ + ' app_id) arg_app_id="${3}" ;;' \ + ' esac' \ + ' shift 3' \ + ' ;;' \ + ' --indent)' \ + ' shift 2' \ + ' ;;' \ + ' -*)' \ + ' shift' \ + ' ;;' \ + ' *)' \ + ' if [ -z "${filter_expr}" ]; then' \ + ' filter_expr="${1}"' \ + ' elif [ -z "${file_path}" ]; then' \ + ' file_path="${1}"' \ + ' else' \ + ' echo "unsupported jq stub arguments" >&2' \ + ' exit 2' \ + ' fi' \ + ' shift' \ + ' ;;' \ + ' esac' \ + 'done' \ + 'if [ -z "${filter_expr}" ]; then' \ + ' echo "missing jq filter" >&2' \ + ' exit 2' \ + 'fi' \ + 'cleanup_file=""' \ + 'if [ -n "${file_path}" ] && [ "${file_path}" != "-" ]; then' \ + ' payload_path="${file_path}"' \ + 'else' \ + ' payload_path="$(mktemp)"' \ + ' cleanup_file="${payload_path}"' \ + ' cat >"${payload_path}"' \ + 'fi' \ + 'jq_stub_cleanup() {' \ + ' if [ -n "${cleanup_file}" ] && [ -f "${cleanup_file}" ]; then' \ + ' rm -f "${cleanup_file}"' \ + ' fi' \ + '}' \ + 'trap jq_stub_cleanup EXIT' \ + 'jq_stub_is_object() {' \ + ' awk '"'"'BEGIN { found=0 } /^[[:space:]]*$/ { next } { if ($0 ~ /^[[:space:]]*{/) found=1; exit } END { exit(found ? 0 : 1) }'"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_first_string_field() {' \ + ' local field_name="${1}"' \ + ' awk -v field_name="${field_name}" '"'"'match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"", parts) { print parts[1]; exit }'"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_array_strings() {' \ + ' local key="${1}"' \ + ' awk -v key="${key}" '"'"'' \ + ' function emit_matches(segment, parts) {' \ + ' while (match(segment, /"([^"]+)"/, parts)) {' \ + ' print parts[1]' \ + ' segment = substr(segment, RSTART + RLENGTH)' \ + ' }' \ + ' }' \ + ' $0 ~ "\"" key "\"[[:space:]]*:[[:space:]]*\\[" {' \ + ' segment = $0' \ + ' sub(/^.*\[[[:space:]]*/, "", segment)' \ + ' emit_matches(segment)' \ + ' if (segment ~ /\]/) {' \ + ' exit' \ + ' }' \ + ' in_array = 1' \ + ' next' \ + ' }' \ + ' in_array {' \ + ' emit_matches($0)' \ + ' if ($0 ~ /\]/) {' \ + ' exit' \ + ' }' \ + ' }' \ + ' '"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_object_entries() {' \ + ' local key="${1}"' \ + ' awk -v key="${key}" '"'"'' \ + ' $0 ~ "\"" key "\"[[:space:]]*:[[:space:]]*\\{" { in_object = 1; next }' \ + ' in_object && /^[[:space:]]*}/ { exit }' \ + ' in_object && match($0, /"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts) { print parts[1] "|" parts[2] }' \ + ' '"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_lookup_object_value() {' \ + ' local object_key="${1}"' \ + ' local lookup_key="${2}"' \ + ' awk -v object_key="${object_key}" -v lookup_key="${lookup_key}" '"'"'' \ + ' $0 ~ "\"" object_key "\"[[:space:]]*:[[:space:]]*\\{" { in_object = 1; next }' \ + ' in_object && /^[[:space:]]*}/ { exit }' \ + ' in_object && match($0, /"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts) {' \ + ' if (parts[1] == lookup_key) {' \ + ' print parts[2]' \ + ' exit' \ + ' }' \ + ' }' \ + ' '"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_top_level_keys() {' \ + ' awk '"'"'match($0, /^ "([^"]+)":/, parts) { print parts[1] }'"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_count_delta() {' \ + ' local line="${1}"' \ + ' local opens=0' \ + ' local closes=0' \ + ' local tmp=""' \ + ' tmp="${line//[^\{]/}"' \ + ' opens=$((opens + ${#tmp}))' \ + ' tmp="${line//[^\[]/}"' \ + ' opens=$((opens + ${#tmp}))' \ + ' tmp="${line//[^\}]/}"' \ + ' closes=$((closes + ${#tmp}))' \ + ' tmp="${line//[^\]]/}"' \ + ' closes=$((closes + ${#tmp}))' \ + ' printf "%s\n" "$((opens - closes))"' \ + '}' \ + 'jq_stub_top_level_value() {' \ + ' local key="${1}"' \ + ' local line=""' \ + ' local value=""' \ + ' local in_block=0' \ + ' local depth=0' \ + ' local delta=0' \ + ' while IFS= read -r line || [ -n "${line}" ]; do' \ + ' if [ "${in_block}" -eq 0 ]; then' \ + ' case "${line}" in' \ + ' " \"${key}\":"*)' \ + ' value="${line#*: }"' \ + ' if [[ "${value}" == \{* || "${value}" == \[* ]]; then' \ + ' printf "%s\n" "${value}"' \ + ' depth="$(jq_stub_count_delta "${value}")"' \ + ' if [ "${depth}" -le 0 ]; then' \ + ' return 0' \ + ' fi' \ + ' in_block=1' \ + ' else' \ + ' value="${value%,}"' \ + ' printf "%s\n" "${value}"' \ + ' return 0' \ + ' fi' \ + ' ;;' \ + ' esac' \ + ' else' \ + ' delta="$(jq_stub_count_delta "${line}")"' \ + ' if [ $((depth + delta)) -le 0 ]; then' \ + ' printf "%s\n" "${line%,}"' \ + ' return 0' \ + ' fi' \ + ' printf "%s\n" "${line}"' \ + ' depth=$((depth + delta))' \ + ' fi' \ + ' done <"${payload_path}"' \ + '}' \ + 'jq_stub_apps_custom_lines() {' \ + ' local repo=""' \ + ' local branch=""' \ + ' awk '"'"'' \ + ' /"custom"[[:space:]]*:[[:space:]]*\[/ { in_custom = 1; next }' \ + ' in_custom && /\]/ { exit }' \ + ' in_custom && match($0, /"repo"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts) { repo = parts[1] }' \ + ' in_custom && match($0, /"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts) { branch = parts[1] }' \ + ' in_custom && repo != "" && branch != "" { print repo "|" branch; repo = ""; branch = "" }' \ + ' '"'"' "${payload_path}"' \ + '}' \ + 'jq_stub_apps_json_refs() {' \ + ' awk '"'"'match($0, /"url"[[:space:]]*:[[:space:]]*"([^"]+)".*"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts) { print parts[1] "|" parts[2] }'"'"' "${payload_path}"' \ + '}' \ + 'case "${filter_expr}" in' \ + ' "(.apps.predefined // []) | join(\",\")")' \ + ' output="$(jq_stub_array_strings "predefined" | paste -sd, -)"' \ + ' [ -n "${output}" ] && printf "%s\n" "${output}"' \ + ' ;;' \ + ' "(.apps.custom // [])[]? | select(has(\"repo\") and has(\"branch\")) | \"\\(.repo)|\\(.branch)\"")' \ + ' jq_stub_apps_custom_lines' \ + ' ;;' \ + ' "(.apps.predefined_branches // {}) | to_entries[]? | \"\\(.key)|\\(.value)\"")' \ + ' jq_stub_object_entries "predefined_branches"' \ + ' ;;' \ + ' ".apps.predefined_branches[\$app_id] // empty")' \ + ' jq_stub_lookup_object_value "predefined_branches" "${arg_app_id}"' \ + ' ;;' \ + ' "[.. | objects | .[\$field_name]? | select(type == \"string\")][0] // empty")' \ + ' jq_stub_first_string_field "${arg_field_name}"' \ + ' ;;' \ + ' "([.. | objects | .compose_files? | select(type == \"array\")] | .[0] // [])[]?")' \ + ' jq_stub_array_strings "compose_files"' \ + ' ;;' \ + ' ".site[\$field_name] // empty")' \ + ' jq_stub_first_string_field "${arg_field_name}"' \ + ' ;;' \ + ' "(.site.apps_installed // [])[]? | select(type == \"string\")")' \ + ' jq_stub_array_strings "apps_installed"' \ + ' ;;' \ + ' "type == \"object\"")' \ + ' if jq_stub_is_object; then' \ + ' [ "${exit_status}" -eq 0 ] && printf "true\n"' \ + ' exit 0' \ + ' fi' \ + ' [ "${exit_status}" -eq 1 ] && exit 1' \ + ' printf "false\n"' \ + ' exit 0' \ + ' ;;' \ + ' "keys_unsorted[]")' \ + ' jq_stub_top_level_keys' \ + ' ;;' \ + ' ".[\$key]")' \ + ' jq_stub_top_level_value "${arg_key}"' \ + ' ;;' \ + ' ".[]? | select((.url // \"\") != \"\" and (.branch // \"\") != \"\") | \"\\(.url)|\\(.branch)\"")' \ + ' jq_stub_apps_json_refs' \ + ' ;;' \ + ' *)' \ + ' echo "unsupported jq filter in stub: ${filter_expr}" >&2' \ + ' exit 2' \ + ' ;;' \ + 'esac' + + easy_docker_test_prepend_bin_dir +}