refactor(easy-docker): split wizard shell modules

This commit is contained in:
RocketQuack 2026-03-28 17:19:08 +01:00
parent 1a839299ab
commit da905fb1c4
32 changed files with 4847 additions and 3521 deletions

View file

@ -1,3 +1,7 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/frappe-bench/env/bin/python"
"python.defaultInterpreterPath": "${workspaceFolder}/frappe-bench/env/bin/python",
"editor.detectIndentation": false,
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true
}

View file

@ -0,0 +1,128 @@
# Easy-Docker Development Team Process
## Purpose
This document defines the working model for the easy-docker team.
Focus is process, responsibilities, and execution flow for ongoing refactoring and feature work.
## Team Setup
- Lead Developer
- Owns scope, priorities, and release decisions.
- Resolves conflicts between technical proposals.
- Approves final merge readiness.
- Senior Developer A (Correctness)
- Reviews control flow, edge cases, and failure behavior.
- Validates data handling, state transitions, and rollback paths.
- Checks defensive programming and explicit error handling.
- Senior Developer B (Architecture)
- Reviews modularity, coupling, and naming consistency.
- Drives DRY/KISS refactors and shared helper extraction.
- Validates maintainability and testability.
- Implementation Developer
- Delivers code changes according to approved scope.
- Keeps behavior stable unless change is explicitly requested.
- Adds/update docs for structure and flow changes.
- QA/Verification Owner
- Runs pre-commit and targeted checks.
- Executes reproducible manual test matrix for wizard paths.
- Reports pass/fail with concrete reproduction steps.
## Working Agreement
- No hidden behavior changes during refactors.
- Source-of-truth decisions must be explicit and documented.
- New code must prefer existing helpers over duplicated logic.
- Every change batch must be reviewable by concern (flow, env, compose, ui).
## Daily Process (Tomorrow Plan)
1. Kickoff (15 min)
- Confirm target scope for the day.
- Confirm "no functional change" boundaries.
- Assign owners for implementation and verification.
2. Design sync (20 min)
- Compare at least two technical options for non-trivial edits.
- Select one approach with short tradeoff note.
3. Implementation blocks
- Work in small vertical batches (one concern per batch).
- Keep public function contracts stable where possible.
- Update docs in the same batch when structure changes.
4. Review blocks
- Senior A reviews correctness and failure paths.
- Senior B reviews architecture and maintainability.
- Lead resolves conflicts and accepts/rejects batch.
5. Verification block
- Run pre-commit for changed files.
- Run targeted manual flow checks.
- Record results in short checklist format.
6. Handover
- Write what is done, what is pending, and next first task.
- List any blockers with owner and proposed resolution.
## Implementation Workflow
1. Define scope and constraints.
2. Map affected files/functions.
3. Propose options and select approach.
4. Implement with small commits by concern.
5. Validate with checks and manual path coverage.
6. Document final state and next steps.
## Review Workflow
1. Findings-first review format.
2. Severity order: BLOCKER, HIGH, MEDIUM, LOW.
3. Each point must include file reference and reason.
4. Lead decision:
- Approved
- Approved with conditions
- Not approved
## Test and Verification Matrix (Minimum)
- Create new production stack and complete wizard.
- Create new development stack and complete wizard.
- Manage existing stack:
- Apps -> Generate apps.json
- Apps -> Select apps and branches
- Docker -> Generate docker compose from env
- Docker -> Start stack in Docker Compose (single-host topology)
- Abort/Back paths:
- Back navigation in each submenu
- Abort wizard with rollback
- Validation paths:
- Domain validation error then correction
- Branch selection from apps catalog (including back-navigation)
## Definition of Done (Team)
- Scope completed with no unplanned behavior change.
- No avoidable duplication introduced.
- Review completed by both senior roles.
- Lead verdict documented.
- Verification evidence recorded.
- Handover notes prepared for next workday.
## Handover Template
Use this at day end:
```text
Date:
Completed:
- ...
In Progress:
- ...
Next First Task:
- ...
Blockers:
- <owner> - <issue> - <proposed action>
Verification:
- pre-commit: <pass/fail + note>
- manual matrix: <pass/fail + note>
```

View file

@ -0,0 +1,174 @@
# Easy-Docker Single-Stack Readiness
## Purpose
This document freezes the required single-stack scope for `easy-docker`
before work moves to `separate services`.
Current interpretation:
- `single-stack` means the implemented `single-host` topology.
- The stack must be isolated from other stacks at the Docker Compose project level.
- The supported happy path is one usable site per stack unless a later
product decision explicitly broadens this.
- The current site bootstrap path always installs the full app selection
stored on the stack itself.
- It is not yet supported to create multiple sites in one stack with
different app selections per site.
## Current Supported Scope
The current codebase already supports these single-stack paths:
- Create production stack
- Create development stack
- Choose `single-host` topology
- Select proxy, database, and redis mode
- Select apps and branches
- Persist `metadata.json`, stack `.env`, `apps.json`
- Render `compose.generated.yaml`
- Manage existing stacks
- Regenerate `apps.json`
- Re-select apps and branches
- Build custom image
- Start stack with Docker Compose
- Stop stack with Docker Compose
- Show stack runtime status
- Abort wizard with rollback or keep-files behavior
- Isolate stacks through stack-specific Compose project names
## Definition Of Done Before Separate Services
Single-stack is not considered complete when containers merely run.
It is considered complete when the user can move from stack creation
to a usable Frappe/ERPNext site and operate that stack safely.
Minimum user-facing path:
1. Create stack
2. Configure single-host topology
3. Build image if needed
4. Start stack
5. Create/bootstrap first site
6. Install selected apps on that site
7. Verify site access behind the chosen proxy mode
8. Stop/restart/down the stack
9. Re-open manage flow and inspect status/logs
## Required Remaining Changes
### High Priority
- Add a documented or automated site/bootstrap path
- create first site
- install selected apps
- verify site routing/access
- Freeze the supported site model
- recommended: one site per stack as the supported happy path
### Medium Priority
- Add remaining lifecycle operations
- `restart`
- `down/remove`
- `logs`
- Add post-start recovery guidance
- partial start
- failed bootstrap
- retry after custom image rebuild
- Add one-time cleanup/runbook note for stacks created before
per-stack Compose project isolation
### Hardening Priority
- Keep runtime status semantics explicit
- `Not created`
- `Created`
- `Running`
- `Partial`
- `Stopped`
- `Restarting`
- optional uptime hint
- Ensure manage actions only affect the selected stack
- Preserve safe abort/rollback behavior
## Required Single-Stack Paths
### Setup Paths
- Environment check
- Create production stack
- Create development stack
- Complete single-host wizard
- Back/cancel at each prompt
- Abort wizard with rollback
- Abort wizard while keeping files
### Runtime Paths
- Generate compose from env
- Build custom image
- Start stack
- Stop stack
- Restart stack
- Down/remove stack resources
- Inspect runtime status
- Inspect logs
### Site Paths
- Create first site
- Install selected apps on the site
- Current limitation: the site install set is the stack app set
- one stack -> one supported site -> one shared app selection
- Verify the site is reachable
- Re-open and manage the stack after restart
### Recovery Paths
- Missing custom image -> build -> retry start
- Invalid app branch -> mapped build failure
- Partial start -> inspect status/logs -> retry
- Failed bootstrap -> rerun or recover cleanly
- Cleanup of pre-isolation shared Compose leftovers
## Verification Matrix
Before calling single-stack ready, the team should execute at least:
1. Environment/bootstrap gate
2. New production single-stack creation
3. New development single-stack creation
4. Apps regeneration/update path
5. Compose render path
6. Custom image build success and failure paths
7. Start path including missing-image build/retry
8. Stop path
9. Runtime isolation between two stacks
10. Runtime status in not-created/created/running/partial/stopped states
11. Abort/back/rollback paths
12. Validation error and correction paths
13. Site/bootstrap reality check after stack start
Required automated checks on every single-stack change:
- `bash -n` on touched shell files
- `pre-commit run --files <changed easy-docker files>`
- compose render/config validation for at least one production
and one development stack
## Lead Verdict
`single-stack` is close on the Compose/runtime side but is not yet fully done.
The largest remaining gap before `separate services` is the missing
site/bootstrap lifecycle. After that, the next most important gaps are
`restart`, `down/remove`, `logs`, and reproducible manual verification.
Recommended order:
1. Freeze single-stack site model
2. Add site/bootstrap path
3. Add `restart`, `down/remove`, and `logs`
4. Run the verification matrix
5. Move to `separate services`

View file

@ -0,0 +1,76 @@
# Easy Docker Wizard Flow (Clean View)
This document shows the wizard paths in a clean, forward-only view.
Back/Cancel/Exit loops are intentionally hidden to keep the flow readable.
## 1) Main Wizard Paths
```mermaid
flowchart TD
A[Main Menu]
A --> B[Production Setup]
A --> C[Development Setup]
A --> D[Environment Check]
A --> Z[Exit]
B --> E[Create new stack]
B --> F[Manage existing stacks]
C --> E2[Create new stack]
C --> F2[Manage existing stacks]
E --> G[Create stack dir + metadata.json]
E2 --> G
G --> H[Topology selection]
H --> I[Single-host flow]
H --> J[Split services flow]
I --> K[Persist files + render compose]
K --> L[Done]
J --> J2[Current status: placeholder only]
J2 --> L2[Pending implementation]
F --> M[Select existing stack]
F2 --> M
M --> N[Manage stack actions]
N --> N1[Apps actions]
N --> N2[Docker actions]
N1 --> O[apps.json generated/updated]
N2 --> P[compose.generated.yaml rendered]
N2 --> Q[Start stack in Docker Compose]
Q --> Q1{Topology}
Q1 -->|single-host| Q2[docker compose up -d]
Q1 -->|split-services / others| Q3[Show runbook warning]
```
## 2) Single-host Detail Path
```mermaid
flowchart TD
S1[Single-host selected]
S1 --> S2[Choose proxy mode]
S2 --> S3[Choose database mode]
S3 --> S4[Choose redis mode]
S4 --> S5[Set CUSTOM_IMAGE + CUSTOM_TAG]
S5 --> S6[Select apps: apps catalog]
S6 --> S7[For each selected app: fetch branches + choose branch]
S7 --> S8[Proxy-specific questions]
S8 --> S9[Database-specific questions]
S9 --> S10[Write .env]
S10 --> S11[Write metadata.json]
S11 --> S12[Generate apps.json]
S12 --> S13[Render compose.generated.yaml]
S13 --> S14[Success message]
```
## 3) Notes
- This is a readability-focused flow map, not an exhaustive state machine.
- Navigation loops (Back/Cancel/Exit) are intentionally omitted.
- `Split services` remains not fully implemented in the wizard runtime.
- `Start stack in Docker Compose` currently supports only `single-host` topology.
- Site bootstrap is currently scoped to one supported site per stack.
- The site bootstrap installs the full app selection stored on the stack.
- Multiple sites in one stack with different per-site app selections are
not supported yet and are planned for a later phase.

