From da905fb1c46bc1640d6be914bb2adde70a5706a2 Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:19:08 +0100 Subject: [PATCH] refactor(easy-docker): split wizard shell modules --- development/vscode-example/settings.json | 6 +- .../docs/development-team-process.md | 128 ++ .../docs/single-stack-readiness.md | 174 +++ scripts/easy-docker/docs/wizard-flow-clean.md | 76 ++ scripts/easy-docker/docs/wizard-flow.md | 136 +++ .../easy-docker/lib/app/wizard/common/apps.sh | 939 +-------------- .../lib/app/wizard/common/apps/catalog.sh | 490 ++++++++ .../lib/app/wizard/common/apps/metadata.sh | 378 ++++++ .../lib/app/wizard/common/apps/persistence.sh | 191 +++ .../common/compose/runtime/lifecycle.sh | 193 +++ .../wizard/common/compose/runtime/shared.sh | 100 ++ .../wizard/common/compose/runtime/status.sh | 233 ++++ .../lib/app/wizard/common/compose/start.sh | 573 +-------- .../app/wizard/common/compose/start/delete.sh | 110 ++ .../app/wizard/common/compose/start/start.sh | 131 +++ .../app/wizard/common/compose/start/status.sh | 239 ++++ .../app/wizard/common/compose/start/stop.sh | 89 ++ .../lib/app/wizard/common/site/bootstrap.sh | 1030 +---------------- .../wizard/common/site/bootstrap/errors.sh | 89 ++ .../wizard/common/site/bootstrap/lifecycle.sh | 409 +++++++ .../wizard/common/site/bootstrap/runtime.sh | 157 +++ .../app/wizard/common/site/bootstrap/state.sh | 253 ++++ .../common/site/bootstrap/validation.sh | 118 ++ .../lib/app/wizard/common/site/metadata.sh | 365 +----- .../app/wizard/common/site/metadata/read.sh | 184 +++ .../app/wizard/common/site/metadata/write.sh | 179 +++ .../lib/app/wizard/flows/manage.sh | 669 +---------- .../lib/app/wizard/flows/manage/build.sh | 64 + .../lib/app/wizard/flows/manage/docker.sh | 64 + .../lib/app/wizard/flows/manage/prompts.sh | 95 ++ .../lib/app/wizard/flows/manage/site.sh | 185 +++ .../lib/app/wizard/flows/manage/stack.sh | 321 +++++ 32 files changed, 4847 insertions(+), 3521 deletions(-) create mode 100644 scripts/easy-docker/docs/development-team-process.md create mode 100644 scripts/easy-docker/docs/single-stack-readiness.md create mode 100644 scripts/easy-docker/docs/wizard-flow-clean.md create mode 100644 scripts/easy-docker/docs/wizard-flow.md create mode 100755 scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/runtime/status.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/start/delete.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/start/status.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/compose/start/stop.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/bootstrap/validation.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh create mode 100755 scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage/build.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage/site.sh create mode 100755 scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh diff --git a/development/vscode-example/settings.json b/development/vscode-example/settings.json index 1490b727..a60982e0 100644 --- a/development/vscode-example/settings.json +++ b/development/vscode-example/settings.json @@ -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 } diff --git a/scripts/easy-docker/docs/development-team-process.md b/scripts/easy-docker/docs/development-team-process.md new file mode 100644 index 00000000..af32c4bd --- /dev/null +++ b/scripts/easy-docker/docs/development-team-process.md @@ -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: +- - - + +Verification: +- pre-commit: +- manual matrix: +``` diff --git a/scripts/easy-docker/docs/single-stack-readiness.md b/scripts/easy-docker/docs/single-stack-readiness.md new file mode 100644 index 00000000..6b5c1fce --- /dev/null +++ b/scripts/easy-docker/docs/single-stack-readiness.md @@ -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 ` +- 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` diff --git a/scripts/easy-docker/docs/wizard-flow-clean.md b/scripts/easy-docker/docs/wizard-flow-clean.md new file mode 100644 index 00000000..1060fd1c --- /dev/null +++ b/scripts/easy-docker/docs/wizard-flow-clean.md @@ -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. diff --git a/scripts/easy-docker/docs/wizard-flow.md b/scripts/easy-docker/docs/wizard-flow.md new file mode 100644 index 00000000..12f3bd52 --- /dev/null +++ b/scripts/easy-docker/docs/wizard-flow.md @@ -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. diff --git a/scripts/easy-docker/lib/app/wizard/common/apps.sh b/scripts/easy-docker/lib/app/wizard/common/apps.sh index faab91cf..cca37f19 100755 --- a/scripts/easy-docker/lib/app/wizard/common/apps.sh +++ b/scripts/easy-docker/lib/app/wizard/common/apps.sh @@ -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 <"${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 diff --git a/scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh b/scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh new file mode 100755 index 00000000..075bd44e --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/apps/catalog.sh @@ -0,0 +1,490 @@ +#!/usr/bin/env bash + +trim_predefined_catalog_field() { + local result_var="${1}" + local value="${2}" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + printf -v "${result_var}" "%s" "${value}" +} + +is_valid_predefined_app_id() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!a-z0-9._-]*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +generate_predefined_app_id_from_label() { + local result_var="${1}" + local app_label="${2}" + local generated_id="" + + generated_id="$( + printf '%s' "${app_label}" | + tr '[:upper:]' '[:lower:]' | + sed -E 's/[[:space:]]+/_/g; s/[^a-z0-9._-]+/_/g; s/_+/_/g; s/^_+//; s/_+$//' + )" + + if ! is_valid_predefined_app_id "${generated_id}"; then + return 1 + fi + + printf -v "${result_var}" "%s" "${generated_id}" + return 0 +} + +is_valid_predefined_app_repo() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + https://* | http://* | ssh://* | git://* | git@*:* | file://*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +is_valid_predefined_app_branch() { + local value="${1}" + + if [ -z "${value}" ]; then + return 1 + fi + + case "${value}" in + *[!A-Za-z0-9._/-]* | .* | *..* | */ | /*) + return 1 + ;; + *) + return 0 + ;; + esac +} + +csv_contains_branch() { + local csv_values="${1}" + local value="${2}" + + case ",${csv_values}," in + *,"${value}",*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +normalize_predefined_branches_csv() { + local result_csv_var="${1}" + local branches_csv_raw="${2}" + local branch_token="" + local normalized_csv="" + local -a raw_tokens=() + + IFS=',' read -r -a raw_tokens <<<"${branches_csv_raw}" + for branch_token in "${raw_tokens[@]}"; do + trim_predefined_catalog_field branch_token "${branch_token}" + if [ -z "${branch_token}" ]; then + continue + fi + + if ! is_valid_predefined_app_branch "${branch_token}"; then + return 1 + fi + + if csv_contains_branch "${normalized_csv}" "${branch_token}"; then + continue + fi + + if [ -z "${normalized_csv}" ]; then + normalized_csv="${branch_token}" + else + normalized_csv="${normalized_csv},${branch_token}" + fi + done + + if [ -z "${normalized_csv}" ]; then + return 1 + fi + + printf -v "${result_csv_var}" "%s" "${normalized_csv}" + return 0 +} + +get_predefined_apps_catalog_path() { + local repo_root="" + + repo_root="$(get_easy_docker_repo_root)" + printf '%s/scripts/easy-docker/config/apps.tsv\n' "${repo_root}" +} + +get_predefined_apps_catalog_entries() { + local catalog_path="" + local raw_line="" + local line="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + local normalized_branches_csv="" + local first_branch="" + local extra="" + local seen_ids="," + local seen_labels="," + + catalog_path="$(get_predefined_apps_catalog_path)" + if [ ! -f "${catalog_path}" ]; then + return 1 + fi + + while IFS= read -r raw_line || [ -n "${raw_line}" ]; do + trim_predefined_catalog_field line "${raw_line}" + if [ -z "${line}" ]; then + continue + fi + + case "${line}" in + \#*) + continue + ;; + esac + + if [[ "${line}" == *$'\t'* ]]; then + IFS=$'\t' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" + else + # Backward compatibility for older catalog rows. + IFS='|' read -r app_id app_label app_repo app_default_branch app_branches_csv extra <<<"${line}" + fi + trim_predefined_catalog_field app_id "${app_id}" + trim_predefined_catalog_field app_label "${app_label}" + trim_predefined_catalog_field app_repo "${app_repo}" + trim_predefined_catalog_field app_default_branch "${app_default_branch}" + trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" + trim_predefined_catalog_field extra "${extra}" + + if [ -n "${extra}" ] || [ -z "${app_id}" ] || [ -z "${app_label}" ] || [ -z "${app_repo}" ] || [ -z "${app_branches_csv}" ]; then + return 1 + fi + + if ! is_valid_predefined_app_id "${app_id}"; then + return 1 + fi + + if ! is_valid_predefined_app_repo "${app_repo}"; then + return 1 + fi + + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + return 1 + fi + + if [ -z "${app_default_branch}" ]; then + first_branch="${normalized_branches_csv%%,*}" + app_default_branch="${first_branch}" + fi + + if ! is_valid_predefined_app_branch "${app_default_branch}"; then + return 1 + fi + + if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then + return 1 + fi + + case "${seen_ids}" in + *,"${app_id}",*) + return 1 + ;; + esac + case "${seen_labels}" in + *,"${app_label}",*) + return 1 + ;; + esac + + seen_ids="${seen_ids}${app_id}," + seen_labels="${seen_labels}${app_label}," + + printf '%s|%s|%s|%s|%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" + done <"${catalog_path}" +} + +parse_predefined_app_catalog_entry() { + local entry="${1}" + local app_id_var="${2}" + local app_label_var="${3}" + local app_repo_var="${4}" + local app_default_branch_var="${5}" + local app_branches_csv_var="${6}" + local parsed_app_id="" + local parsed_app_label="" + local parsed_app_repo="" + local parsed_app_default_branch="" + local parsed_app_branches_csv="" + + IFS='|' read -r parsed_app_id parsed_app_label parsed_app_repo parsed_app_default_branch parsed_app_branches_csv <<<"${entry}" + printf -v "${app_id_var}" "%s" "${parsed_app_id}" + printf -v "${app_label_var}" "%s" "${parsed_app_label}" + printf -v "${app_repo_var}" "%s" "${parsed_app_repo}" + printf -v "${app_default_branch_var}" "%s" "${parsed_app_default_branch}" + printf -v "${app_branches_csv_var}" "%s" "${parsed_app_branches_csv}" +} + +get_predefined_app_field_by_field() { + local lookup_field="${1}" + local lookup_value="${2}" + local result_field="${3}" + local entry="" + local app_id="" + local app_label="" + local app_repo="" + local app_default_branch="" + local app_branches_csv="" + local lookup_candidate="" + local result_value="" + + trim_predefined_catalog_field lookup_value "${lookup_value}" + if [ -z "${lookup_value}" ]; then + return 1 + fi + + while IFS= read -r entry; do + if [ -z "${entry}" ]; then + continue + fi + + parse_predefined_app_catalog_entry "${entry}" app_id app_label app_repo app_default_branch app_branches_csv + + case "${lookup_field}" in + id) + lookup_candidate="${app_id}" + ;; + label) + lookup_candidate="${app_label}" + ;; + *) + return 1 + ;; + esac + + trim_predefined_catalog_field lookup_candidate "${lookup_candidate}" + if [ "${lookup_candidate}" != "${lookup_value}" ]; then + continue + fi + + case "${result_field}" in + id) + result_value="${app_id}" + ;; + label) + result_value="${app_label}" + ;; + repo) + result_value="${app_repo}" + ;; + default_branch) + result_value="${app_default_branch}" + ;; + branches_csv) + result_value="${app_branches_csv}" + ;; + *) + return 1 + ;; + esac + + printf '%s\n' "${result_value}" + return 0 + done < <(get_predefined_apps_catalog_entries) + + return 1 +} + +get_predefined_app_id_by_label() { + local label="${1}" + get_predefined_app_field_by_field "label" "${label}" "id" +} + +get_predefined_app_repo_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "repo" +} + +get_predefined_app_label_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "label" +} + +get_predefined_app_default_branch_by_id() { + local app_id_lookup="${1}" + get_predefined_app_field_by_field "id" "${app_id_lookup}" "default_branch" +} + +get_predefined_app_branch_lines_by_id() { + local result_var="${1}" + local app_id_lookup="${2}" + local app_branches_csv="" + local branch="" + local branch_lines="" + local -a branches=() + + app_branches_csv="$(get_predefined_app_field_by_field "id" "${app_id_lookup}" "branches_csv" || true)" + if [ -z "${app_branches_csv}" ]; then + return 1 + fi + + IFS=',' read -r -a branches <<<"${app_branches_csv}" + for branch in "${branches[@]}"; do + trim_predefined_catalog_field branch "${branch}" + if [ -z "${branch}" ]; then + continue + fi + if [ -z "${branch_lines}" ]; then + branch_lines="${branch}" + else + branch_lines="${branch_lines}"$'\n'"${branch}" + fi + done + + if [ -z "${branch_lines}" ]; then + return 1 + fi + + printf -v "${result_var}" "%s" "${branch_lines}" + return 0 +} + +predefined_app_catalog_has_id() { + local app_id_lookup="${1}" + + if [ -z "${app_id_lookup}" ]; then + return 1 + fi + + get_predefined_app_field_by_field "id" "${app_id_lookup}" "id" >/dev/null 2>&1 +} + +predefined_app_catalog_has_label() { + local app_label_lookup="${1}" + + if [ -z "${app_label_lookup}" ]; then + return 1 + fi + + get_predefined_app_field_by_field "label" "${app_label_lookup}" "label" >/dev/null 2>&1 +} + +append_predefined_app_catalog_entry() { + local app_id="${1}" + local app_label="${2}" + local app_repo="${3}" + local app_default_branch="${4}" + local app_branches_csv="${5}" + local normalized_branches_csv="" + local first_branch="" + local catalog_path="" + local catalog_tmp_path="" + local last_char="" + + if ! get_predefined_apps_catalog_entries >/dev/null 2>&1; then + return 1 + fi + + trim_predefined_catalog_field app_id "${app_id}" + trim_predefined_catalog_field app_label "${app_label}" + trim_predefined_catalog_field app_repo "${app_repo}" + trim_predefined_catalog_field app_default_branch "${app_default_branch}" + trim_predefined_catalog_field app_branches_csv "${app_branches_csv}" + + if ! is_valid_predefined_app_id "${app_id}"; then + return 1 + fi + if [ -z "${app_label}" ]; then + return 1 + fi + if ! is_valid_predefined_app_repo "${app_repo}"; then + return 1 + fi + if ! normalize_predefined_branches_csv normalized_branches_csv "${app_branches_csv}"; then + return 1 + fi + + if [ -z "${app_default_branch}" ]; then + first_branch="${normalized_branches_csv%%,*}" + app_default_branch="${first_branch}" + fi + if ! is_valid_predefined_app_branch "${app_default_branch}"; then + return 1 + fi + if ! csv_contains_branch "${normalized_branches_csv}" "${app_default_branch}"; then + return 1 + fi + + if predefined_app_catalog_has_id "${app_id}"; then + return 1 + fi + if predefined_app_catalog_has_label "${app_label}"; then + return 1 + fi + + catalog_path="$(get_predefined_apps_catalog_path)" + catalog_tmp_path="${catalog_path}.tmp" + if [ ! -f "${catalog_path}" ]; then + return 1 + fi + + if ! cp -- "${catalog_path}" "${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if [ -s "${catalog_tmp_path}" ]; then + if command_exists tail; then + last_char="$(tail -c 1 "${catalog_tmp_path}" 2>/dev/null || true)" + if [ -n "${last_char}" ]; then + if ! printf '\n' >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + fi + else + if ! printf '\n' >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + fi + fi + + if ! printf '%s\t%s\t%s\t%s\t%s\n' "${app_id}" "${app_label}" "${app_repo}" "${app_default_branch}" "${normalized_branches_csv}" >>"${catalog_tmp_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${catalog_tmp_path}" "${catalog_path}"; then + rm -f -- "${catalog_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh b/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh new file mode 100755 index 00000000..9dbbb498 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/apps/metadata.sh @@ -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 <"${metadata_tmp_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + if ! mv -- "${metadata_tmp_path}" "${metadata_path}"; then + rm -f -- "${metadata_tmp_path}" >/dev/null 2>&1 || true + return 1 + fi + + return 0 +} + +persist_stack_metadata_apps_object() { + local stack_dir="${1}" + local apps_json_object="${2}" + + persist_stack_metadata_top_level_object "${stack_dir}" "apps" "${apps_json_object}" "wizard" +} + +persist_stack_metadata_wizard_object() { + local stack_dir="${1}" + local wizard_json_object="${2}" + + persist_stack_metadata_top_level_object "${stack_dir}" "wizard" "${wizard_json_object}" +} diff --git a/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh b/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh new file mode 100755 index 00000000..e581f3cc --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/apps/persistence.sh @@ -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 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh new file mode 100755 index 00000000..5b246c90 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/lifecycle.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash + +start_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local compose_project_name="" + local fallback_erpnext_version="" + local configured_pull_policy="" + local runtime_pull_policy="" + local custom_image="" + local custom_tag="" + local image_ref="" + local image_inspect_error="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + easy_docker_compose_init_context "${stack_dir}" metadata_path env_path compose_project_name + + if [ ! -f "${metadata_path}" ]; then + return 31 + fi + + if [ ! -f "${env_path}" ]; then + return 32 + fi + + if ! easy_docker_compose_require_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 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh new file mode 100755 index 00000000..1d62093a --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/runtime/shared.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +easy_docker_compose_init_context() { + local stack_dir="${1}" + local metadata_var="${2}" + local env_var="${3}" + local project_var="${4}" + local metadata_path="" + local env_path="" + local compose_project_name="" + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + compose_project_name="$(get_stack_compose_project_name "${stack_dir}")" + + printf -v "${metadata_var}" "%s" "${metadata_path}" + printf -v "${env_var}" "%s" "${env_path}" + printf -v "${project_var}" "%s" "${compose_project_name}" +} + +easy_docker_compose_get_fallback_erpnext_version() { + local result_var="${1}" + local env_path="${2}" + local env_erpnext_version="" + local fallback_erpnext_version="" + + env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" + if [ -z "${env_erpnext_version}" ]; then + fallback_erpnext_version="$(get_default_erpnext_version || true)" + fi + + printf -v "${result_var}" "%s" "${fallback_erpnext_version}" +} + +easy_docker_compose_require_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 </dev/null + )" + compose_status=$? + else + container_ids_lines="$( + docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null + )" + compose_status=$? + fi + + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker compose status failed)" + return 0 + fi + + if [ -z "${container_ids_lines}" ]; then + printf -v "${result_var}" "%s" "Not created" + return 0 + fi + + docker_ps_args=(-a --no-trunc --format "{{.ID}}|{{.State}}|{{.Status}}") + while IFS= read -r container_id; do + if [ -n "${container_id}" ]; then + docker_ps_args+=(--filter "id=${container_id}") + fi + done </dev/null)" + compose_status=$? + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker ps status failed)" + return 0 + fi + + while IFS= read -r container_status_line; do + if [ -z "${container_status_line}" ]; then + continue + fi + + total_containers_count=$((total_containers_count + 1)) + IFS='|' read -r container_id container_state container_status_text <&1 >/dev/null)"; then - runtime_pull_policy="if_not_present" - else - case "${image_inspect_error}" in - *"No such image"* | *"No such object"*) - # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 38. - EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_ref}" - return 38 - ;; - *) - if [ -z "${image_inspect_error}" ]; then - image_inspect_error="docker image inspect failed for ${image_ref}" - fi - # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 39. - EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_inspect_error}" - return 39 - ;; - esac - fi - fi - fi - - compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" - if [ -z "${compose_files_lines}" ]; then - return 35 - fi - - repo_root="$(get_easy_docker_repo_root)" - while IFS= read -r compose_file; do - if [ -z "${compose_file}" ]; then - continue - fi - - source_compose_path="${repo_root}/${compose_file}" - if [ ! -f "${source_compose_path}" ]; then - # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 36. - EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" - return 36 - fi - - compose_args+=(-f "${source_compose_path}") - done </dev/null 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 </dev/null - )" - compose_status=$? - else - container_ids_lines="$( - docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null - )" - compose_status=$? - fi - - if [ "${compose_status}" -ne 0 ]; then - printf -v "${result_var}" "%s" "Unknown (docker compose status failed)" - return 0 - fi - - if [ -z "${container_ids_lines}" ]; then - printf -v "${result_var}" "%s" "Not created" - return 0 - fi - - docker_ps_args=(-a --no-trunc --format "{{.ID}}|{{.State}}|{{.Status}}") - while IFS= read -r container_id; do - if [ -n "${container_id}" ]; then - docker_ps_args+=(--filter "id=${container_id}") - fi - done </dev/null)" - compose_status=$? - if [ "${compose_status}" -ne 0 ]; then - printf -v "${result_var}" "%s" "Unknown (docker ps status failed)" - return 0 - fi - - while IFS= read -r container_status_line; do - if [ -z "${container_status_line}" ]; then - continue - fi - - total_containers_count=$((total_containers_count + 1)) - IFS='|' read -r container_id container_state container_status_text </dev/null 2>&1; then + if ! docker image rm "${custom_image_ref}"; then + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${custom_image_ref}" + return 55 + fi + fi + fi + + if ! rollback_stack_directory "${stack_dir}"; then + # shellcheck disable=SC2034 # Read by manage flow after delete_stack_with_compose_from_metadata returns 56. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_dir}" + return 56 + fi + + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh b/scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh new file mode 100755 index 00000000..a04e4b55 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/compose/start/start.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +start_stack_with_compose_from_metadata() { + local stack_dir="${1}" + local metadata_path="" + local env_path="" + local compose_files_lines="" + local compose_file="" + local source_compose_path="" + local env_erpnext_version="" + local fallback_erpnext_version="" + local configured_pull_policy="" + local runtime_pull_policy="" + local custom_image="" + local custom_tag="" + local image_ref="" + local image_inspect_error="" + local compose_project_name="" + local stack_topology="" + local repo_root="" + local -a compose_args=() + + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata fails. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="" + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + compose_project_name="$(get_stack_compose_project_name "${stack_dir}")" + + if [ ! -f "${metadata_path}" ]; then + return 31 + fi + + if [ ! -f "${env_path}" ]; then + return 32 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 33. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="metadata.json missing topology" + return 33 + fi + + case "${stack_topology}" in + "single-host") ;; + *) + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 34. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${stack_topology}" + return 34 + ;; + esac + + env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" + if [ -z "${env_erpnext_version}" ]; then + fallback_erpnext_version="$(get_default_erpnext_version || true)" + fi + + configured_pull_policy="$(get_env_file_key_value "${env_path}" "PULL_POLICY" || true)" + if [ -z "${configured_pull_policy}" ]; then + custom_image="$(get_env_file_key_value "${env_path}" "CUSTOM_IMAGE" || true)" + custom_tag="$(get_env_file_key_value "${env_path}" "CUSTOM_TAG" || true)" + if [ -n "${custom_image}" ] && [ -n "${custom_tag}" ]; then + image_ref="${custom_image}:${custom_tag}" + if image_inspect_error="$(docker image inspect "${image_ref}" 2>&1 >/dev/null)"; then + runtime_pull_policy="if_not_present" + else + case "${image_inspect_error}" in + *"No such image"* | *"No such object"*) + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 38. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_ref}" + return 38 + ;; + *) + if [ -z "${image_inspect_error}" ]; then + image_inspect_error="docker image inspect failed for ${image_ref}" + fi + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 39. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${image_inspect_error}" + return 39 + ;; + esac + fi + fi + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 35 + fi + + repo_root="$(get_easy_docker_repo_root)" + while IFS= read -r compose_file; do + if [ -z "${compose_file}" ]; then + continue + fi + + source_compose_path="${repo_root}/${compose_file}" + if [ ! -f "${source_compose_path}" ]; then + # shellcheck disable=SC2034 # Read by manage flow after start_stack_with_compose_from_metadata returns 36. + EASY_DOCKER_COMPOSE_ERROR_DETAIL="${source_compose_path}" + return 36 + fi + + compose_args+=(-f "${source_compose_path}") + done </dev/null + )" + compose_status=$? + else + container_ids_lines="$( + docker compose --project-name "${compose_project_name}" --env-file "${env_path}" "${compose_args[@]}" ps -a -q 2>/dev/null + )" + compose_status=$? + fi + + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker compose status failed)" + return 0 + fi + + if [ -z "${container_ids_lines}" ]; then + printf -v "${result_var}" "%s" "Not created" + return 0 + fi + + docker_ps_args=(-a --no-trunc --format "{{.ID}}|{{.State}}|{{.Status}}") + while IFS= read -r container_id; do + if [ -n "${container_id}" ]; then + docker_ps_args+=(--filter "id=${container_id}") + fi + done </dev/null)" + compose_status=$? + if [ "${compose_status}" -ne 0 ]; then + printf -v "${result_var}" "%s" "Unknown (docker ps status failed)" + return 0 + fi + + while IFS= read -r container_status_line; do + if [ -z "${container_status_line}" ]; then + continue + fi + + total_containers_count=$((total_containers_count + 1)) + IFS='|' read -r container_id container_state container_status_text <"${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 - - EASY_DOCKER_SITE_ERROR_LOG_PATH="${log_path}" - return 0 -} - -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 -} - -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_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_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_runtime_available_app_lines() { - local stack_dir="${1}" - - run_stack_backend_bash_command "${stack_dir}" "ls -1 apps" -} - -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 </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 -} +load_easy_docker_site_bootstrap_modules diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh new file mode 100755 index 00000000..2b08c2b8 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/errors.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +reset_easy_docker_site_error_state() { + EASY_DOCKER_SITE_ERROR_DETAIL="" + EASY_DOCKER_SITE_ERROR_LOG_PATH="" +} + +build_stack_site_error_log_relative_path() { + local result_var="${1}" + local action_name="${2:-site-error}" + local raw_timestamp="" + local safe_timestamp="" + local relative_path="" + + raw_timestamp="$(get_current_utc_timestamp)" + safe_timestamp="$(printf '%s' "${raw_timestamp}" | tr ':' '-')" + relative_path="$(printf 'logs/%s-%s.log' "${action_name}" "${safe_timestamp}")" + printf -v "${result_var}" "%s" "${relative_path}" +} + +write_stack_site_error_log() { + local result_var="${1}" + local stack_dir="${2}" + local action_name="${3:-site-error}" + local error_output="${4:-}" + local relative_path="" + local log_dir="" + local absolute_path="" + + if [ -z "${error_output}" ]; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + build_stack_site_error_log_relative_path relative_path "${action_name}" + log_dir="${stack_dir}/logs" + absolute_path="${stack_dir}/${relative_path}" + + if ! mkdir -p "${log_dir}"; then + return 1 + fi + + if ! printf '%s\n' "${error_output}" >"${absolute_path}"; then + return 1 + fi + + printf -v "${result_var}" "%s" "${relative_path}" + return 0 +} + +run_stack_backend_bash_command_capture() { + local result_var="${1}" + local stack_dir="${2}" + local backend_command="${3}" + local command_output="" + local command_status=0 + + reset_easy_docker_site_error_state + command_output="$(run_stack_backend_bash_command "${stack_dir}" "${backend_command}" 2>&1)" + command_status=$? + + if [ -n "${command_output}" ]; then + printf '%s\n' "${command_output}" + fi + + printf -v "${result_var}" "%s" "${command_output}" + return "${command_status}" +} + +capture_stack_site_error_log() { + local stack_dir="${1}" + local action_name="${2:-site-error}" + local error_output="${3:-}" + local log_path="" + + EASY_DOCKER_SITE_ERROR_LOG_PATH="" + if [ -z "${error_output}" ]; then + return 0 + fi + + if ! write_stack_site_error_log log_path "${stack_dir}" "${action_name}" "${error_output}"; then + EASY_DOCKER_SITE_ERROR_DETAIL="${EASY_DOCKER_SITE_ERROR_DETAIL:-Failed to write site error log.}" + return 1 + fi + + # shellcheck disable=SC2034 # Read by manage flow after site bootstrap failures. + EASY_DOCKER_SITE_ERROR_LOG_PATH="${log_path}" + return 0 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh new file mode 100755 index 00000000..6efb2249 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/lifecycle.sh @@ -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 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh new file mode 100755 index 00000000..59315fd6 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/runtime.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash + +run_stack_backend_bash_command() { + local stack_dir="${1}" + local backend_command="${2}" + local wrapped_backend_command="" + local metadata_path="" + local env_path="" + local compose_files_lines="" + local compose_file="" + local source_compose_path="" + local env_erpnext_version="" + local fallback_erpnext_version="" + local compose_project_name="" + local stack_topology="" + local repo_root="" + local -a compose_args=() + + metadata_path="${stack_dir}/metadata.json" + env_path="$(get_stack_env_path "${stack_dir}")" + compose_project_name="$(get_stack_compose_project_name "${stack_dir}")" + + if [ ! -f "${metadata_path}" ]; then + return 54 + fi + + if [ ! -f "${env_path}" ]; then + return 54 + fi + + stack_topology="$(get_stack_topology "${stack_dir}" || true)" + if [ -z "${stack_topology}" ]; then + return 54 + fi + + case "${stack_topology}" in + single-host) ;; + *) + return 52 + ;; + esac + + env_erpnext_version="$(get_env_file_key_value "${env_path}" "ERPNEXT_VERSION" || true)" + if [ -z "${env_erpnext_version}" ]; then + fallback_erpnext_version="$(get_default_erpnext_version || true)" + fi + + compose_files_lines="$(get_metadata_compose_files_lines "${metadata_path}" || true)" + if [ -z "${compose_files_lines}" ]; then + return 54 + fi + + repo_root="$(get_easy_docker_repo_root)" + while IFS= read -r compose_file; do + if [ -z "${compose_file}" ]; then + continue + fi + + source_compose_path="${repo_root}/${compose_file}" + if [ ! -f "${source_compose_path}" ]; then + return 54 + fi + + compose_args+=(-f "${source_compose_path}") + done </dev/null 2>&1; then + return 0 + fi + + backend_ready_status=$? + if [ "${backend_ready_status}" -eq 54 ] || [ "${backend_ready_status}" -eq 52 ]; then + return "${backend_ready_status}" + fi + + # If exec fails, the backend service is not ready for site actions yet. + return 1 +} + +stack_database_service_is_reachable() { + local stack_dir="${1}" + local reachability_command="" + local db_ready_status=0 + + IFS= read -r -d '' reachability_command <<'EOF' || true +python - <<'PY' +import json +import socket +from pathlib import Path + +config_path = Path("/home/frappe/frappe-bench/sites/common_site_config.json") +with config_path.open(encoding="utf-8") as handle: + config = json.load(handle) + +db_host = config.get("db_host") +db_port = int(config.get("db_port", 3306)) +socket.create_connection((db_host, db_port), 5).close() +PY +EOF + + if run_stack_backend_bash_command "${stack_dir}" "${reachability_command}" >/dev/null 2>&1; then + return 0 + fi + + db_ready_status=$? + if [ "${db_ready_status}" -eq 54 ] || [ "${db_ready_status}" -eq 52 ]; then + return "${db_ready_status}" + fi + + return 1 +} + +get_stack_common_db_endpoint() { + local stack_dir="${1}" + local read_command="" + + read_command="$( + cat <<'EOF' +python - <<'PY' +import json +from pathlib import Path +path = Path("sites/common_site_config.json") +with path.open(encoding="utf-8") as handle: + config = json.load(handle) +print(f"{config.get('db_host', '')}|{config.get('db_port', 3306)}") +PY +EOF + )" + + run_stack_backend_bash_command "${stack_dir}" "${read_command}" +} + +get_stack_runtime_available_app_lines() { + local stack_dir="${1}" + + run_stack_backend_bash_command "${stack_dir}" "ls -1 apps" +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh new file mode 100755 index 00000000..79354c07 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/bootstrap/state.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash + +stack_site_exists_in_bench() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "bench list-sites | grep -F -x -- %s >/dev/null" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +stack_site_directory_exists() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "test -d sites/%s" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +stack_site_config_exists() { + local stack_dir="${1}" + local site_name="${2}" + local exists_command="" + local exists_status=0 + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + exists_command="$( + printf "test -f sites/%s/site_config.json" "$(shell_quote_site_command_arg "${site_name}")" + )" + if run_stack_backend_bash_command "${stack_dir}" "${exists_command}" >/dev/null 2>&1; then + return 0 + fi + + exists_status=$? + if [ "${exists_status}" -eq 54 ] || [ "${exists_status}" -eq 52 ]; then + return "${exists_status}" + fi + + return 1 +} + +get_stack_site_database_name() { + local stack_dir="${1}" + local site_name="${2}" + local read_command="" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + read_command="$( + printf "python - <<'PY'\nimport json\nfrom pathlib import Path\npath = Path('sites') / %s / 'site_config.json'\nwith path.open(encoding='utf-8') as handle:\n print(json.load(handle).get('db_name', ''))\nPY" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + run_stack_backend_bash_command "${stack_dir}" "${read_command}" +} + +get_stack_site_runtime_app_names_lines() { + local stack_dir="${1}" + local site_name="${2}" + local list_apps_command="" + + if ! is_safe_stack_site_cleanup_name "${site_name}"; then + return 61 + fi + + list_apps_command="$( + printf "bench --site %s list-apps | awk 'NF { print \$1 }'" \ + "$(shell_quote_site_command_arg "${site_name}")" + )" + + run_stack_backend_bash_command "${stack_dir}" "${list_apps_command}" +} + +get_stack_site_runtime_selected_apps_lines() { + local result_var="${1}" + local stack_dir="${2}" + local site_name="${3}" + local selected_app_lines="" + local runtime_app_lines="" + local selected_app_name="" + local installed_app_lines="" + + if ! get_stack_selected_installable_apps selected_app_lines "${stack_dir}"; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + if [ -z "${selected_app_lines}" ]; then + printf -v "${result_var}" "%s" "" + return 0 + fi + + runtime_app_lines="$(get_stack_site_runtime_app_names_lines "${stack_dir}" "${site_name}" || true)" + if [ -z "${runtime_app_lines}" ]; then + printf -v "${result_var}" "%s" "" + return 1 + fi + + while IFS= read -r selected_app_name; do + if [ -z "${selected_app_name}" ]; then + continue + fi + + if ! printf '%s\n' "${runtime_app_lines}" | grep -F -x -- "${selected_app_name}" >/dev/null 2>&1; then + continue + fi + + if [ -z "${installed_app_lines}" ]; then + installed_app_lines="${selected_app_name}" + else + installed_app_lines="${installed_app_lines}"$'\n'"${selected_app_name}" + fi + done <"${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 diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh new file mode 100755 index 00000000..ace5e3dd --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/read.sh @@ -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 +} diff --git a/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh new file mode 100755 index 00000000..1d35697e --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/common/site/metadata/write.sh @@ -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 <"${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}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage.sh b/scripts/easy-docker/lib/app/wizard/flows/manage.sh index 639f5e06..1e9a98ec 100755 --- a/scripts/easy-docker/lib/app/wizard/flows/manage.sh +++ b/scripts/easy-docker/lib/app/wizard/flows/manage.sh @@ -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 diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh new file mode 100755 index 00000000..9d18a0fb --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/build.sh @@ -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}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh new file mode 100755 index 00000000..9d18a0fb --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/docker.sh @@ -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}" +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh new file mode 100755 index 00000000..c39c9922 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/prompts.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +prompt_manage_stack_site_name_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local stack_dir="${3}" + local input_site_name="" + local suggestion="" + local prompt_status=0 + + suggestion="$(get_stack_primary_site_name_suggestion "${stack_dir}" || true)" + while true; do + input_site_name="$(prompt_stack_site_name "${stack_name}" "${suggestion}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + input_site_name="$(printf '%s' "${input_site_name}" | tr -d '\r\n')" + case "${input_site_name}" in + "") + show_warning_and_wait "Site name is required." 2 + ;; + /back | /Back | /BACK) + return "${FLOW_ABORT_INPUT}" + ;; + *) + if ! is_valid_stack_site_name "${input_site_name}"; then + show_warning_and_wait "Site name may only contain letters, numbers, dots, dashes, and underscores." 3 + continue + fi + printf -v "${result_var}" "%s" "${input_site_name}" + return "${FLOW_CONTINUE}" + ;; + esac + done +} + +prompt_manage_stack_site_admin_password_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local input_admin_password="" + local prompt_status=0 + + while true; do + input_admin_password="$(prompt_stack_site_admin_password "${stack_name}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + input_admin_password="$(printf '%s' "${input_admin_password}" | tr -d '\r\n')" + case "${input_admin_password}" in + "") + show_warning_and_wait "Administrator password is required." 2 + ;; + /back | /Back | /BACK) + return "${FLOW_ABORT_INPUT}" + ;; + *) + printf -v "${result_var}" "%s" "${input_admin_password}" + return "${FLOW_CONTINUE}" + ;; + esac + done +} + +prompt_manage_stack_delete_keyword_with_cancel() { + local result_var="${1}" + local stack_name="${2}" + local delete_confirmation="" + local prompt_status=0 + + while true; do + delete_confirmation="$(prompt_manage_stack_delete_keyword "${stack_name}")" + prompt_status=$? + if [ "${prompt_status}" -ne 0 ]; then + return "${FLOW_ABORT_INPUT}" + fi + + delete_confirmation="$(printf '%s' "${delete_confirmation}" | tr -d '\r\n')" + case "${delete_confirmation}" in + /back | /Back | /BACK | "") + return "${FLOW_ABORT_INPUT}" + ;; + delete) + printf -v "${result_var}" "%s" "${delete_confirmation}" + return "${FLOW_CONTINUE}" + ;; + *) + show_warning_and_wait "Type exactly delete to confirm stack removal." 3 + ;; + esac + done +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh new file mode 100755 index 00000000..6a076ab3 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/site.sh @@ -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 +} diff --git a/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh new file mode 100755 index 00000000..340c9bd0 --- /dev/null +++ b/scripts/easy-docker/lib/app/wizard/flows/manage/stack.sh @@ -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 +}