View file

@ -0,0 +1,136 @@
# Easy Docker Wizard Flow
```mermaid
flowchart TD
A[Main Menu] -->|Production Stack| B[Setup Menu: Production]
A -->|Development Stack| C[Setup Menu: Development]
A -->|Environment check| D[Environment Status]
A -->|Exit| Z1[Exit App]
D -->|Back to main menu| A
D -->|Exit and close easy-docker| Z1
B -->|Create new stack| E[Prompt: Stack name]
B -->|Manage existing stacks| F[List existing production stacks]
B -->|Back| A
B -->|Exit| Z1
C -->|Create new stack| E2[Prompt: Stack name]
C -->|Manage existing stacks| F2[List existing development stacks]
C -->|Back| A
C -->|Exit| Z1
E --> E3[Select Frappe branch profile from frappe.tsv]
E2 --> E4[Select Frappe branch profile from frappe.tsv]
E3 --> G[Create stack directory + metadata.json]
E4 --> G2[Create stack directory + metadata.json]
G --> H[Topology Menu]
G2 --> H2[Topology Menu]
H -->|Single-host| I[Single-host selection]
H -->|Split services| J[Split services example]
H -->|Abort wizard to main menu| K[Abort prompt]
H -->|Back/Cancel| B
H2 -->|Single-host| I
H2 -->|Split services| J
H2 -->|Abort wizard to main menu| K
H2 -->|Back/Cancel| C
J -->|Use this topology| J2[Info: placeholder path]
J -->|Back| H
J2 --> H
K -->|Rollback files and return to main menu| A
K -->|Keep files and return to main menu| A
K -->|Back to topology selection| H
I --> I1[Proxy mode]
I1 --> I2[Database mode]
I2 --> I3[Redis mode]
I3 --> I6[Prompt CUSTOM_IMAGE + CUSTOM_TAG]
I6 --> I7[App selection list]
I7 -->|Enter| I8[Per selected app: choose branch from apps.tsv]
I8 --> I9[Continue]
I9 --> P{Proxy specific questions}
P -->|traefik-https| P1[SITE_DOMAINS + LETSENCRYPT_EMAIL + HTTP_PUBLISH_PORT? + HTTPS_PUBLISH_PORT?]
P -->|nginxproxy-https| P2[SITE_DOMAINS + NGINX_PROXY_HOSTS + LETSENCRYPT_EMAIL + HTTP_PUBLISH_PORT? + HTTPS_PUBLISH_PORT?]
P -->|nginxproxy-http| P3[SITE_DOMAINS + NGINX_PROXY_HOSTS + HTTP_PUBLISH_PORT?]
P -->|traefik-http| P4[HTTP_PUBLISH_PORT?]
P -->|caddy-external / no-proxy| P5[HTTP_PUBLISH_PORT? default 8080]
P1 --> DBQ
P2 --> DBQ
P3 --> DBQ
P4 --> DBQ
P5 --> DBQ
DBQ{Database specific question}
DBQ -->|postgres| DB1[DB_PASSWORD required]
DBQ -->|mariadb| DB2[DB_PASSWORD optional]
DB1 --> S[Write stack env file]
DB2 --> S
S --> T[Write metadata.json with top-level apps]
T --> U[Generate apps.json from metadata.json apps]
U --> V[Render compose.generated.yaml from metadata + env]
V --> W[Success message]
W --> B
F -->|Stack selected| M[Manage selected stack]
F -->|Back| B
F -->|Exit| Z1
F -->|No stacks found| F0[Manage stacks placeholder]
F0 -->|Back| B
F0 -->|Exit| Z1
F2 -->|Stack selected| M
F2 -->|Back| C
F2 -->|Exit| Z1
F2 -->|No stacks found| F20[Manage stacks placeholder]
F20 -->|Back| C
F20 -->|Exit| Z1
M --> M2[Stack actions: Apps / Docker / Back / Exit]
M2 -->|Apps| M3[Apps submenu]
M2 -->|Docker| M4[Docker submenu]
M2 -->|Back| M0[Return to current stack list]
M2 -->|Exit| Z1
M0 --> F
M0 --> F2
M3 -->|Generate apps.json| M31[Read metadata.json apps + regenerate apps.json]
M3 -->|Select apps and branches| M32[Re-prompt app and branch selection]
M32 --> M33[Update metadata.json apps]
M33 --> M34[Regenerate apps.json from metadata]
M34 --> M3
M3 -->|Back| M2
M3 -->|Exit| Z1
M31 --> M3
M4 -->|Generate docker compose from env| M41[Render compose.generated.yaml]
M4 -->|Start stack in Docker Compose| M42[Topology gate]
M42 -->|single-host| M43[docker compose up -d]
M42 -->|split-services / others| M44[Show topology-specific runbook message]
M4 -->|Back| M2
M4 -->|Exit| Z1
M41 --> M4
M43 --> M4
M44 --> M4
```
## Notes
- `SITE_DOMAINS` validation accepts only domain names in form `sub.domain.tld` or `sub.sub.domain.tld`.
- Existing stack lists are filtered by `setup_type` (`production` vs `development`).
- In `Manage existing stacks`, navigation options are only `Back` and `Exit`.
- `Select apps and branches` writes app selection to top-level `apps` in `metadata.json`.
- `Generate apps.json` uses only `metadata.json -> apps` as source of truth.
- New stack wizard always uses custom image path (no separate official-vs-custom image step).
- `Start stack in Docker Compose` is currently allowed only for `single-host` topology stacks.
## Module Layout
- `lib/app/wizard/common.sh` is now a loader for common modules under `lib/app/wizard/common/`.
- `lib/app/wizard/env.sh` is now a loader for env modules under `lib/app/wizard/env/`.
- `lib/app/wizard/flows.sh` is now a loader for flow modules under `lib/app/wizard/flows/`.
- Public function names and flow behavior remain unchanged; only code organization was refactored.

View file

@ -1,934 +1,15 @@
#!/usr/bin/env bash
trim_predefined_catalog_field() {
local result_var="${1}"
local value="${2}"
load_easy_docker_wizard_app_modules() {
local apps_dir=""
apps_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/apps"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf -v "${result_var}" "%s" "${value}"
# 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"
}
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
}
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
awk '
/"apps"[[:space:]]*:[[:space:]]*{/ {
in_apps = 1
}
in_apps && /"predefined"[[:space:]]*:[[:space:]]*\[/ {
in_predefined = 1
next
}
in_predefined && /\]/ {
in_predefined = 0
next
}
in_predefined {
if (match($0, /"([^"]+)"/, parts)) {
if (csv == "") {
csv = parts[1]
} else {
csv = csv "," parts[1]
}
}
}
END {
if (csv != "") {
print csv
}
}
' "${metadata_path}"
}
get_metadata_apps_custom_lines() {
local metadata_path="${1}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
awk '
/"apps"[[:space:]]*:[[:space:]]*{/ {
in_apps = 1
}
in_apps && /"custom"[[:space:]]*:[[:space:]]*\[/ {
in_custom = 1
next
}
in_custom && /\]/ {
in_custom = 0
repo = ""
branch = ""
next
}
in_custom {
if (match($0, /"repo"[[:space:]]*:[[:space:]]*"([^"]+)"/, repo_parts)) {
repo = repo_parts[1]
}
if (match($0, /"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, branch_parts)) {
branch = branch_parts[1]
}
if (repo != "" && branch != "") {
print repo "|" branch
repo = ""
branch = ""
}
}
' "${metadata_path}"
}
get_metadata_apps_predefined_branch_lines() {
local metadata_path="${1}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
awk '
/"apps"[[:space:]]*:[[:space:]]*{/ {
in_apps = 1
}
in_apps && /"predefined_branches"[[:space:]]*:[[:space:]]*{/ {
in_predefined_branches = 1
next
}
in_predefined_branches && /}/ {
in_predefined_branches = 0
next
}
in_predefined_branches {
if (match($0, /"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts)) {
print parts[1] "|" parts[2]
}
}
' "${metadata_path}"
}
get_metadata_apps_predefined_branch_for_id() {
local metadata_path="${1}"
local app_id_lookup="${2}"
local line=""
local app_id=""
local app_branch=""
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
app_id="${line%%|*}"
app_branch="${line#*|}"
if [ "${app_id}" = "${app_id_lookup}" ] && [ -n "${app_branch}" ]; then
printf '%s\n' "${app_branch}"
return 0
fi
done < <(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true)
return 1
}
build_stack_apps_json_content_from_metadata_apps() {
local result_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local preset_apps_csv=""
local custom_apps_lines=""
local predefined_branch=""
local preset_branch=""
local catalog_default_branch=""
local app=""
local line=""
local repo=""
local branch=""
local url=""
local escaped_url=""
local escaped_branch=""
local entry_json=""
local entries_json=""
local -a preset_apps=()
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
preset_apps_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)"
custom_apps_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)"
preset_branch="$(get_stack_frappe_branch "${stack_dir}" || true)"
if [ -z "${preset_branch}" ]; then
preset_branch="$(get_default_frappe_branch)"
fi
if [ -n "${preset_apps_csv}" ]; then
IFS=',' read -r -a preset_apps <<<"${preset_apps_csv}"
for app in "${preset_apps[@]}"; do
url="$(get_predefined_app_repo_by_id "${app}" || true)"
if [ -z "${url}" ]; then
return 1
fi
predefined_branch="$(get_metadata_apps_predefined_branch_for_id "${metadata_path}" "${app}" || true)"
if [ -z "${predefined_branch}" ]; then
catalog_default_branch="$(get_predefined_app_default_branch_by_id "${app}" || true)"
if [ -n "${catalog_default_branch}" ]; then
predefined_branch="${catalog_default_branch}"
else
predefined_branch="${preset_branch}"
fi
fi
escaped_url="$(json_escape_string "${url}")"
escaped_branch="$(json_escape_string "${predefined_branch}")"
entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")"
if [ -z "${entries_json}" ]; then
entries_json="${entry_json}"
else
entries_json="${entries_json}"$',\n'"${entry_json}"
fi
done
fi
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
repo="${line%%|*}"
branch="${line#*|}"
if [ -z "${repo}" ] || [ -z "${branch}" ]; then
continue
fi
escaped_url="$(json_escape_string "${repo}")"
escaped_branch="$(json_escape_string "${branch}")"
entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")"
if [ -z "${entries_json}" ]; then
entries_json="${entry_json}"
else
entries_json="${entries_json}"$',\n'"${entry_json}"
fi
done <<EOF
${custom_apps_lines}
EOF
if [ -z "${entries_json}" ]; then
printf -v "${result_var}" "[\n]\n"
else
printf -v "${result_var}" "[\n%s\n]\n" "${entries_json}"
fi
return 0
}
persist_stack_apps_json_from_metadata_apps() {
local stack_dir="${1}"
local apps_json_content=""
if ! build_stack_apps_json_content_from_metadata_apps apps_json_content "${stack_dir}"; then
return 1
fi
if ! persist_stack_apps_json_content "${stack_dir}" "${apps_json_content}"; then
return 1
fi
return 0
}
persist_stack_metadata_apps_object() {
local stack_dir="${1}"
local apps_json_object="${2}"
local metadata_path=""
local metadata_tmp_path=""
metadata_path="${stack_dir}/metadata.json"
metadata_tmp_path="${metadata_path}.tmp"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if [ -z "${apps_json_object}" ]; then
return 1
fi
if ! awk -v apps_object="${apps_json_object}" '
BEGIN {
in_top_level_apps = 0
apps_depth = 0
inserted = 0
prev = ""
}
function flush_prev() {
if (prev != "") {
print prev
prev = ""
}
}
{
if (!in_top_level_apps && $0 ~ /^ "apps"[[:space:]]*:/) {
flush_prev()
print " \"apps\": " apps_object ","
in_top_level_apps = 1
inserted = 1
if ($0 ~ /{/) {
apps_depth += gsub(/{/, "{", $0)
apps_depth -= gsub(/}/, "}", $0)
} else {
apps_depth = 0
}
if (apps_depth <= 0) {
in_top_level_apps = 0
}
next
}
if (in_top_level_apps) {
apps_depth += gsub(/{/, "{", $0)
apps_depth -= gsub(/}/, "}", $0)
if (apps_depth <= 0) {
in_top_level_apps = 0
}
next
}
if (!inserted && $0 ~ /^ "wizard"[[:space:]]*:/) {
flush_prev()
print " \"apps\": " apps_object ","
inserted = 1
}
if (!inserted && $0 ~ /^}/) {
if (prev != "") {
if (prev !~ /,[[:space:]]*$/) {
prev = prev ","
}
print prev
prev = ""
}
print " \"apps\": " apps_object
inserted = 1
print $0
next
}
flush_prev()
prev = $0
}
END {
flush_prev()
if (!inserted) {
exit 2
}
}
' "${metadata_path}" >"${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_wizard_object() {
local stack_dir="${1}"
local wizard_json_object="${2}"
local metadata_path=""
local metadata_tmp_path=""
metadata_path="${stack_dir}/metadata.json"
metadata_tmp_path="${metadata_path}.tmp"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if [ -z "${wizard_json_object}" ]; then
return 1
fi
if ! awk -v wizard_object="${wizard_json_object}" '
BEGIN {
in_top_level_wizard = 0
wizard_depth = 0
inserted = 0
prev = ""
}
function flush_prev() {
if (prev != "") {
print prev
prev = ""
}
}
{
if (!in_top_level_wizard && $0 ~ /^ "wizard"[[:space:]]*:/) {
flush_prev()
print " \"wizard\": " wizard_object
in_top_level_wizard = 1
inserted = 1
if ($0 ~ /{/) {
wizard_depth += gsub(/{/, "{", $0)
wizard_depth -= gsub(/}/, "}", $0)
} else {
wizard_depth = 0
}
if (wizard_depth <= 0) {
in_top_level_wizard = 0
}
next
}
if (in_top_level_wizard) {
wizard_depth += gsub(/{/, "{", $0)
wizard_depth -= gsub(/}/, "}", $0)
if (wizard_depth <= 0) {
in_top_level_wizard = 0
}
next
}
if (!inserted && $0 ~ /^}/) {
if (prev != "") {
if (prev !~ /,[[:space:]]*$/) {
prev = prev ","
}
print prev
prev = ""
}
print " \"wizard\": " wizard_object
inserted = 1
print $0
next
}
flush_prev()
prev = $0
}
END {
flush_prev()
if (!inserted) {
exit 2
}
}
' "${metadata_path}" >"${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
}
load_easy_docker_wizard_app_modules

View file

@ -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
}

View file

@ -0,0 +1,378 @@
#!/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
awk '
/"apps"[[:space:]]*:[[:space:]]*{/ {
in_apps = 1
}
in_apps && /"predefined"[[:space:]]*:[[:space:]]*\[/ {
in_predefined = 1
next
}
in_predefined && /\]/ {
in_predefined = 0
next
}
in_predefined {
if (match($0, /"([^"]+)"/, parts)) {
if (csv == "") {
csv = parts[1]
} else {
csv = csv "," parts[1]
}
}
}
END {
if (csv != "") {
print csv
}
}
' "${metadata_path}"
}
get_metadata_apps_custom_lines() {
local metadata_path="${1}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
awk '
/"apps"[[:space:]]*:[[:space:]]*{/ {
in_apps = 1
}
in_apps && /"custom"[[:space:]]*:[[:space:]]*\[/ {
in_custom = 1
next
}
in_custom && /\]/ {
in_custom = 0
repo = ""
branch = ""
next
}
in_custom {
if (match($0, /"repo"[[:space:]]*:[[:space:]]*"([^"]+)"/, repo_parts)) {
repo = repo_parts[1]
}
if (match($0, /"branch"[[:space:]]*:[[:space:]]*"([^"]+)"/, branch_parts)) {
branch = branch_parts[1]
}
if (repo != "" && branch != "") {
print repo "|" branch
repo = ""
branch = ""
}
}
' "${metadata_path}"
}
get_metadata_apps_predefined_branch_lines() {
local metadata_path="${1}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
awk '
/"apps"[[:space:]]*:[[:space:]]*{/ {
in_apps = 1
}
in_apps && /"predefined_branches"[[:space:]]*:[[:space:]]*{/ {
in_predefined_branches = 1
next
}
in_predefined_branches && /}/ {
in_predefined_branches = 0
next
}
in_predefined_branches {
if (match($0, /"([^"]+)"[[:space:]]*:[[:space:]]*"([^"]+)"/, parts)) {
print parts[1] "|" parts[2]
}
}
' "${metadata_path}"
}
get_metadata_apps_predefined_branch_for_id() {
local metadata_path="${1}"
local app_id_lookup="${2}"
local line=""
local app_id=""
local app_branch=""
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
app_id="${line%%|*}"
app_branch="${line#*|}"
if [ "${app_id}" = "${app_id_lookup}" ] && [ -n "${app_branch}" ]; then
printf '%s\n' "${app_branch}"
return 0
fi
done < <(get_metadata_apps_predefined_branch_lines "${metadata_path}" || true)
return 1
}
build_stack_apps_json_content_from_metadata_apps() {
local result_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local preset_apps_csv=""
local custom_apps_lines=""
local predefined_branch=""
local preset_branch=""
local catalog_default_branch=""
local app=""
local line=""
local repo=""
local branch=""
local url=""
local escaped_url=""
local escaped_branch=""
local entry_json=""
local entries_json=""
local -a preset_apps=()
metadata_path="${stack_dir}/metadata.json"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
preset_apps_csv="$(get_metadata_apps_predefined_csv "${metadata_path}" || true)"
custom_apps_lines="$(get_metadata_apps_custom_lines "${metadata_path}" || true)"
preset_branch="$(get_stack_frappe_branch "${stack_dir}" || true)"
if [ -z "${preset_branch}" ]; then
preset_branch="$(get_default_frappe_branch)"
fi
if [ -n "${preset_apps_csv}" ]; then
IFS=',' read -r -a preset_apps <<<"${preset_apps_csv}"
for app in "${preset_apps[@]}"; do
url="$(get_predefined_app_repo_by_id "${app}" || true)"
if [ -z "${url}" ]; then
return 1
fi
predefined_branch="$(get_metadata_apps_predefined_branch_for_id "${metadata_path}" "${app}" || true)"
if [ -z "${predefined_branch}" ]; then
catalog_default_branch="$(get_predefined_app_default_branch_by_id "${app}" || true)"
if [ -n "${catalog_default_branch}" ]; then
predefined_branch="${catalog_default_branch}"
else
predefined_branch="${preset_branch}"
fi
fi
escaped_url="$(json_escape_string "${url}")"
escaped_branch="$(json_escape_string "${predefined_branch}")"
entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")"
if [ -z "${entries_json}" ]; then
entries_json="${entry_json}"
else
entries_json="${entries_json}"$',\n'"${entry_json}"
fi
done
fi
while IFS= read -r line; do
if [ -z "${line}" ]; then
continue
fi
repo="${line%%|*}"
branch="${line#*|}"
if [ -z "${repo}" ] || [ -z "${branch}" ]; then
continue
fi
escaped_url="$(json_escape_string "${repo}")"
escaped_branch="$(json_escape_string "${branch}")"
entry_json="$(printf ' {"url": "%s", "branch": "%s"}' "${escaped_url}" "${escaped_branch}")"
if [ -z "${entries_json}" ]; then
entries_json="${entry_json}"
else
entries_json="${entries_json}"$',\n'"${entry_json}"
fi
done <<EOF
${custom_apps_lines}
EOF
if [ -z "${entries_json}" ]; then
printf -v "${result_var}" "[\n]\n"
else
printf -v "${result_var}" "[\n%s\n]\n" "${entries_json}"
fi
return 0
}
persist_stack_apps_json_from_metadata_apps() {
local stack_dir="${1}"
local apps_json_content=""
if ! build_stack_apps_json_content_from_metadata_apps apps_json_content "${stack_dir}"; then
return 1
fi
if ! persist_stack_apps_json_content "${stack_dir}" "${apps_json_content}"; then
return 1
fi
return 0
}
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=""
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 ! awk -v object_key="${object_key}" -v object_json="${object_json}" -v insert_before_key="${insert_before_key}" '
BEGIN {
target_regex = "^ \"" object_key "\"[[:space:]]*:"
before_regex = ""
if (insert_before_key != "") {
before_regex = "^ \"" insert_before_key "\"[[:space:]]*:"
}
in_target = 0
target_depth = 0
inserted = 0
prev = ""
}
function flush_prev() {
if (prev != "") {
print prev
prev = ""
}
}
{
if (!in_target && $0 ~ target_regex) {
flush_prev()
if (object_key == "wizard") {
print " \"" object_key "\": " object_json
} else {
print " \"" object_key "\": " object_json ","
}
in_target = 1
inserted = 1
if ($0 ~ /{/) {
target_depth += gsub(/{/, "{", $0)
target_depth -= gsub(/}/, "}", $0)
} else {
target_depth = 0
}
if (target_depth <= 0) {
in_target = 0
}
next
}
if (in_target) {
target_depth += gsub(/{/, "{", $0)
target_depth -= gsub(/}/, "}", $0)
if (target_depth <= 0) {
in_target = 0
}
next
}
if (!inserted && before_regex != "" && $0 ~ before_regex) {
flush_prev()
print " \"" object_key "\": " object_json ","
inserted = 1
}
if (!inserted && $0 ~ /^}/) {
if (prev != "") {
if (prev !~ /,[[:space:]]*$/) {
prev = prev ","
}
print prev
prev = ""
}
print " \"" object_key "\": " object_json
inserted = 1
print $0
next
}
flush_prev()
prev = $0
}
END {
flush_prev()
if (!inserted) {
exit 2
}
}
' "${metadata_path}" >"${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}"
}

View file

@ -0,0 +1,191 @@
#!/usr/bin/env bash
persist_stack_metadata_apps_object() {
local stack_dir="${1}"
local apps_json_object="${2}"
local metadata_path=""
local metadata_tmp_path=""
metadata_path="${stack_dir}/metadata.json"
metadata_tmp_path="${metadata_path}.tmp"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if [ -z "${apps_json_object}" ]; then
return 1
fi
if ! awk -v apps_object="${apps_json_object}" '
BEGIN {
in_top_level_apps = 0
apps_depth = 0
inserted = 0
prev = ""
}
function flush_prev() {
if (prev != "") {
print prev
prev = ""
}
}
{
if (!in_top_level_apps && $0 ~ /^ "apps"[[:space:]]*:/) {
flush_prev()
print " \"apps\": " apps_object ","
in_top_level_apps = 1
inserted = 1
if ($0 ~ /{/) {
apps_depth += gsub(/{/, "{", $0)
apps_depth -= gsub(/}/, "}", $0)
} else {
apps_depth = 0
}
if (apps_depth <= 0) {
in_top_level_apps = 0
}
next
}
if (in_top_level_apps) {
apps_depth += gsub(/{/, "{", $0)
apps_depth -= gsub(/}/, "}", $0)
if (apps_depth <= 0) {
in_top_level_apps = 0
}
next
}
if (!inserted && $0 ~ /^ "wizard"[[:space:]]*:/) {
flush_prev()
print " \"apps\": " apps_object ","
inserted = 1
}
if (!inserted && $0 ~ /^}/) {
if (prev != "") {
if (prev !~ /,[[:space:]]*$/) {
prev = prev ","
}
print prev
prev = ""
}
print " \"apps\": " apps_object
inserted = 1
print $0
next
}
flush_prev()
prev = $0
}
END {
flush_prev()
if (!inserted) {
exit 2
}
}
' "${metadata_path}" >"${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_wizard_object() {
local stack_dir="${1}"
local wizard_json_object="${2}"
local metadata_path=""
local metadata_tmp_path=""
metadata_path="${stack_dir}/metadata.json"
metadata_tmp_path="${metadata_path}.tmp"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
if [ -z "${wizard_json_object}" ]; then
return 1
fi
if ! awk -v wizard_object="${wizard_json_object}" '
BEGIN {
in_top_level_wizard = 0
wizard_depth = 0
inserted = 0
prev = ""
}
function flush_prev() {
if (prev != "") {
print prev
prev = ""
}
}
{
if (!in_top_level_wizard && $0 ~ /^ "wizard"[[:space:]]*:/) {
flush_prev()
print " \"wizard\": " wizard_object
in_top_level_wizard = 1
inserted = 1
if ($0 ~ /{/) {
wizard_depth += gsub(/{/, "{", $0)
wizard_depth -= gsub(/}/, "}", $0)
} else {
wizard_depth = 0
}
if (wizard_depth <= 0) {
in_top_level_wizard = 0
}
next
}
if (in_top_level_wizard) {
wizard_depth += gsub(/{/, "{", $0)
wizard_depth -= gsub(/}/, "}", $0)
if (wizard_depth <= 0) {
in_top_level_wizard = 0
}
next
}
if (!inserted && $0 ~ /^}/) {
if (prev != "") {
if (prev !~ /,[[:space:]]*$/) {
prev = prev ","
}
print prev
prev = ""
}
print " \"wizard\": " wizard_object
inserted = 1
print $0
next
}
flush_prev()
prev = $0
}
END {
flush_prev()
if (!inserted) {
exit 2
}
}
' "${metadata_path}" >"${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
}

View file

@ -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_single_host_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_single_host_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_single_host_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
}

View file

@ -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_single_host_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")
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 <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args_ref[@]}" -eq 0 ]; then
return "${missing_compose_code}"
fi
return 0
}

View file

@ -0,0 +1,233 @@
#!/usr/bin/env bash
get_stack_compose_runtime_status_label() {
local result_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local env_path=""
local stack_topology=""
local compose_files_lines=""
local compose_file=""
local source_compose_path=""
local fallback_erpnext_version=""
local container_ids_lines=""
local container_id=""
local container_status_lines=""
local container_status_line=""
local container_state=""
local container_status_text=""
local first_running_status=""
local running_status_excerpt=""
local running_status_varies=0
local compose_status=0
local total_containers_count=0
local running_containers_count=0
local exited_containers_count=0
local created_containers_count=0
local restarting_containers_count=0
local paused_containers_count=0
local dead_containers_count=0
local other_containers_count=0
local compose_project_name=""
local repo_root=""
local status_label=""
local -a compose_args=()
local -a docker_ps_args=()
easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name
if [ ! -f "${metadata_path}" ]; then
printf -v "${result_var}" "%s" "Unknown (metadata missing)"
return 0
fi
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
printf -v "${result_var}" "%s" "Unknown (topology missing)"
return 0
fi
case "${stack_topology}" in
"single-host") ;;
*)
printf -v "${result_var}" "%s" "N/A (${stack_topology})"
return 0
;;
esac
if [ ! -f "${env_path}" ]; then
printf -v "${result_var}" "%s" "Unknown (env missing)"
return 0
fi
easy_docker_compose_get_fallback_erpnext_version fallback_erpnext_version "${env_path}"
compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)"
if [ -z "${compose_files_lines}" ]; then
printf -v "${result_var}" "%s" "Unknown (compose files missing)"
return 0
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
printf -v "${result_var}" "%s" "Unknown (missing file: ${compose_file})"
return 0
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
printf -v "${result_var}" "%s" "Unknown (compose files missing)"
return 0
fi
if [ -n "${fallback_erpnext_version}" ]; then
container_ids_lines="$(
ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/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 <<EOF
${container_ids_lines}
EOF
container_status_lines="$(docker ps "${docker_ps_args[@]}" 2>/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 <<EOF
${container_status_line}
EOF
case "${container_state}" in
running)
running_containers_count=$((running_containers_count + 1))
if [ -z "${first_running_status}" ]; then
first_running_status="${container_status_text}"
elif [ "${container_status_text}" != "${first_running_status}" ]; then
running_status_varies=1
fi
;;
exited)
exited_containers_count=$((exited_containers_count + 1))
;;
created)
created_containers_count=$((created_containers_count + 1))
;;
restarting)
restarting_containers_count=$((restarting_containers_count + 1))
;;
paused)
paused_containers_count=$((paused_containers_count + 1))
;;
dead)
dead_containers_count=$((dead_containers_count + 1))
;;
*)
other_containers_count=$((other_containers_count + 1))
;;
esac
done <<EOF
${container_status_lines}
EOF
if [ "${total_containers_count}" -eq 0 ]; then
printf -v "${result_var}" "%s" "Not created"
return 0
fi
if [ -n "${first_running_status}" ]; then
case "${first_running_status}" in
Up\ *)
running_status_excerpt="${first_running_status#Up }"
;;
*)
running_status_excerpt="${first_running_status}"
;;
esac
fi
if [ "${running_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Running (${running_containers_count}/${total_containers_count} containers"
if [ -n "${running_status_excerpt}" ]; then
status_label="${status_label}, up ${running_status_excerpt}"
if [ "${running_status_varies}" -eq 1 ]; then
status_label="${status_label}+"
fi
fi
status_label="${status_label})"
elif [ "${running_containers_count}" -gt 0 ]; then
status_label="Partial (${running_containers_count}/${total_containers_count} running"
if [ "${restarting_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${restarting_containers_count} restarting"
elif [ "${paused_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${paused_containers_count} paused"
elif [ "${exited_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${exited_containers_count} stopped"
elif [ "${created_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${created_containers_count} created"
elif [ "${dead_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${dead_containers_count} dead"
elif [ "${other_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${other_containers_count} other"
fi
if [ -n "${running_status_excerpt}" ]; then
status_label="${status_label}, up ${running_status_excerpt}"
if [ "${running_status_varies}" -eq 1 ]; then
status_label="${status_label}+"
fi
fi
status_label="${status_label})"
elif [ "${restarting_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Restarting (${total_containers_count} containers)"
elif [ "${paused_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Paused (${total_containers_count} containers)"
elif [ "${created_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Created (${total_containers_count} containers)"
else
status_label="Stopped (${total_containers_count} containers)"
fi
printf -v "${result_var}" "%s" "${status_label}"
return 0
}

View file

@ -1,566 +1,17 @@
#!/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=()
load_easy_docker_compose_lifecycle_modules() {
local lifecycle_dir=""
lifecycle_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/start"
# 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") ;;
*)
# 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 <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 35
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
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh
source "${lifecycle_dir}/start.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/stop.sh
source "${lifecycle_dir}/stop.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/delete.sh
source "${lifecycle_dir}/delete.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/compose/start/status.sh
source "${lifecycle_dir}/status.sh"
}
stop_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 -a compose_args=()
# shellcheck disable=SC2034 # Read by manage flow after stop_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 41
fi
if [ ! -f "${env_path}" ]; then
return 42
fi
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
# shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata returns 43.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology"
return 43
fi
case "${stack_topology}" in
"single-host") ;;
*)
# shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata returns 44.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}"
return 44
;;
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 45
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 stop_stack_with_compose_from_metadata returns 46.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}"
return 46
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 45
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_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
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") ;;
*)
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 <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 52
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
}
get_stack_compose_runtime_status_label() {
local result_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local env_path=""
local stack_topology=""
local compose_files_lines=""
local compose_file=""
local source_compose_path=""
local env_erpnext_version=""
local fallback_erpnext_version=""
local container_ids_lines=""
local container_id=""
local container_status_lines=""
local container_status_line=""
local container_state=""
local container_status_text=""
local first_running_status=""
local running_status_excerpt=""
local running_status_varies=0
local compose_status=0
local total_containers_count=0
local running_containers_count=0
local exited_containers_count=0
local created_containers_count=0
local restarting_containers_count=0
local paused_containers_count=0
local dead_containers_count=0
local other_containers_count=0
local compose_project_name=""
local repo_root=""
local status_label=""
local -a compose_args=()
local -a docker_ps_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
printf -v "${result_var}" "%s" "Unknown (metadata missing)"
return 0
fi
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
printf -v "${result_var}" "%s" "Unknown (topology missing)"
return 0
fi
case "${stack_topology}" in
"single-host") ;;
*)
printf -v "${result_var}" "%s" "N/A (${stack_topology})"
return 0
;;
esac
if [ ! -f "${env_path}" ]; then
printf -v "${result_var}" "%s" "Unknown (env missing)"
return 0
fi
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
printf -v "${result_var}" "%s" "Unknown (compose files missing)"
return 0
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
printf -v "${result_var}" "%s" "Unknown (missing file: ${compose_file})"
return 0
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
printf -v "${result_var}" "%s" "Unknown (compose files missing)"
return 0
fi
if [ -n "${fallback_erpnext_version}" ]; then
container_ids_lines="$(
ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/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 <<EOF
${container_ids_lines}
EOF
container_status_lines="$(docker ps "${docker_ps_args[@]}" 2>/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 <<EOF
${container_status_line}
EOF
case "${container_state}" in
running)
running_containers_count=$((running_containers_count + 1))
if [ -z "${first_running_status}" ]; then
first_running_status="${container_status_text}"
elif [ "${container_status_text}" != "${first_running_status}" ]; then
running_status_varies=1
fi
;;
exited)
exited_containers_count=$((exited_containers_count + 1))
;;
created)
created_containers_count=$((created_containers_count + 1))
;;
restarting)
restarting_containers_count=$((restarting_containers_count + 1))
;;
paused)
paused_containers_count=$((paused_containers_count + 1))
;;
dead)
dead_containers_count=$((dead_containers_count + 1))
;;
*)
other_containers_count=$((other_containers_count + 1))
;;
esac
done <<EOF
${container_status_lines}
EOF
if [ "${total_containers_count}" -eq 0 ]; then
printf -v "${result_var}" "%s" "Not created"
return 0
fi
if [ -n "${first_running_status}" ]; then
case "${first_running_status}" in
Up\ *)
running_status_excerpt="${first_running_status#Up }"
;;
*)
running_status_excerpt="${first_running_status}"
;;
esac
fi
if [ "${running_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Running (${running_containers_count}/${total_containers_count} containers"
if [ -n "${running_status_excerpt}" ]; then
status_label="${status_label}, up ${running_status_excerpt}"
if [ "${running_status_varies}" -eq 1 ]; then
status_label="${status_label}+"
fi
fi
status_label="${status_label})"
elif [ "${running_containers_count}" -gt 0 ]; then
status_label="Partial (${running_containers_count}/${total_containers_count} running"
if [ "${restarting_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${restarting_containers_count} restarting"
elif [ "${paused_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${paused_containers_count} paused"
elif [ "${exited_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${exited_containers_count} stopped"
elif [ "${created_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${created_containers_count} created"
elif [ "${dead_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${dead_containers_count} dead"
elif [ "${other_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${other_containers_count} other"
fi
if [ -n "${running_status_excerpt}" ]; then
status_label="${status_label}, up ${running_status_excerpt}"
if [ "${running_status_varies}" -eq 1 ]; then
status_label="${status_label}+"
fi
fi
status_label="${status_label})"
elif [ "${restarting_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Restarting (${total_containers_count} containers)"
elif [ "${paused_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Paused (${total_containers_count} containers)"
elif [ "${created_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Created (${total_containers_count} containers)"
else
status_label="Stopped (${total_containers_count} containers)"
fi
printf -v "${result_var}" "%s" "${status_label}"
return 0
}
load_easy_docker_compose_lifecycle_modules

View file

@ -0,0 +1,110 @@
#!/usr/bin/env bash
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
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") ;;
*)
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 <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 52
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
}

View file

@ -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") ;;
*)
# 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 <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 35
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
}

View file

@ -0,0 +1,239 @@
#!/usr/bin/env bash
get_stack_compose_runtime_status_label() {
local result_var="${1}"
local stack_dir="${2}"
local metadata_path=""
local env_path=""
local stack_topology=""
local compose_files_lines=""
local compose_file=""
local source_compose_path=""
local env_erpnext_version=""
local fallback_erpnext_version=""
local container_ids_lines=""
local container_id=""
local container_status_lines=""
local container_status_line=""
local container_state=""
local container_status_text=""
local first_running_status=""
local running_status_excerpt=""
local running_status_varies=0
local compose_status=0
local total_containers_count=0
local running_containers_count=0
local exited_containers_count=0
local created_containers_count=0
local restarting_containers_count=0
local paused_containers_count=0
local dead_containers_count=0
local other_containers_count=0
local compose_project_name=""
local repo_root=""
local status_label=""
local -a compose_args=()
local -a docker_ps_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
printf -v "${result_var}" "%s" "Unknown (metadata missing)"
return 0
fi
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
printf -v "${result_var}" "%s" "Unknown (topology missing)"
return 0
fi
case "${stack_topology}" in
"single-host") ;;
*)
printf -v "${result_var}" "%s" "N/A (${stack_topology})"
return 0
;;
esac
if [ ! -f "${env_path}" ]; then
printf -v "${result_var}" "%s" "Unknown (env missing)"
return 0
fi
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
printf -v "${result_var}" "%s" "Unknown (compose files missing)"
return 0
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
printf -v "${result_var}" "%s" "Unknown (missing file: ${compose_file})"
return 0
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
printf -v "${result_var}" "%s" "Unknown (compose files missing)"
return 0
fi
if [ -n "${fallback_erpnext_version}" ]; then
container_ids_lines="$(
ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/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 <<EOF
${container_ids_lines}
EOF
container_status_lines="$(docker ps "${docker_ps_args[@]}" 2>/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 <<EOF
${container_status_line}
EOF
case "${container_state}" in
running)
running_containers_count=$((running_containers_count + 1))
if [ -z "${first_running_status}" ]; then
first_running_status="${container_status_text}"
elif [ "${container_status_text}" != "${first_running_status}" ]; then
running_status_varies=1
fi
;;
exited)
exited_containers_count=$((exited_containers_count + 1))
;;
created)
created_containers_count=$((created_containers_count + 1))
;;
restarting)
restarting_containers_count=$((restarting_containers_count + 1))
;;
paused)
paused_containers_count=$((paused_containers_count + 1))
;;
dead)
dead_containers_count=$((dead_containers_count + 1))
;;
*)
other_containers_count=$((other_containers_count + 1))
;;
esac
done <<EOF
${container_status_lines}
EOF
if [ "${total_containers_count}" -eq 0 ]; then
printf -v "${result_var}" "%s" "Not created"
return 0
fi
if [ -n "${first_running_status}" ]; then
case "${first_running_status}" in
Up\ *)
running_status_excerpt="${first_running_status#Up }"
;;
*)
running_status_excerpt="${first_running_status}"
;;
esac
fi
if [ "${running_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Running (${running_containers_count}/${total_containers_count} containers"
if [ -n "${running_status_excerpt}" ]; then
status_label="${status_label}, up ${running_status_excerpt}"
if [ "${running_status_varies}" -eq 1 ]; then
status_label="${status_label}+"
fi
fi
status_label="${status_label})"
elif [ "${running_containers_count}" -gt 0 ]; then
status_label="Partial (${running_containers_count}/${total_containers_count} running"
if [ "${restarting_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${restarting_containers_count} restarting"
elif [ "${paused_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${paused_containers_count} paused"
elif [ "${exited_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${exited_containers_count} stopped"
elif [ "${created_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${created_containers_count} created"
elif [ "${dead_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${dead_containers_count} dead"
elif [ "${other_containers_count}" -gt 0 ]; then
status_label="${status_label}, ${other_containers_count} other"
fi
if [ -n "${running_status_excerpt}" ]; then
status_label="${status_label}, up ${running_status_excerpt}"
if [ "${running_status_varies}" -eq 1 ]; then
status_label="${status_label}+"
fi
fi
status_label="${status_label})"
elif [ "${restarting_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Restarting (${total_containers_count} containers)"
elif [ "${paused_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Paused (${total_containers_count} containers)"
elif [ "${created_containers_count}" -eq "${total_containers_count}" ]; then
status_label="Created (${total_containers_count} containers)"
else
status_label="Stopped (${total_containers_count} containers)"
fi
printf -v "${result_var}" "%s" "${status_label}"
return 0
}

View file

@ -0,0 +1,89 @@
#!/usr/bin/env bash
stop_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 -a compose_args=()
# shellcheck disable=SC2034 # Read by manage flow after stop_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 41
fi
if [ ! -f "${env_path}" ]; then
return 42
fi
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
if [ -z "${stack_topology}" ]; then
# shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata returns 43.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology"
return 43
fi
case "${stack_topology}" in
"single-host") ;;
*)
# shellcheck disable=SC2034 # Read by manage flow after stop_stack_with_compose_from_metadata returns 44.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}"
return 44
;;
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 45
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 stop_stack_with_compose_from_metadata returns 46.
EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}"
return 46
fi
compose_args+=(-f "${source_compose_path}")
done <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 45
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
}

File diff suppressed because it is too large Load diff

View file

@ -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
}

View file

@ -0,0 +1,409 @@
#!/usr/bin/env bash
drop_stack_site_database() {
local stack_dir="${1}"
local db_name="${2}"
local db_password=""
local db_endpoint=""
local db_host=""
local db_port=""
local drop_db_command=""
db_password="$(get_stack_database_root_password "${stack_dir}")"
db_endpoint="$(get_stack_common_db_endpoint "${stack_dir}" || true)"
db_host="${db_endpoint%%|*}"
db_port="${db_endpoint#*|}"
if [ -z "${db_host}" ] || [ -z "${db_port}" ]; then
return 1
fi
drop_db_command="$(
printf "mysql --protocol=TCP -h %s -P %s -u root -p%s -e %s" \
"$(shell_quote_site_command_arg "${db_host}")" \
"$(shell_quote_site_command_arg "${db_port}")" \
"$(printf '%s' "${db_password}" | sed "s/'/'\"'\"'/g")" \
"$(shell_quote_site_command_arg "DROP DATABASE IF EXISTS \`${db_name}\`; DROP USER IF EXISTS '${db_name}'@'%'; DROP USER IF EXISTS '${db_name}'@'localhost'; FLUSH PRIVILEGES;")"
)"
if ! run_stack_backend_bash_command "${stack_dir}" "${drop_db_command}"; then
return 1
fi
return 0
}
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}" "${db_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=""
create_site_command="$(
printf "bench new-site %s --mariadb-user-host-login-scope='%%' --admin-password %s --db-root-username root --db-root-password %s" \
"$(shell_quote_site_command_arg "${site_name}")" \
"$(shell_quote_site_command_arg "${admin_password}")" \
"$(shell_quote_site_command_arg "$(get_stack_database_root_password "${stack_dir}")")"
)"
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}" \
"apps_installing" \
"${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
}
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 app_install_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}" "requested" "" "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}" "creating" "" "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
updated_at="$(get_current_utc_timestamp)"
if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "created" "" "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
updated_at="$(get_current_utc_timestamp)"
if ! persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "ready" "${installed_app_lines}" "install-apps" "" "" "${created_at}" "${updated_at}"; then
return 58
fi
return 0
}

View file

@ -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) ;;
*)
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 <<EOF
${compose_files_lines}
EOF
if [ "${#compose_args[@]}" -eq 0 ]; then
return 54
fi
wrapped_backend_command="$(printf "cd /home/frappe/frappe-bench && %s" "${backend_command}")"
if [ -n "${fallback_erpnext_version}" ]; then
ERPNEXT_VERSION="${fallback_erpnext_version}" docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" exec -T backend bash -lc "${wrapped_backend_command}" </dev/null
return $?
fi
docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" exec -T backend bash -lc "${wrapped_backend_command}" </dev/null
}
stack_backend_service_is_running() {
local stack_dir="${1}"
local backend_ready_status=0
if run_stack_backend_bash_command "${stack_dir}" "true" >/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"
}

View file

@ -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 <<EOF
${selected_app_lines}
EOF
printf -v "${result_var}" "%s" "${installed_app_lines}"
return 0
}
repair_stack_site_runtime_state() {
local stack_dir="${1}"
local database_id=""
local redis_id=""
local db_host=""
local db_port=""
local repair_command=""
database_id="$(get_stack_database_id "${stack_dir}" || true)"
redis_id="$(get_stack_redis_id "${stack_dir}" || true)"
case "${database_id}" in
mariadb)
db_host="db"
db_port="3306"
;;
postgres)
db_host="db"
db_port="5432"
;;
*)
return 57
;;
esac
repair_command="$(
cat <<EOF
mkdir -p sites
test -f sites/common_site_config.json || printf '{}' > sites/common_site_config.json
ls -1 apps > sites/apps.txt
bench set-config -g db_host ${db_host}
bench set-config -gp db_port ${db_port}
EOF
)"
case "${redis_id}" in
enabled)
repair_command="${repair_command}"$'\n'"bench set-config -g redis_cache redis://redis-cache:6379"
repair_command="${repair_command}"$'\n'"bench set-config -g redis_queue redis://redis-queue:6379"
repair_command="${repair_command}"$'\n'"bench set-config -g redis_socketio redis://redis-queue:6379"
;;
"" | disabled)
:
;;
*)
return 62
;;
esac
repair_command="${repair_command}"$'\n'"bench set-config -gp socketio_port 9000"
repair_command="${repair_command}"$'\n'"bench set-config -g chromium_path /usr/bin/chromium-headless-shell"
if ! run_stack_backend_bash_command "${stack_dir}" "${repair_command}"; then
return 62
fi
return 0
}
stack_site_has_partial_artifacts() {
local stack_dir="${1}"
local site_name="${2}"
if ! is_safe_stack_site_cleanup_name "${site_name}"; then
return 61
fi
if stack_site_exists_in_bench "${stack_dir}" "${site_name}"; then
return 0
fi
case $? in
61)
return 61
;;
54 | 52)
return $?
;;
esac
if stack_site_directory_exists "${stack_dir}" "${site_name}"; then
return 0
fi
case $? in
61)
return 61
;;
54 | 52)
return $?
;;
esac
return 1
}

View file

@ -0,0 +1,118 @@
#!/usr/bin/env bash
is_valid_stack_site_name() {
local site_name="${1}"
if [ -z "${site_name}" ]; then
return 1
fi
case "${site_name}" in
*[!A-Za-z0-9._-]*)
return 1
;;
*)
return 0
;;
esac
}
is_safe_stack_site_cleanup_name() {
local site_name="${1}"
if ! is_valid_stack_site_name "${site_name}"; then
return 1
fi
case "${site_name}" in
"." | ".." | "/" | "")
return 1
;;
*)
return 0
;;
esac
}
shell_quote_site_command_arg() {
local raw_value="${1}"
printf "'%s'" "$(printf '%s' "${raw_value}" | sed "s/'/'\"'\"'/g")"
}
get_stack_primary_site_name_suggestion() {
local stack_dir="${1}"
local env_path=""
local site_domains=""
local primary_domain=""
env_path="$(get_stack_env_path "${stack_dir}")"
site_domains="$(get_env_file_key_value "${env_path}" "SITE_DOMAINS" || true)"
primary_domain="${site_domains%%,*}"
primary_domain="${primary_domain%% *}"
if [ -n "${primary_domain}" ]; then
printf '%s\n' "${primary_domain}"
return 0
fi
printf '%s.localhost\n' "${stack_dir##*/}"
return 0
}
get_stack_database_id() {
local stack_dir="${1}"
get_metadata_string_field "${stack_dir}/metadata.json" "database_id"
}
get_stack_redis_id() {
local stack_dir="${1}"
get_metadata_string_field "${stack_dir}/metadata.json" "redis_id"
}
get_stack_database_root_password() {
local stack_dir="${1}"
local env_path=""
local db_password=""
env_path="$(get_stack_env_path "${stack_dir}")"
db_password="$(get_env_file_key_value "${env_path}" "DB_PASSWORD" || true)"
if [ -z "${db_password}" ]; then
db_password="123"
fi
printf '%s\n' "${db_password}"
return 0
}
stack_site_bootstrap_supports_database() {
local stack_dir="${1}"
local database_id=""
database_id="$(get_stack_database_id "${stack_dir}" || true)"
case "${database_id}" in
mariadb)
return 0
;;
*)
return 1
;;
esac
}
stack_supports_single_site_management() {
local stack_dir="${1}"
local stack_topology=""
stack_topology="$(get_stack_topology "${stack_dir}" || true)"
case "${stack_topology}" in
single-host)
return 0
;;
*)
return 1
;;
esac
}

View file

@ -1,362 +1,13 @@
#!/usr/bin/env bash
get_metadata_site_string_field() {
local metadata_path="${1}"
local field_name="${2}"
load_easy_docker_site_metadata_modules() {
local metadata_dir=""
metadata_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/metadata"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
awk -v field_name="${field_name}" '
/"site"[[:space:]]*:[[:space:]]*{/ {
in_site = 1
site_depth = 1
next
}
in_site {
if (match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"", parts)) {
print parts[1]
exit
}
line = $0
open_count = gsub(/{/, "{", line)
close_count = gsub(/}/, "}", line)
site_depth += open_count - close_count
if (site_depth <= 0) {
exit
}
}
' "${metadata_path}"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh
source "${metadata_dir}/read.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh
source "${metadata_dir}/write.sh"
}
get_metadata_site_apps_installed_lines() {
local metadata_path="${1}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
awk '
/"site"[[:space:]]*:[[:space:]]*{/ {
in_site = 1
site_depth = 1
next
}
in_site && /"apps_installed"[[:space:]]*:[[:space:]]*\[/ {
in_apps_installed = 1
next
}
in_apps_installed && /\]/ {
in_apps_installed = 0
next
}
in_apps_installed {
if (match($0, /"([^"]+)"/, parts)) {
print parts[1]
}
}
in_site {
line = $0
open_count = gsub(/{/, "{", line)
close_count = gsub(/}/, "}", line)
site_depth += open_count - close_count
if (site_depth <= 0) {
exit
}
}
' "${metadata_path}"
}
get_stack_site_name() {
local stack_dir="${1}"
get_metadata_site_string_field "${stack_dir}/metadata.json" "name"
}
get_stack_site_state() {
local stack_dir="${1}"
get_metadata_site_string_field "${stack_dir}/metadata.json" "state"
}
get_stack_site_created_at() {
local stack_dir="${1}"
get_metadata_site_string_field "${stack_dir}/metadata.json" "created_at"
}
get_stack_site_apps_installed_lines() {
local stack_dir="${1}"
get_metadata_site_apps_installed_lines "${stack_dir}/metadata.json"
}
stack_has_site_record() {
local stack_dir="${1}"
local site_name=""
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if [ -n "${site_name}" ]; then
return 0
fi
return 1
}
stack_has_site_configured() {
local stack_dir="${1}"
local site_state=""
site_state="$(get_stack_site_state "${stack_dir}" || true)"
case "${site_state}" in
created | apps_installing | ready)
return 0
;;
*)
return 1
;;
esac
}
get_stack_site_status_label() {
local result_var="${1}"
local stack_dir="${2}"
local site_state=""
local site_name=""
local site_status_label=""
site_state="$(get_stack_site_state "${stack_dir}" || true)"
site_name="$(get_stack_site_name "${stack_dir}" || true)"
case "${site_state}" in
"")
site_status_label="Not configured"
;;
requested)
site_status_label="Requested"
;;
creating)
site_status_label="Creating"
;;
created)
site_status_label="Created"
;;
apps_installing)
site_status_label="Installing apps"
;;
ready)
site_status_label="Ready"
;;
failed)
site_status_label="Failed"
;;
*)
site_status_label="${site_state}"
;;
esac
if [ -n "${site_name}" ]; then
site_status_label="${site_status_label} (${site_name})"
fi
printf -v "${result_var}" "%s" "${site_status_label}"
return 0
}
get_stack_site_menu_entry() {
local result_var="${1}"
local stack_dir="${2}"
local site_name=""
local site_status_label=""
local menu_entry=""
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if [ -z "${site_name}" ]; then
return 1
fi
get_stack_site_status_label site_status_label "${stack_dir}"
menu_entry="$(printf "%s | %s" "${site_name}" "${site_status_label}")"
printf -v "${result_var}" "%s" "${menu_entry}"
return 0
}
build_stack_site_apps_installed_json_array() {
local result_var="${1}"
local apps_installed_lines="${2:-}"
local app_name=""
local escaped_app_name=""
local entries_json=""
while IFS= read -r app_name; do
if [ -z "${app_name}" ]; then
continue
fi
escaped_app_name="$(json_escape_string "${app_name}")"
if [ -z "${entries_json}" ]; then
entries_json="$(printf ' "%s"' "${escaped_app_name}")"
else
entries_json="${entries_json}"$',\n'"$(printf ' "%s"' "${escaped_app_name}")"
fi
done <<EOF
${apps_installed_lines}
EOF
if [ -z "${entries_json}" ]; then
printf -v "${result_var}" '[\n ]'
else
printf -v "${result_var}" '[\n%s\n ]' "${entries_json}"
fi
}
build_stack_site_metadata_json_object() {
local result_var="${1}"
local site_mode="${2:-single-site}"
local site_name="${3:-}"
local site_state="${4:-not_created}"
local apps_installed_lines="${5:-}"
local last_action="${6:-}"
local last_error="${7:-}"
local error_log_path="${8:-}"
local created_at="${9:-}"
local updated_at="${10:-}"
local apps_installed_json_array=""
build_stack_site_apps_installed_json_array apps_installed_json_array "${apps_installed_lines}"
printf -v "${result_var}" '{\n "mode": "%s",\n "name": "%s",\n "state": "%s",\n "apps_installed": %s,\n "last_action": "%s",\n "last_error": "%s",\n "error_log_path": "%s",\n "created_at": "%s",\n "updated_at": "%s"\n }' \
"$(json_escape_string "${site_mode}")" \
"$(json_escape_string "${site_name}")" \
"$(json_escape_string "${site_state}")" \
"${apps_installed_json_array}" \
"$(json_escape_string "${last_action}")" \
"$(json_escape_string "${last_error}")" \
"$(json_escape_string "${error_log_path}")" \
"$(json_escape_string "${created_at}")" \
"$(json_escape_string "${updated_at}")"
}
persist_stack_site_metadata() {
local stack_dir="${1}"
local site_mode="${2:-single-site}"
local site_name="${3:-}"
local site_state="${4:-not_created}"
local apps_installed_lines="${5:-}"
local last_action="${6:-}"
local last_error="${7:-}"
local error_log_path="${8:-}"
local created_at="${9:-}"
local updated_at="${10:-}"
local metadata_path=""
local metadata_tmp_path=""
local site_json_object=""
metadata_path="${stack_dir}/metadata.json"
metadata_tmp_path="${metadata_path}.tmp"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
build_stack_site_metadata_json_object site_json_object "${site_mode}" "${site_name}" "${site_state}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}"
if ! awk -v site_object="${site_json_object}" '
BEGIN {
in_site = 0
inserted = 0
site_depth = 0
prev = ""
}
function flush_prev() {
if (prev != "") {
print prev
prev = ""
}
}
{
if (!in_site && $0 ~ /^ "site"[[:space:]]*:[[:space:]]*{/) {
in_site = 1
site_depth = 1
next
}
if (in_site) {
line = $0
open_count = gsub(/{/, "{", line)
close_count = gsub(/}/, "}", line)
site_depth += open_count - close_count
if (site_depth <= 0) {
in_site = 0
}
next
}
if (!inserted && $0 ~ /^}$/) {
if (prev != "") {
if (prev ~ /,$/) {
print prev
} else {
print prev ","
}
prev = ""
}
print " \"site\": " site_object
print "}"
inserted = 1
next
}
flush_prev()
prev = $0
}
END {
if (!inserted) {
if (prev != "") {
if (prev ~ /,$/) {
print prev
} else {
print prev ","
}
}
print " \"site\": " site_object
print "}"
} else if (prev != "") {
print prev
}
}
' "${metadata_path}" >"${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
}
mark_stack_site_failed() {
local stack_dir="${1}"
local site_name="${2:-}"
local apps_installed_lines="${3:-}"
local last_action="${4:-bootstrap-site}"
local last_error="${5:-Unknown site bootstrap failure}"
local error_log_path="${6:-}"
local created_at="${7:-}"
local updated_at=""
updated_at="$(get_current_utc_timestamp)"
persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "failed" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}"
}
clear_stack_site_metadata() {
local stack_dir="${1}"
local updated_at=""
updated_at="$(get_current_utc_timestamp)"
persist_stack_site_metadata "${stack_dir}" "single-site" "" "not_created" "" "delete-site" "" "" "" "${updated_at}"
}
load_easy_docker_site_metadata_modules

View file

@ -0,0 +1,184 @@
#!/usr/bin/env bash
get_metadata_site_string_field() {
local metadata_path="${1}"
local field_name="${2}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
awk -v field_name="${field_name}" '
/"site"[[:space:]]*:[[:space:]]*{/ {
in_site = 1
site_depth = 1
next
}
in_site {
if (match($0, "\"" field_name "\"[[:space:]]*:[[:space:]]*\"([^\"]*)\"", parts)) {
print parts[1]
exit
}
line = $0
open_count = gsub(/{/, "{", line)
close_count = gsub(/}/, "}", line)
site_depth += open_count - close_count
if (site_depth <= 0) {
exit
}
}
' "${metadata_path}"
}
get_metadata_site_apps_installed_lines() {
local metadata_path="${1}"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
awk '
/"site"[[:space:]]*:[[:space:]]*{/ {
in_site = 1
site_depth = 1
next
}
in_site && /"apps_installed"[[:space:]]*:[[:space:]]*\[/ {
in_apps_installed = 1
next
}
in_apps_installed && /\]/ {
in_apps_installed = 0
next
}
in_apps_installed {
if (match($0, /"([^"]+)"/, parts)) {
print parts[1]
}
}
in_site {
line = $0
open_count = gsub(/{/, "{", line)
close_count = gsub(/}/, "}", line)
site_depth += open_count - close_count
if (site_depth <= 0) {
exit
}
}
' "${metadata_path}"
}
get_stack_site_name() {
local stack_dir="${1}"
get_metadata_site_string_field "${stack_dir}/metadata.json" "name"
}
get_stack_site_state() {
local stack_dir="${1}"
get_metadata_site_string_field "${stack_dir}/metadata.json" "state"
}
get_stack_site_created_at() {
local stack_dir="${1}"
get_metadata_site_string_field "${stack_dir}/metadata.json" "created_at"
}
get_stack_site_apps_installed_lines() {
local stack_dir="${1}"
get_metadata_site_apps_installed_lines "${stack_dir}/metadata.json"
}
stack_has_site_record() {
local stack_dir="${1}"
local site_name=""
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if [ -n "${site_name}" ]; then
return 0
fi
return 1
}
stack_has_site_configured() {
local stack_dir="${1}"
local site_state=""
site_state="$(get_stack_site_state "${stack_dir}" || true)"
case "${site_state}" in
created | apps_installing | ready)
return 0
;;
*)
return 1
;;
esac
}
get_stack_site_status_label() {
local result_var="${1}"
local stack_dir="${2}"
local site_state=""
local site_name=""
local site_status_label=""
site_state="$(get_stack_site_state "${stack_dir}" || true)"
site_name="$(get_stack_site_name "${stack_dir}" || true)"
case "${site_state}" in
"")
site_status_label="Not configured"
;;
requested)
site_status_label="Requested"
;;
creating)
site_status_label="Creating"
;;
created)
site_status_label="Created"
;;
apps_installing)
site_status_label="Installing apps"
;;
ready)
site_status_label="Ready"
;;
failed)
site_status_label="Failed"
;;
*)
site_status_label="${site_state}"
;;
esac
if [ -n "${site_name}" ]; then
site_status_label="${site_status_label} (${site_name})"
fi
printf -v "${result_var}" "%s" "${site_status_label}"
return 0
}
get_stack_site_menu_entry() {
local result_var="${1}"
local stack_dir="${2}"
local site_name=""
local site_status_label=""
local menu_entry=""
site_name="$(get_stack_site_name "${stack_dir}" || true)"
if [ -z "${site_name}" ]; then
return 1
fi
get_stack_site_status_label site_status_label "${stack_dir}"
menu_entry="$(printf "%s | %s" "${site_name}" "${site_status_label}")"
printf -v "${result_var}" "%s" "${menu_entry}"
return 0
}

View file

@ -0,0 +1,179 @@
#!/usr/bin/env bash
build_stack_site_apps_installed_json_array() {
local result_var="${1}"
local apps_installed_lines="${2:-}"
local app_name=""
local escaped_app_name=""
local entries_json=""
while IFS= read -r app_name; do
if [ -z "${app_name}" ]; then
continue
fi
escaped_app_name="$(json_escape_string "${app_name}")"
if [ -z "${entries_json}" ]; then
entries_json="$(printf ' "%s"' "${escaped_app_name}")"
else
entries_json="${entries_json}"$',\n'"$(printf ' "%s"' "${escaped_app_name}")"
fi
done <<EOF
${apps_installed_lines}
EOF
if [ -z "${entries_json}" ]; then
printf -v "${result_var}" '[\n ]'
else
printf -v "${result_var}" '[\n%s\n ]' "${entries_json}"
fi
}
build_stack_site_metadata_json_object() {
local result_var="${1}"
local site_mode="${2:-single-site}"
local site_name="${3:-}"
local site_state="${4:-not_created}"
local apps_installed_lines="${5:-}"
local last_action="${6:-}"
local last_error="${7:-}"
local error_log_path="${8:-}"
local created_at="${9:-}"
local updated_at="${10:-}"
local apps_installed_json_array=""
build_stack_site_apps_installed_json_array apps_installed_json_array "${apps_installed_lines}"
printf -v "${result_var}" '{\n "mode": "%s",\n "name": "%s",\n "state": "%s",\n "apps_installed": %s,\n "last_action": "%s",\n "last_error": "%s",\n "error_log_path": "%s",\n "created_at": "%s",\n "updated_at": "%s"\n }' \
"$(json_escape_string "${site_mode}")" \
"$(json_escape_string "${site_name}")" \
"$(json_escape_string "${site_state}")" \
"${apps_installed_json_array}" \
"$(json_escape_string "${last_action}")" \
"$(json_escape_string "${last_error}")" \
"$(json_escape_string "${error_log_path}")" \
"$(json_escape_string "${created_at}")" \
"$(json_escape_string "${updated_at}")"
}
persist_stack_site_metadata() {
local stack_dir="${1}"
local site_mode="${2:-single-site}"
local site_name="${3:-}"
local site_state="${4:-not_created}"
local apps_installed_lines="${5:-}"
local last_action="${6:-}"
local last_error="${7:-}"
local error_log_path="${8:-}"
local created_at="${9:-}"
local updated_at="${10:-}"
local metadata_path=""
local metadata_tmp_path=""
local site_json_object=""
metadata_path="${stack_dir}/metadata.json"
metadata_tmp_path="${metadata_path}.tmp"
if [ ! -f "${metadata_path}" ]; then
return 1
fi
build_stack_site_metadata_json_object site_json_object "${site_mode}" "${site_name}" "${site_state}" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}"
if ! awk -v site_object="${site_json_object}" '
BEGIN {
in_site = 0
inserted = 0
site_depth = 0
prev = ""
}
function flush_prev() {
if (prev != "") {
print prev
prev = ""
}
}
{
if (!in_site && $0 ~ /^ "site"[[:space:]]*:[[:space:]]*{/) {
in_site = 1
site_depth = 1
next
}
if (in_site) {
line = $0
open_count = gsub(/{/, "{", line)
close_count = gsub(/}/, "}", line)
site_depth += open_count - close_count
if (site_depth <= 0) {
in_site = 0
}
next
}
if (!inserted && $0 ~ /^}$/) {
if (prev != "") {
if (prev ~ /,$/) {
print prev
} else {
print prev ","
}
prev = ""
}
print " \"site\": " site_object
print "}"
inserted = 1
next
}
flush_prev()
prev = $0
}
END {
if (!inserted) {
if (prev != "") {
if (prev ~ /,$/) {
print prev
} else {
print prev ","
}
}
print " \"site\": " site_object
print "}"
} else if (prev != "") {
print prev
}
}
' "${metadata_path}" >"${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
}
mark_stack_site_failed() {
local stack_dir="${1}"
local site_name="${2:-}"
local apps_installed_lines="${3:-}"
local last_action="${4:-bootstrap-site}"
local last_error="${5:-Unknown site bootstrap failure}"
local error_log_path="${6:-}"
local created_at="${7:-}"
local updated_at=""
updated_at="$(get_current_utc_timestamp)"
persist_stack_site_metadata "${stack_dir}" "single-site" "${site_name}" "failed" "${apps_installed_lines}" "${last_action}" "${last_error}" "${error_log_path}" "${created_at}" "${updated_at}"
}
clear_stack_site_metadata() {
local stack_dir="${1}"
local updated_at=""
updated_at="$(get_current_utc_timestamp)"
persist_stack_site_metadata "${stack_dir}" "single-site" "" "not_created" "" "delete-site" "" "" "" "${updated_at}"
}

View file

@ -1,662 +1,17 @@
#!/usr/bin/env bash
run_build_stack_custom_image_with_feedback() {
local stack_name="${1}"
local stack_dir="${2}"
local build_image_status=0
load_easy_docker_manage_flow_modules() {
local manage_dir=""
manage_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/manage"
show_warning_message "Starting docker build for stack: ${stack_name}"
if build_stack_custom_image "${stack_dir}"; then
show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3
return 0
fi
build_image_status=$?
case "${build_image_status}" in
11)
show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4
;;
12)
show_warning_and_wait "Custom image build failed: stack env file not found in ${stack_dir}." 4
;;
13)
show_warning_and_wait "Custom image build failed: CUSTOM_IMAGE is missing in stack env file." 4
;;
14)
show_warning_and_wait "Custom image build failed: CUSTOM_TAG is missing in stack env file." 4
;;
15)
show_warning_and_wait "Custom image build failed: frappe_branch missing in metadata.json." 4
;;
16)
show_warning_and_wait "Custom image build failed: could not generate apps.json from metadata app selection." 4
;;
17)
show_warning_and_wait "Custom image build failed: apps.json not found after generation." 4
;;
18)
show_warning_and_wait "Custom image build failed: base64 command is not available in this environment." 4
;;
19)
show_warning_and_wait "Custom image build failed: apps.json could not be base64-encoded." 4
;;
20)
show_warning_and_wait "Custom image build failed: images/layered/Containerfile not found." 4
;;
21)
show_warning_and_wait "Custom image build failed: docker build returned an error. Check the output above." 4
;;
22)
show_warning_and_wait "Custom image build failed: git is required for app branch precheck (git ls-remote)." 4
;;
23)
show_warning_and_wait "Custom image build failed: could not parse app entries from apps.json." 4
;;
24)
show_warning_and_wait "Custom image build failed: app branch precheck failed -> ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6
;;
*)
show_warning_and_wait "Custom image build failed (${build_image_status})." 4
;;
esac
return "${build_image_status}"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh
source "${manage_dir}/docker.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh
source "${manage_dir}/prompts.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/site.sh
source "${manage_dir}/site.sh"
# shellcheck source=scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh
source "${manage_dir}/stack.sh"
}
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
}
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_details_action=""
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 and selected stack apps were installed: ${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 MariaDB-backed single-host stacks." 4
;;
58)
show_warning_and_wait "The site state 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
;;
*)
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)"
if stack_backend_service_is_running "${stack_dir}" >/dev/null 2>&1; then
get_stack_site_runtime_selected_apps_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}" || true
)"
case "${existing_site_details_action}" in
"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
fi
site_flow_status=$?
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 state 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
}
handle_manage_selected_stack_flow() {
local stack_name="${1}"
local stack_dir=""
local stack_action=""
local apps_action=""
local docker_action=""
local stack_metadata_path=""
local stack_apps_path=""
local custom_apps_update_status=0
local persist_apps_status=0
local render_compose_status=0
local compose_start_status=0
local generated_compose_path=""
local stack_runtime_status=""
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_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}" || true)"
case "${apps_action}" in
"Regenerate apps.json from metadata")
stack_metadata_path="${stack_dir}/metadata.json"
stack_apps_path="${stack_dir}/apps.json"
if [ ! -f "${stack_metadata_path}" ]; then
show_warning_and_wait "Cannot generate apps.json because metadata is missing: ${stack_metadata_path}" 3
continue
fi
if persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then
:
else
persist_apps_status=$?
show_warning_and_wait "Could not generate ${stack_apps_path} (${persist_apps_status})." 3
continue
fi
show_warning_and_wait "apps.json generated successfully: ${stack_apps_path}" 3
;;
"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
;;
"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
;;
"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
fi
compose_start_status=$?
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}"
fi
compose_start_status=$?
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
;;
"Docker")
while true; do
docker_action="$(show_manage_stack_docker_menu "${stack_name}" "${stack_dir}" || true)"
case "${docker_action}" in
"Build custom image")
if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then
:
else
continue
fi
;;
"Generate docker compose from env")
generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")"
if render_stack_compose_from_metadata "${stack_dir}"; then
:
else
render_compose_status=$?
show_warning_and_wait "Could not generate docker compose (${render_compose_status}) for ${generated_compose_path}." 3
continue
fi
show_warning_and_wait "Docker compose generated successfully: ${generated_compose_path}" 3
;;
"Back" | "")
break
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown docker action: ${docker_action}"
;;
esac
done
;;
"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
}
load_easy_docker_manage_flow_modules

View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
run_build_stack_custom_image_with_feedback() {
local stack_name="${1}"
local stack_dir="${2}"
local build_image_status=0
show_warning_message "Starting docker build for stack: ${stack_name}"
if build_stack_custom_image "${stack_dir}"; then
show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3
return 0
fi
build_image_status=$?
case "${build_image_status}" in
11)
show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4
;;
12)
show_warning_and_wait "Custom image build failed: stack env file not found in ${stack_dir}." 4
;;
13)
show_warning_and_wait "Custom image build failed: CUSTOM_IMAGE is missing in stack env file." 4
;;
14)
show_warning_and_wait "Custom image build failed: CUSTOM_TAG is missing in stack env file." 4
;;
15)
show_warning_and_wait "Custom image build failed: frappe_branch missing in metadata.json." 4
;;
16)
show_warning_and_wait "Custom image build failed: could not generate apps.json from metadata app selection." 4
;;
17)
show_warning_and_wait "Custom image build failed: apps.json not found after generation." 4
;;
18)
show_warning_and_wait "Custom image build failed: base64 command is not available in this environment." 4
;;
19)
show_warning_and_wait "Custom image build failed: apps.json could not be base64-encoded." 4
;;
20)
show_warning_and_wait "Custom image build failed: images/layered/Containerfile not found." 4
;;
21)
show_warning_and_wait "Custom image build failed: docker build returned an error. Check the output above." 4
;;
22)
show_warning_and_wait "Custom image build failed: git is required for app branch precheck (git ls-remote)." 4
;;
23)
show_warning_and_wait "Custom image build failed: could not parse app entries from apps.json." 4
;;
24)
show_warning_and_wait "Custom image build failed: app branch precheck failed -> ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6
;;
*)
show_warning_and_wait "Custom image build failed (${build_image_status})." 4
;;
esac
return "${build_image_status}"
}

View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
run_build_stack_custom_image_with_feedback() {
local stack_name="${1}"
local stack_dir="${2}"
local build_image_status=0
show_warning_message "Starting docker build for stack: ${stack_name}"
if build_stack_custom_image "${stack_dir}"; then
show_warning_and_wait "Custom image build finished successfully for stack: ${stack_name}" 3
return 0
fi
build_image_status=$?
case "${build_image_status}" in
11)
show_warning_and_wait "Custom image build failed: missing metadata.json in ${stack_dir}." 4
;;
12)
show_warning_and_wait "Custom image build failed: stack env file not found in ${stack_dir}." 4
;;
13)
show_warning_and_wait "Custom image build failed: CUSTOM_IMAGE is missing in stack env file." 4
;;
14)
show_warning_and_wait "Custom image build failed: CUSTOM_TAG is missing in stack env file." 4
;;
15)
show_warning_and_wait "Custom image build failed: frappe_branch missing in metadata.json." 4
;;
16)
show_warning_and_wait "Custom image build failed: could not generate apps.json from metadata app selection." 4
;;
17)
show_warning_and_wait "Custom image build failed: apps.json not found after generation." 4
;;
18)
show_warning_and_wait "Custom image build failed: base64 command is not available in this environment." 4
;;
19)
show_warning_and_wait "Custom image build failed: apps.json could not be base64-encoded." 4
;;
20)
show_warning_and_wait "Custom image build failed: images/layered/Containerfile not found." 4
;;
21)
show_warning_and_wait "Custom image build failed: docker build returned an error. Check the output above." 4
;;
22)
show_warning_and_wait "Custom image build failed: git is required for app branch precheck (git ls-remote)." 4
;;
23)
show_warning_and_wait "Custom image build failed: could not parse app entries from apps.json." 4
;;
24)
show_warning_and_wait "Custom image build failed: app branch precheck failed -> ${EASY_DOCKER_BUILD_ERROR_DETAIL}" 6
;;
*)
show_warning_and_wait "Custom image build failed (${build_image_status})." 4
;;
esac
return "${build_image_status}"
}

View file

@ -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
}

View file

@ -0,0 +1,185 @@
#!/usr/bin/env bash
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_details_action=""
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 and selected stack apps were installed: ${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 MariaDB-backed single-host stacks." 4
;;
58)
show_warning_and_wait "The site state 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
;;
*)
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)"
if stack_backend_service_is_running "${stack_dir}" >/dev/null 2>&1; then
get_stack_site_runtime_selected_apps_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}" || true
)"
case "${existing_site_details_action}" in
"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
fi
site_flow_status=$?
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 state 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
}

View file

@ -0,0 +1,321 @@
#!/usr/bin/env bash
handle_manage_selected_stack_flow() {
local stack_name="${1}"
local stack_dir=""
local stack_action=""
local apps_action=""
local docker_action=""
local stack_metadata_path=""
local stack_apps_path=""
local custom_apps_update_status=0
local persist_apps_status=0
local render_compose_status=0
local compose_start_status=0
local generated_compose_path=""
local stack_runtime_status=""
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_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}" || true)"
case "${apps_action}" in
"Regenerate apps.json from metadata")
stack_metadata_path="${stack_dir}/metadata.json"
stack_apps_path="${stack_dir}/apps.json"
if [ ! -f "${stack_metadata_path}" ]; then
show_warning_and_wait "Cannot generate apps.json because metadata is missing: ${stack_metadata_path}" 3
continue
fi
if persist_stack_apps_json_from_metadata_apps "${stack_dir}"; then
:
else
persist_apps_status=$?
show_warning_and_wait "Could not generate ${stack_apps_path} (${persist_apps_status})." 3
continue
fi
show_warning_and_wait "apps.json generated successfully: ${stack_apps_path}" 3
;;
"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
;;
"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
;;
"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
fi
compose_start_status=$?
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}"
fi
compose_start_status=$?
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
;;
"Docker")
while true; do
docker_action="$(show_manage_stack_docker_menu "${stack_name}" "${stack_dir}" || true)"
case "${docker_action}" in
"Build custom image")
if run_build_stack_custom_image_with_feedback "${stack_name}" "${stack_dir}"; then
:
else
continue
fi
;;
"Generate docker compose from env")
generated_compose_path="$(get_stack_generated_compose_path "${stack_dir}")"
if render_stack_compose_from_metadata "${stack_dir}"; then
:
else
render_compose_status=$?
show_warning_and_wait "Could not generate docker compose (${render_compose_status}) for ${generated_compose_path}." 3
continue
fi
show_warning_and_wait "Docker compose generated successfully: ${generated_compose_path}" 3
;;
"Back" | "")
break
;;
"Exit and close easy-docker")
return "${FLOW_EXIT_APP}"
;;
*)
show_warning_and_wait "Unknown docker action: ${docker_action}"
;;
esac
done
;;
"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
}