diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci.yml similarity index 100% rename from .github/workflows/ci-go.yml rename to .github/workflows/ci.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d4606..766fbbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to Edgelet are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0-beta.3] — mid-June 2026 + +Production hardening release paired with Controller **v3.8.0-beta.1**. Deploy Edgelet and Controller v3.8 together; greenfield ControlPlane YAML only. + +### Added + +- **Controller microservice register-once:** after provision and local ControlPlane deploy, Edgelet calls `POST /api/v3/agent/controller/register` once and retries until success; no re-register on spec drift. +- **OTA controller semver:** `GET /api/v3/agent/version` `semver` field normalized to `vX.Y.Z` for `install.sh`; `provisionKey` and `expirationTime` (Unix ms) drive post-OTA reprovision (private key rotation only; stable `iofogUuid`). +- **Per-OS install paths:** documented linux, darwin, and windows directory tables in README and `docs/edgelet/installation.md`; embedded `uninstall.sh` in the install monolith; linux `/usr/share/edgelet/` ships both scripts after curl-only install. + +### Changed + +- **ControlPlane manifest v3.8:** OIDC auth, EdgeOps Console, TLS, and `controller.publicUrl` / `trustProxy`; canonical env projection (`AUTH_*`, `OIDC_*`, `CONSOLE_*`, `TLS_*`, `INTERMEDIATE_CERT`); host ports **51121** (API) and **80** → console. +- **Config hot-reload:** PATCH `/v1/config` and `edgelet system reload` apply log level without service restart; shared reload path for SIGHUP, PATCH, and POST reload; `logging.InstanceConfigUpdated` on hot reload. +- **`edgelet system reload` UX:** human success output (spinner model) when not using structured `-o`. + +### Fixed + +- **Curl-only linux install:** `/usr/share/edgelet/uninstall.sh` now present after pipe-to-bash install (embedded in assemble pipeline). +- **macOS install:** darwin uses `/var/run/edgelet` (not `/run`) and `/usr/local/share/edgelet/` for bundled scripts; avoids failures on default macOS layout. + +### Breaking + +- **Legacy ControlPlane YAML rejected:** keys such as `auth.url`, `ecnViewer*`, `spec.https`, Keycloak/viewer/ssl-era fields fail validation; use v3.8 schema and examples under `docs/edgelet/examples/controlplane.yaml`. + +### Known limitations (beta) + +- **Pre-release:** `v1.0.0-beta.3` is not production GA; pair with Controller **3.8.0-beta.1** or newer v3.8 line. +- **Provisioned guard IT:** blocked `ms rm` / `controlplane delete` when provisioned deferred (requires live system fog); unprovisioned lifecycle covered in CP IT. + ## [1.0.0-beta.2] — mid-June 2026 ### Fixed diff --git a/Makefile b/Makefile index a227978..a6bd1da 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-cli build-daemon build-daemon-embedded build-edgelet build-edgelet-linux build-edgelet-local deps test lint lint-fix clean docker-build install install-dev start-dev stop-dev setup-dev-env export-dev-env fmt vet help build-all-archs build-release-matrix build-linux-amd64 build-linux-arm64 build-linux-arm build-linux-riscv64 release-binaries build-desktop-darwin build-desktop-windows test-embedded test-embedded-ci cli-docs cli-docs-check cli-help-check cli-completion test-embedded-docker ci-docker quality-linux quality-linux-arm64 quality-linux-amd64 install-scripts install-scripts-check +.PHONY: build build-cli build-daemon build-daemon-embedded build-edgelet build-edgelet-linux build-edgelet-local deps test test-linux lint lint-fix clean docker-build install install-dev start-dev stop-dev setup-dev-env export-dev-env fmt vet help build-all-archs build-release-matrix build-linux-amd64 build-linux-arm64 build-linux-arm build-linux-riscv64 release-binaries build-desktop-darwin build-desktop-windows test-embedded test-embedded-ci cli-docs cli-docs-check cli-help-check cli-completion test-embedded-docker ci-docker quality-linux quality-linux-arm64 quality-linux-amd64 install-scripts install-scripts-check GOBIN ?= $(shell go env GOBIN) ifeq ($(GOBIN),) @@ -95,7 +95,7 @@ cli-help-check: ## Fail if CLI help regression tests fail @go test ./internal/cli/cmd/ -run '^TestHelp_' -count=1 install-scripts: ## Regenerate monolithic install.sh from authoring inputs - @chmod +x scripts/assemble-install.sh scripts/install/gen-embedded-block.sh + @chmod +x scripts/assemble-install.sh scripts/install/gen-embedded-block.sh scripts/install/gen-embedded-uninstall-block.sh scripts/install/gen-embedded-install-self-block.sh @./scripts/assemble-install.sh install-scripts-check: install-scripts ## Fail if install.sh drift from assemble @@ -182,6 +182,10 @@ test-integration: ## Run integration tests @echo "Running integration tests..." @go test -v ./test/integration/... +test-linux: ## Run make test-unit in Linux Docker (CI Test job parity; GOARCH=host arch) + @chmod +x scripts/test-linux.sh + @GOARCH=$${GOARCH:-$$(go env GOARCH)} scripts/test-linux.sh + test-embedded: ## Run embedded-containerd integration tests in a Lima VM (macOS only) @echo "Running embedded containerd integration tests..." @./test/embedded/run-all.sh @@ -484,11 +488,11 @@ security-code: ## Run static Go security analysis (gosec) fi @gosec -exclude-dir=build $(GOSEC_SCOPE) -quality-linux-arm64: ## Run lint, vulncheck, security-code in Linux arm64 Docker +quality-linux-arm64: test-linux ## Run test-linux and then lint, vulncheck, security-code in Linux arm64 Docker @chmod +x scripts/quality-linux.sh @GOARCH=arm64 scripts/quality-linux.sh -quality-linux-amd64: ## Run lint, vulncheck, security-code in Linux amd64 Docker +quality-linux-amd64: test-linux ## Run test-linux and then lint, vulncheck, security-code in Linux amd64 Docker @chmod +x scripts/quality-linux.sh @GOARCH=amd64 scripts/quality-linux.sh diff --git a/README.md b/README.md index 97b9109..5390df9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Edgelet -[![CI](https://github.com/eclipse-iofog/edgelet/actions/workflows/ci-go.yml/badge.svg)](https://github.com/eclipse-iofog/edgelet/actions/workflows/ci-go.yml) +[![CI](https://github.com/eclipse-iofog/edgelet/actions/workflows/ci.yml/badge.svg)](https://github.com/eclipse-iofog/edgelet/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/eclipse-iofog/edgelet?include_prereleases)](https://github.com/eclipse-iofog/edgelet/releases) [![Go](https://img.shields.io/badge/Go-1.26.4-blue.svg)](https://go.dev/) [![License](https://img.shields.io/badge/License-EPL--2.0-blue.svg)](LICENSE) @@ -190,6 +190,17 @@ sudo ./install.sh --version=v1.0.0-beta.2 Release artifacts per tag: seven binaries (`edgelet-linux-`, `edgelet-darwin-`, `edgelet-windows-amd64.exe`), `SHA256SUMS`, `install.sh`, `uninstall.sh`, and config/CA samples. +### Install paths + +| Purpose | Linux | macOS | Windows | +|---------|-------|-------|---------| +| Binary | `/usr/local/bin/edgelet` | `/usr/local/bin/edgelet` | `%ProgramFiles%\Edgelet\edgelet.exe` | +| Config | `/etc/edgelet/config.yaml` | `/etc/edgelet/config.yaml` | `%ProgramData%\Edgelet\config\config.yaml` | +| Data | `/var/lib/edgelet/` | `/var/lib/edgelet/` | `%ProgramData%\Edgelet\data\` | +| Runtime | `/run/edgelet/` | `/var/run/edgelet/` | `%ProgramData%\Edgelet\run\` | +| Logs | `/var/log/edgelet/` | `/var/log/edgelet/` | `%ProgramData%\Edgelet\log\` | +| Scripts | `/usr/share/edgelet/` | `/usr/local/share/edgelet/` | `%ProgramData%\Edgelet\scripts\` | + ```bash sudo edgelet init-config # default config if missing edgelet daemon # foreground @@ -228,7 +239,7 @@ Controller REST (field agent) uses `/api/v3/...` on the controller URL — separ | Workflow | Purpose | |----------|---------| -| [`.github/workflows/ci-go.yml`](.github/workflows/ci-go.yml) | Build, lint, unit tests | +| [`.github/workflows/ci.yml`](.github/workflows/ci.yml) | Build, lint, unit tests | | [`.github/workflows/govulncheck.yml`](.github/workflows/govulncheck.yml) | Dependency vulnerability scan | | [`.github/workflows/release.yml`](.github/workflows/release.yml) | Release matrix on tag push | diff --git a/cmd/edgelet-server/daemon.go b/cmd/edgelet-server/daemon.go index 7040209..4fcab3c 100644 --- a/cmd/edgelet-server/daemon.go +++ b/cmd/edgelet-server/daemon.go @@ -145,7 +145,9 @@ func runDaemon() { lastReloadTime = now logging.LogInfo("Daemon", "Reloading configuration due to SIGHUP") - reloadAgentConfig(sup) + if err := sup.ReloadFromDisk(); err != nil { + logging.LogError("Daemon", "Configuration reload failed", err) + } continue } @@ -203,37 +205,3 @@ func startLoggingService() { logging.LogInfo("MAIN_DAEMON", "Configuration loaded.") } - -func reloadAgentConfig(sup *supervisor.Supervisor) { - logging.LogInfo("Daemon", "Reloading configuration...") - config.SetLastReloadSuccessful(false) - - sup.BeginConfigReload() - - if err := config.LoadConfig(utils.ConfigYAMLPath); err != nil { - logging.LogError("Daemon", "Failed to reload configuration", err) - logging.LogWarn("Daemon", "Rejected configuration reload; keeping last-known-good runtime config") - return - } - - cfg := config.GetInstance() - if err := config.ValidateConfig(cfg); err != nil { - logging.LogError("Daemon", "Configuration validation failed after reload", err) - logging.LogWarn("Daemon", "Rejected configuration reload; keeping last-known-good runtime config") - return - } - config.SetLastReloadSuccessful(true) - - logDiskLimitMB := int(cfg.LogDiskLimit * 1024) - if err := logging.InstanceConfigUpdated(cfg.LogDiskDirectory, logDiskLimitMB, cfg.LogFileCount, cfg.LogLevel); err != nil { - logging.LogError("Daemon", "Failed to update logger configuration", err) - } - - if err := sup.ReloadConfig(); err != nil { - logging.LogError("Daemon", "Failed to notify modules of config reload", err) - config.SetLastReloadSuccessful(false) - return - } - - logging.LogInfo("Daemon", "Configuration reloaded successfully") -} diff --git a/cmd/edgelet/daemon_desktop.go b/cmd/edgelet/daemon_desktop.go index 767fee6..d7d4f6c 100644 --- a/cmd/edgelet/daemon_desktop.go +++ b/cmd/edgelet/daemon_desktop.go @@ -108,7 +108,9 @@ func runDaemon() { lastReloadTime = now logging.LogInfo("Daemon", "Reloading configuration due to SIGHUP") - reloadAgentConfig(sup) + if err := sup.ReloadFromDisk(); err != nil { + logging.LogError("Daemon", "Configuration reload failed", err) + } continue } @@ -166,34 +168,3 @@ func startLoggingService() { logging.LogInfo("MAIN_DAEMON", "Configuration loaded.") } - -func reloadAgentConfig(sup *supervisor.Supervisor) { - logging.LogInfo("Daemon", "Reloading configuration...") - config.SetLastReloadSuccessful(false) - sup.BeginConfigReload() - - if err := config.LoadConfig(utils.ConfigYAMLPath); err != nil { - logging.LogError("Daemon", "Failed to reload configuration", err) - logging.LogWarn("Daemon", "Rejected configuration reload; keeping last-known-good runtime config") - return - } - - cfg := config.GetInstance() - if err := config.ValidateConfig(cfg); err != nil { - logging.LogError("Daemon", "Configuration validation failed after reload", err) - logging.LogWarn("Daemon", "Rejected configuration reload; keeping last-known-good runtime config") - return - } - config.SetLastReloadSuccessful(true) - - logDiskLimitMB := int(cfg.LogDiskLimit * 1024) - if err := logging.InstanceConfigUpdated(cfg.LogDiskDirectory, logDiskLimitMB, cfg.LogFileCount, cfg.LogLevel); err != nil { - logging.LogError("Daemon", "Failed to update logger configuration", err) - } - - if err := sup.ReloadConfig(); err != nil { - logging.LogError("Daemon", "Failed to notify modules of config reload", err) - } - - logging.LogInfo("Daemon", "Configuration reloaded successfully") -} diff --git a/cmd/edgelet/daemon_signal_windows.go b/cmd/edgelet/daemon_signal_windows.go index f59dacd..79e20f4 100644 --- a/cmd/edgelet/daemon_signal_windows.go +++ b/cmd/edgelet/daemon_signal_windows.go @@ -8,6 +8,7 @@ import ( "syscall" "github.com/eclipse-iofog/edgelet/internal/supervisor" + "github.com/eclipse-iofog/edgelet/internal/utils/logging" ) func registerDaemonSignals(sigChan chan os.Signal) { @@ -15,7 +16,9 @@ func registerDaemonSignals(sigChan chan os.Signal) { } func onConfigFileChanged(sup *supervisor.Supervisor) { - reloadAgentConfig(sup) + if err := sup.ReloadFromDisk(); err != nil { + logging.LogError("Daemon", "Configuration reload failed", err) + } } func isConfigReloadSignal(_ os.Signal) bool { diff --git a/docs/edgelet/control-plane.md b/docs/edgelet/control-plane.md index 21b5946..4d74b2f 100644 --- a/docs/edgelet/control-plane.md +++ b/docs/edgelet/control-plane.md @@ -63,14 +63,24 @@ Use **controlplane get** for deployment status and manifest; **ms inspect** retu edgelet controlplane delete ``` -This is the **only** supported way to remove the controller deployment. `edgelet ms rm` on the controller UUID is rejected; Edgelet will reconcile the container back if the ControlPlane record still exists. +This is the **only** supported way to remove the controller deployment while the agent is **unprovisioned**. `edgelet ms rm` on the controller UUID is rejected when provisioned; Edgelet will reconcile the container back if the ControlPlane record still exists. + +While the agent is **provisioned**, `edgelet controlplane delete` is also rejected — deprovision the agent first. + +## Controller registration (system fog) + +After the local ControlPlane container is running and the agent is provisioned, Edgelet registers the controller workload once with Pot (`POST /api/v3/agent/controller/register`). Pot then lists the controller microservice with `isController: true`. + +**Operator requirement:** the system fog must be created on Controller with **`isSystem: true`**. Registration uses the agent fog token and fails with **403** on non-system fogs. + +Spec updates from Pot (`microserviceList`) merge into the local ControlPlane deployment; the process manager does not ADD/DELETE a duplicate controller container. ## Ports | Host | Container | Service | |------|-----------|---------| | 51121 | `spec.controller.port` (default 51121) | Controller API | -| 80 | `spec.ecnViewerPort` (default 8008) | ECN viewer | +| 80 | `spec.console.port` (default 8008) | EdgeOps Console | ## DNS (embedded) diff --git a/docs/edgelet/deployment.md b/docs/edgelet/deployment.md index 7a7dce4..5579033 100644 --- a/docs/edgelet/deployment.md +++ b/docs/edgelet/deployment.md @@ -47,6 +47,8 @@ Orchestrators must **not** pass provision keys to `install.sh`. 3. **Airgap:** verify `SHA256SUMS` on the orchestrator → SCP binary → `install.sh --airgap --bin-path=…`. 4. **Provision:** SSH `edgelet provision ` (and `edgelet config …` as needed). +When deploying a **local ControlPlane** controller on a system fog, create that fog on Controller with **`isSystem: true`** so Edgelet can register the controller microservice after provision. + ## Daemon and systemd Production linux uses the **thin** binary as the service entry point: diff --git a/docs/edgelet/examples/controlplane.yaml b/docs/edgelet/examples/controlplane.yaml index e2060ee..0eeae44 100644 --- a/docs/edgelet/examples/controlplane.yaml +++ b/docs/edgelet/examples/controlplane.yaml @@ -10,8 +10,10 @@ # Rules: # - At most one ControlPlane per Edgelet node. # - metadata.labels are forbidden. +# - spec.auth is required (mode embedded | external). # - spec.siteCA / spec.localCA are forbidden (import via Controller REST after deploy). -# - spec.https.path and spec.https.base64 are mutually exclusive. +# - spec.tls.path and spec.tls.base64 are mutually exclusive. +# - Legacy keys (auth.url, ecnViewer*, spec.https, Keycloak fields) are rejected. # ============================================================================= apiVersion: edgelet.iofog.org/v1 # required @@ -24,18 +26,45 @@ metadata: spec: controller: - image: ghcr.io/datasance/controller:3.7.0 # required — controller container image + image: ghcr.io/datasance/controller:3.8.0-beta.0 # required — controller container image registry: 1 # optional — local registry row ID for image pull - port: 51121 # optional — controller API port (host + container); default 51121 + port: 51121 # optional — API port (host 51121 + container); default 51121 + publicUrl: https://controller.example.com # recommended — CONTROLLER_PUBLIC_URL + trustProxy: true # optional — honor X-Forwarded-* when TLS terminates at ingress - auth: # optional — Keycloak / auth configuration - url: https://auth.example.com/ # auth server URL - realm: example # realm name - realmKey: "" - ssl: external # ssl mode (e.g. external) - controllerClient: pot-controller - controllerSecret: "" - viewerClient: ecn-viewer + console: # optional — EdgeOps Console (embedded in Controller image) + port: 8008 # optional — container console port; host maps port 80 + url: https://console.example.com # optional — CONSOLE_URL (defaults to publicUrl) + + auth: # required — OIDC configuration (embedded or external IdP) + mode: embedded # required — embedded | external + insecureAllowHttp: false # dev only — AUTH_INSECURE_ALLOW_HTTP + insecureAllowBootstrapLog: false # dev only — AUTH_INSECURE_ALLOW_BOOTSTRAP_LOG + bootstrap: # required when mode=embedded + username: admin # OIDC_BOOTSTRAP_ADMIN_USERNAME + password: "" # OIDC_BOOTSTRAP_ADMIN_PASSWORD (≥12 chars, 1 upper, 1 special) + # issuerUrl: https://auth.example.com/realms/myrealm # required when mode=external + # client: # required when mode=external; optional overrides in embedded + # id: controller + # secret: "" + # consoleClient: ecn-viewer # OIDC_CONSOLE_CLIENT_ID + # consoleClientEnabled: false # AUTH_CONSOLE_CLIENT_ENABLED + # rateLimit: + # enabled: true + # maxRequestsPerWindow: 60 + # windowMs: 60000 + # sessionStore: + # type: memory # memory | database + # ttlMs: 600000 + # secret: "" + # tokenTtl: + # accessTokenTtlSeconds: 900 + # refreshTokenTtlSeconds: 3600 + # oidcTtl: + # interactionTtlSeconds: + # grantTtlSeconds: + # sessionTtlSeconds: + # idTokenTtlSeconds: # database: # optional — external database (multi-controller setups) # provider: postgres # postgres or mysql @@ -55,10 +84,10 @@ spec: systemMicroservices: # optional — router/NATS images per CPU architecture router: - amd64: ghcr.io/datasance/router:3.7.0 - arm64: ghcr.io/datasance/router:3.7.0 - arm: ghcr.io/datasance/router:3.7.0 - riscv64: ghcr.io/datasance/router:3.7.0 + amd64: ghcr.io/datasance/router:3.8.0-beta.0 + arm64: ghcr.io/datasance/router:3.8.0-beta.0 + arm: ghcr.io/datasance/router:3.8.0-beta.0 + riscv64: ghcr.io/datasance/router:3.8.0-beta.0 nats: # required when spec.nats.enabled is true amd64: ghcr.io/datasance/nats:2.12.4 arm64: ghcr.io/datasance/nats:2.12.4 @@ -68,15 +97,12 @@ spec: nats: # optional — deploy NATS system microservice (JetStream) enabled: false # set true to deploy NATS using systemMicroservices.nats images - # ecnViewerPort: 8008 # optional — container viewer port (host maps port 80) - # ecnViewerUrl: https://viewer.example.com - # logLevel: info # optional — controller log level - # https: # optional — controller HTTPS (path OR base64, not both) + # tls: # optional — listener TLS (path OR base64, not both) # path: /etc/edgelet/controller-tls # absolute host dir with tls.crt + tls.key (+ optional ca.crt) # base64: - # ca: "" # optional + # ca: "" # optional — TLS_BASE64_INTERMEDIATE_CERT # cert: "" # required when base64 block is used # key: "" # required when base64 block is used @@ -100,4 +126,3 @@ spec: # google: # projectId: "" # credentials: "" # service account JSON or path - diff --git a/docs/edgelet/installation.md b/docs/edgelet/installation.md index 9b1f78a..1103f2f 100644 --- a/docs/edgelet/installation.md +++ b/docs/edgelet/installation.md @@ -74,14 +74,16 @@ The fat runtime inside the linux zstd embed is **statically linked by default** ## Install paths -| Item | Linux | macOS | Windows | -|------|-------|-------|---------| -| Binary | `/usr/local/bin/edgelet` | `/usr/local/bin/edgelet` | `Program Files\Edgelet\edgelet.exe` | -| Config | `/etc/edgelet/config.yaml` | `/etc/edgelet/config.yaml` | `%ProgramData%\Edgelet\config.yaml` | +| Purpose | Linux | macOS | Windows | +|---------|-------|-------|---------| +| Binary | `/usr/local/bin/edgelet` | `/usr/local/bin/edgelet` | `%ProgramFiles%\Edgelet\edgelet.exe` | +| Config | `/etc/edgelet/config.yaml` | `/etc/edgelet/config.yaml` | `%ProgramData%\Edgelet\config\config.yaml` | | Controller CA | `/etc/edgelet/cert.crt` | same | same | | Data | `/var/lib/edgelet/` | `/var/lib/edgelet/` | `%ProgramData%\Edgelet\data\` | +| Runtime | `/run/edgelet/` | `/var/run/edgelet/` | `%ProgramData%\Edgelet\run\` | +| Logs | `/var/log/edgelet/` | `/var/log/edgelet/` | `%ProgramData%\Edgelet\log\` | +| Scripts | `/usr/share/edgelet/` | `/usr/local/share/edgelet/` | `%ProgramData%\Edgelet\scripts\` | | OTA metadata | `/var/backups/edgelet/` | — | — | -| Bundled scripts (linux) | `/usr/share/edgelet/install.sh` | optional | optional | Linux thin runtime chain: `/usr/local/bin/edgelet` → lazy extract → `/var/lib/edgelet/data/current/bin/edgelet` (fat). @@ -160,7 +162,7 @@ Environment: `EDGELET_VERSION`, `EDGELET_GITHUB_REPO`. ### After install -`install.sh` does **not** provision the node. On linux it installs the init unit and **starts** `edgelet.service` (and `edgelet-containerd` when `containerEngine=edgelet`). +`install.sh` does **not** provision the node. On **linux** it installs the init unit and **starts** `edgelet.service` (and `edgelet-containerd` when `containerEngine=edgelet`). On **darwin** and **windows** it starts `edgelet daemon` in the background (`nohup`); logs go to `edgelet.0.log` under the platform log directory. ```bash edgelet --version @@ -212,7 +214,9 @@ Constants set at the top of `install.sh`: | `/var/backups/edgelet/cache/` | Cached previous binaries | | `/var/backups/edgelet/install-receipt` | Current install metadata | | `/var/backups/edgelet/previous-release` | Rollback metadata | -| `/usr/share/edgelet/` | Bundled `install.sh`, init templates, lib helpers | +| `/usr/share/edgelet/` (linux) | Bundled `install.sh`, `uninstall.sh`, config/CA samples | +| `/usr/local/share/edgelet/` (macOS) | Bundled `install.sh`, `uninstall.sh`, config/CA samples | +| `%ProgramData%\Edgelet\scripts\` (windows) | Bundled `install.sh`, `uninstall.sh`, config/CA samples | ### Phase 2 — Fresh install @@ -222,7 +226,7 @@ Constants set at the top of `install.sh`: 4. `install_config_samples` — preserve existing config unless `--force-config` 5. `apply_container_engine_to_config` — patch `containerEngine` in config when flag set 6. `write_install_receipt` — `install_method=install` or `install-airgap` -7. `copy_bundled_scripts` — copy self to `/usr/share/edgelet/install.sh` (linux) +7. `copy_bundled_scripts` — copy `install.sh` and `uninstall.sh` to the platform scripts directory 8. `install_init_unit` — install systemd/OpenRC/… unit and **start** the daemon ### Phase 3 — Upgrade (`--upgrade`) @@ -397,39 +401,80 @@ sequenceDiagram participant Pot as Pot Controller participant FA as Field Agent participant VH as Version Handler + participant Disk as ota-reprovision-pending participant IS as install.sh FA->>Pot: Status heartbeat (isReadyToUpgrade=true) Pot->>FA: changes.version=true - FA->>Pot: GET version → versionCommand - Note over Pot,FA: command, version, provisionKey - FA->>VH: ChangeVersion(versionCommand) + FA->>Pot: GET version + Note over Pot,FA: versionCommand, semver, provisionKey, expirationTime (Unix ms) + FA->>VH: ChangeVersion(normalized action) VH->>VH: isValidChangeVersionOperation() alt ready + VH->>Disk: write pending (provisionKey, expiry) chmod 600 VH->>IS: detached sh /usr/share/edgelet/install.sh --upgrade|--rollback IS->>IS: stop → replace binary → install_init_unit start + FA->>Disk: read pending on Start() + alt expiry valid + FA->>Pot: POST provision(provisionKey) + Note over FA,Pot: stable iofogUuid, new privateKey + FA->>Disk: delete pending + else expired + FA->>Pot: GET version (one refresh) + end else not ready VH-->>FA: no-op (command ignored) end ``` -### `versionCommand` payload +### `versionCommand` payload (Controller v3.8) -Fetched from the controller **`version`** endpoint when `changes["version"]` is true: +Fetched from the controller **`version`** endpoint when `changes["version"]` is true. + +**Flat v3.8 shape:** + +```json +{ + "versionCommand": "upgrade", + "provisionKey": "", + "expirationTime": 1718380800000, + "semver": "1.0.0-beta.3" +} +``` + +**Legacy nested shape** (normalized internally): ```json { - "command": "UPGRADE", - "version": "v1.2.3", - "provisionKey": "" + "versionCommand": { + "command": "UPGRADE", + "version": "v1.2.3", + "provisionKey": "", + "expirationTime": 1718380800000 + } } ``` | Field | Values | |-------|--------| -| `command` | `UPGRADE` or `ROLLBACK` (case-insensitive) | -| `version` | Target tag for upgrade (optional; defaults to GitHub latest in script) | -| `provisionKey` | Logged for audit only; not passed to `install.sh` | +| `versionCommand` | `upgrade` or `rollback` (flat string) or nested `command` map | +| `semver` | Target version when set; takes precedence over `version`/`target` | +| `provisionKey` | One-time reprovision key; issued on **upgrade and rollback** | +| `expirationTime` | Unix epoch **milliseconds** (JSON number or decimal string); typical TTL ~20 min | + +`semver` is omitted when unset — do not expect `null`. + +### Post-OTA reprovision + +Controller-driven OTA rotates the agent ed25519 key without changing `iofogUuid`: + +1. Version handler writes `/var/backups/edgelet/ota-reprovision-pending` before launching `install.sh`. +2. `install.sh` stops the daemon, replaces the binary, and restarts via init. +3. On `FieldAgent.Start()`, if the install receipt shows `install_method` of `upgrade`, `upgrade-airgap`, or `rollback`, Edgelet reads the pending file and calls `POST provision` with the stored key (if `expirationTime` is still valid). +4. On success: pending file deleted, JWT rotated, Edge Guard baseline cleared, `postFogConfig` sent. +5. If the key expired during OTA: one `GET version` refresh for a new key; otherwise log a warning, keep the old credentials, and retry on the upgrade scan worker. + +Manual `install.sh` runs do not create a pending file and do not auto-reprovision. `ChangeVersion` validates readiness **again** before spawning the script. Stale controller commands are ignored if the node is not ready. @@ -493,7 +538,11 @@ sudo sh uninstall.sh sudo sh uninstall.sh --remove-data # also removes /etc/edgelet when set ``` -Bundled copy: `sudo sh /usr/share/edgelet/uninstall.sh`. +Bundled copy (linux): `sudo sh /usr/share/edgelet/uninstall.sh`. + +Bundled copy (macOS): `sudo sh /usr/local/share/edgelet/uninstall.sh`. + +Bundled copy (windows): `sh %ProgramData%\Edgelet\scripts\uninstall.sh`. --- diff --git a/docs/edgelet/manifest-reference.md b/docs/edgelet/manifest-reference.md index a21f677..7144d89 100644 --- a/docs/edgelet/manifest-reference.md +++ b/docs/edgelet/manifest-reference.md @@ -154,6 +154,15 @@ Deploys **one** Datasance Controller container per Edgelet node (optional — re **Annotated reference:** [examples/controlplane.yaml](examples/controlplane.yaml) lists every YAML key (active + commented optional blocks). +### Required fields + +| Field | Notes | +|-------|-------| +| `spec.controller.image` | Controller container image | +| `spec.auth.mode` | `embedded` or `external` | +| `spec.auth.bootstrap` | Required when `mode: embedded` (`username`, `password` with complexity rules) | +| `spec.auth.issuerUrl` + `spec.auth.client` | Required when `mode: external` | + ### Forbidden fields | Field | Reason | @@ -172,13 +181,34 @@ spec: image: # required registry: # optional port: 51121 # optional API port - auth: { ... } # optional Keycloak / auth block + publicUrl: # recommended — CONTROLLER_PUBLIC_URL + trustProxy: true|false # optional + console: + port: 8008 # optional — host port 80 maps here + url: # optional — CONSOLE_URL + auth: # required + mode: embedded|external + insecureAllowHttp: false + insecureAllowBootstrapLog: false + bootstrap: # required when mode=embedded + username: admin + password: "" # ≥12 chars, 1 uppercase, 1 special + issuerUrl: # required when mode=external + client: # required when mode=external + id: + secret: + consoleClient: ecn-viewer + consoleClientEnabled: false + rateLimit: { enabled, maxRequestsPerWindow, windowMs } + sessionStore: { type, ttlMs, secret } + tokenTtl: { accessTokenTtlSeconds, refreshTokenTtlSeconds } + oidcTtl: { interactionTtlSeconds, grantTtlSeconds, sessionTtlSeconds, idTokenTtlSeconds } systemMicroservices: # optional router/nats image maps per arch router: { amd64: "...", arm64: "..." } nats: { ... } nats: enabled: true|false - # events, database, https, vault, ecnViewerPort, logLevel — see control-plane.md + # events, database, tls, vault, logLevel — see control-plane.md ``` ### Rules diff --git a/docs/edgelet/modules/controlplane.md b/docs/edgelet/modules/controlplane.md index d57c3f3..5640967 100644 --- a/docs/edgelet/modules/controlplane.md +++ b/docs/edgelet/modules/controlplane.md @@ -36,12 +36,12 @@ Does **not** run HTTP or containers directly. | UUID | Generated controller UUID (stable in SQLite) | | `IsController` | `true`, `IsSystem` | | Host API port | **51121** → `spec.controller.port` (default 51121) | -| Host viewer port | **80** → `spec.ecnViewerPort` (default 8008) | +| Host console port | **80** → `spec.console.port` (default 8008) | | Volumes | `iofog-controller-db`, `iofog-controller-log` | -| HTTPS | Optional bind mount to `/etc/iofog/controller-cert/` | +| TLS | Optional bind mount to `/etc/iofog/controller-cert/` | | Registry | `RegistryID = 2` (local default) | -Constants in `runtime.go`: `HostAPIPort`, `HostViewerPort`, volume names, container paths. +Constants in `runtime.go`: `HostAPIPort`, `HostConsolePort`, volume names, container paths. ## Environment diff --git a/install.sh b/install.sh index 6c751e4..3d20698 100755 --- a/install.sh +++ b/install.sh @@ -6,6 +6,22 @@ # sudo ./install.sh --bin-path=build/edgelet-linux-amd64 --version=dev # sudo ./install.sh --airgap --bin-path=./edgelet-linux-amd64 --expected-sha256=... # +# Options: +# --version=VERSION Release tag (default: latest) +# --arch=ARCH Override arch (amd64, arm64, arm, riscv64) +# --container-engine=ENGINE edgelet, docker, or podman +# (linux default: edgelet; darwin/windows: docker) +# --airgap Do not download; use --bin-path +# --bin-path=PATH Local edgelet binary +# --checksum-path=PATH SHA256SUMS manifest for verification +# --expected-sha256=HASH Verify local binary SHA256 +# --upgrade / --rollback In-place thin-binary OTA (linux) +# --force-config Replace config from sample +# --with-sample-ca Install sample controller CA if cert missing +# --help, -h Show options and exit +# +# Environment: EDGELET_VERSION, EDGELET_GITHUB_REPO +# # Provision after install: edgelet provision (not install.sh flags) set -e @@ -15,6 +31,59 @@ info() { echo ">>> $1"; } SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +# ── argument parsing ────────────────────────────────────────────────────────── +EDGELET_VERSION="${EDGELET_VERSION:-latest}" +CONTAINER_ENGINE="" +ACTION="install" +AIRGAP=false +BIN_PATH="" +FORCE_CONFIG=false +WITH_SAMPLE_CA=false +ARCH_OVERRIDE="" +CHECKSUM_FILE="" +EXPECTED_SHA256="" + +for arg in "$@"; do + case "${arg}" in + --version=*) EDGELET_VERSION="${arg#*=}" ;; + --arch=*) ARCH_OVERRIDE="${arg#*=}" ;; + --container-engine=*) CONTAINER_ENGINE="${arg#*=}" ;; + --bin-path=*) BIN_PATH="${arg#*=}" ;; + --checksum-path=*) CHECKSUM_FILE="${arg#*=}" ;; + --expected-sha256=*) EXPECTED_SHA256="${arg#*=}" ;; + --airgap) AIRGAP=true ;; + --upgrade) ACTION="upgrade" ;; + --rollback) ACTION="rollback" ;; + --force-config) FORCE_CONFIG=true ;; + --with-sample-ca) WITH_SAMPLE_CA=true ;; + --help|-h) + cat < "$_dest" << 'EDGELET_UNINSTALL_EOF' +#!/bin/sh +# uninstall.sh — Edgelet uninstaller +# +# Usage: +# sudo sh uninstall.sh [--remove-data] +# +# --remove-data also removes config, data, runtime, logs, and backup directories + +set -e + +die() { echo "ERROR: $1" >&2; exit 1; } +info() { echo ">>> $1"; } detect_os() { _u=$(uname -s) @@ -815,148 +893,2315 @@ detect_os() { esac } -detect_arch() { - MACHINE=$(uname -m) - case "${MACHINE}" in - x86_64|amd64) echo "amd64" ;; - aarch64|arm64) echo "arm64" ;; - armv7l|armv6l|arm) echo "arm" ;; - riscv64) echo "riscv64" ;; - *) die "Unsupported architecture: ${MACHINE}" ;; - esac -} - -require_root() { - OS=$(detect_os) - if [ "$OS" = "windows" ]; then - return 0 - fi - [ "$(id -u)" -eq 0 ] || die "This script must be run as root. Try: sudo $0 $*" +windows_program_data_edgelet() { + echo "${ProgramData:-/c/ProgramData}/Edgelet" } -binary_basename() { - _os="$1" _arch="$2" - case "${_os}" in - windows) echo "edgelet-${_os}-${_arch}.exe" ;; - *) echo "edgelet-${_os}-${_arch}" ;; +share_dir_for_os() { + case "$1" in + linux) echo "/usr/share/edgelet" ;; + darwin) echo "/usr/local/share/edgelet" ;; + windows) echo "$(windows_program_data_edgelet)/scripts" ;; + *) die "Unsupported OS: $1" ;; esac } -binary_install_path() { - _os="$1" - case "${_os}" in +binary_path_for_os() { + case "$1" in linux|darwin) echo "/usr/local/bin/edgelet" ;; windows) _pf="${ProgramFiles:-/c/Program Files}" echo "${_pf}/Edgelet/edgelet.exe" ;; - *) die "Unsupported OS for binary path" ;; + *) die "Unsupported OS: $1" ;; esac } -release_download_url() { - _ver="$1" _os="$2" _arch="$3" - _base="https://github.com/${GITHUB_REPO}/releases/download/${_ver}/$(binary_basename "$_os" "$_arch")" - echo "$_base" +# OpenRC ships /sbin/openrc-run on Alpine even when PID 1 is busybox (Lima +# template:alpine). Require a running OpenRC supervisor, not merely openrc-run. +openrc_is_pid1() { + [ -x /sbin/openrc-run ] || return 1 + if rc-status -s >/dev/null 2>&1; then + return 0 + fi + if [ -f /etc/inittab ] && grep -q '/sbin/openrc' /etc/inittab 2>/dev/null; then + return 0 + fi + _init="$(readlink -f /sbin/init 2>/dev/null || readlink /sbin/init 2>/dev/null || true)" + case "${_init}" in + *openrc*) return 0 ;; + esac + return 1 } -verify_binary_checksum() { - _bin="$1" - [ -f "$_bin" ] || die "Not a file: $_bin" - if [ -n "$EXPECTED_SHA256" ]; then - _sum=$(sha256sum "$_bin" | awk '{print $1}') - [ "$_sum" = "$EXPECTED_SHA256" ] || die "SHA256 mismatch (expected $EXPECTED_SHA256 got $_sum)" - info "SHA256 verified." - elif [ -n "$CHECKSUM_FILE" ] && [ -f "$CHECKSUM_FILE" ]; then - _bn=$(basename "$_bin") - ( cd "$(dirname "$_bin")" && grep " ${_bn}\$" "$CHECKSUM_FILE" >/dev/null ) || \ - ( cd "$(dirname "$CHECKSUM_FILE")" && sha256sum -c "$CHECKSUM_FILE" ) || \ - die "Checksum file verification failed" +procd_is_openwrt() { + [ -x /sbin/procd ] && [ -f /etc/rc.common ] || return 1 + return 0 +} + +detect_init() { + if command -v systemctl >/dev/null 2>&1 && [ -d /etc/systemd/system ]; then + echo "systemd" + return 0 + fi + if procd_is_openwrt; then + echo "procd" + return 0 + fi + if openrc_is_pid1 && { + command -v openrc >/dev/null 2>&1 \ + || [ -x /sbin/openrc-run ] \ + || [ -f /sbin/openrc ] + }; then + echo "openrc" + return 0 + fi + if command -v initctl >/dev/null 2>&1 && [ -d /etc/init ]; then + echo "upstart" + return 0 fi + if [ -d /etc/s6 ] || command -v s6-svc >/dev/null 2>&1; then + echo "s6" + return 0 + fi + if command -v runsvdir >/dev/null 2>&1 || [ -d /etc/runit ]; then + echo "runit" + return 0 + fi + if [ -f /etc/inittab ] || command -v update-rc.d >/dev/null 2>&1 || command -v chkconfig >/dev/null 2>&1; then + echo "sysvinit" + return 0 + fi + echo "unknown" } -sha256_file() { - sha256sum "$1" | awk '{print $1}' +[ "$(id -u)" -eq 0 ] || die "Must be run as root. Try: sudo $0 $*" + +OS=$(detect_os) +SHARE_DIR=$(share_dir_for_os "$OS") +BINARY_PATH=$(binary_path_for_os "$OS") + +REMOVE_DATA=false +for arg in "$@"; do + case "${arg}" in + --remove-data) REMOVE_DATA=true ;; + --help|-h) + echo "Usage: $0 [--remove-data]" + echo "" + echo " --remove-data also delete config, data, and backup directories" + exit 0 ;; + *) die "Unknown option: ${arg}" ;; + esac +done + +stop_systemd() { + if systemctl is-active --quiet edgelet-containerd 2>/dev/null; then + systemctl stop edgelet-containerd + fi + if systemctl is-active --quiet edgelet 2>/dev/null; then + systemctl stop edgelet + fi + systemctl disable edgelet edgelet-containerd 2>/dev/null || true + rm -f /etc/systemd/system/edgelet.service + rm -f /etc/systemd/system/edgelet-containerd.service + rm -rf /etc/systemd/system/edgelet.service.d + systemctl daemon-reload 2>/dev/null || true + info "systemd service removed." } -kv_get() { - _file="$1" _key="$2" - [ -f "$_file" ] || { echo ""; return 0; } - _line=$(grep "^${_key}=" "$_file" | head -1) || true - [ -n "$_line" ] || { echo ""; return 0; } - echo "$_line" | sed "s/^${_key}=//" +stop_procd() { + /etc/init.d/edgelet stop 2>/dev/null || true + /etc/init.d/edgelet disable 2>/dev/null || true + rm -f /etc/init.d/edgelet + info "procd init script removed." } -write_install_receipt() { - _ver="$1" _os="$2" _arch="$3" _eng="$4" _url="$5" _sha="$6" _method="$7" - mkdir -p "$BACKUP_DIR" - _ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u) - { - printf 'installed_version=%s\n' "$_ver" - printf 'os=%s\n' "$_os" - printf 'arch=%s\n' "$_arch" - printf 'container_engine=%s\n' "$_eng" - printf 'source_url=%s\n' "$_url" - printf 'installed_at=%s\n' "$_ts" - printf 'install_method=%s\n' "$_method" - printf 'binary_sha256=%s\n' "$_sha" - } >"$RECEIPT_FILE" - chmod 600 "$RECEIPT_FILE" 2>/dev/null || true +stop_openrc() { + rc-service edgelet-containerd stop 2>/dev/null || true + rc-service edgelet stop 2>/dev/null || true + rc-update del edgelet-containerd default 2>/dev/null || true + rc-update del edgelet default 2>/dev/null || true + rm -f /etc/init.d/edgelet /etc/init.d/edgelet-containerd + info "OpenRC service removed." } -write_previous_release() { - _pv="$1" _pos="$2" _parch="$3" _peng="$4" _purl="$5" _psha="$6" _cfg="$7" - mkdir -p "$BACKUP_DIR" - { - printf 'previous_version=%s\n' "$_pv" - printf 'previous_os=%s\n' "$_pos" - printf 'previous_arch=%s\n' "$_parch" - printf 'previous_container_engine=%s\n' "$_peng" - printf 'previous_download_url=%s\n' "$_purl" - printf 'previous_binary_sha256=%s\n' "$_psha" - printf 'config_backup_path=%s\n' "$_cfg" - } >"$PREVIOUS_FILE" - chmod 600 "$PREVIOUS_FILE" 2>/dev/null || true +stop_sysvinit() { + /etc/init.d/edgelet stop 2>/dev/null || true + update-rc.d -f edgelet remove 2>/dev/null || true + chkconfig --del edgelet 2>/dev/null || true + rm -f /etc/init.d/edgelet + info "SysV init service removed." } -cache_binary() { - _ver="$1" _os="$2" _arch="$3" _src="$4" - mkdir -p "$CACHE_DIR" - _dest="${CACHE_DIR}/edgelet-${_ver}-${_os}-${_arch}" - case "${_os}" in - windows) _dest="${_dest}.exe" ;; - esac - cp "$_src" "$_dest" - chmod 755 "$_dest" 2>/dev/null || true - info "Cached binary at ${_dest}" +stop_upstart() { + initctl stop edgelet 2>/dev/null || true + rm -f /etc/init/edgelet.conf + initctl reload-configuration 2>/dev/null || true + info "Upstart service removed." } -cached_binary_path() { - _ver="$1" _os="$2" _arch="$3" - _p="${CACHE_DIR}/edgelet-${_ver}-${_os}-${_arch}" - case "${_os}" in - windows) _p="${_p}.exe" ;; - esac - if [ -f "$_p" ]; then - echo "$_p" - return 0 - fi - echo "" +stop_s6() { + s6-svc -d /var/run/s6/services/edgelet 2>/dev/null || true + rm -rf /var/run/s6/services/edgelet + rm -rf /etc/s6/edgelet + info "s6 service removed." } -packaging_etc_dir() { - if [ -d "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet" ]; then - echo "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet" - return 0 - fi - if [ -d "${SHARE_DIR}/etc/edgelet" ]; then - echo "${SHARE_DIR}/etc/edgelet" +stop_runit() { + sv down edgelet 2>/dev/null || true + rm -f /etc/service/edgelet /var/service/edgelet /service/edgelet 2>/dev/null || true + rm -rf /etc/runit/edgelet + info "runit service removed." +} + +stop_fallback() { + pkill -f "/usr/local/bin/edgelet daemon" 2>/dev/null || true + pkill -f "edgelet daemon" 2>/dev/null || true + info "Background edgelet processes stopped (if any)." +} + +lazy_umount_edgelet() { + if ! command -v umount >/dev/null 2>&1; then return 0 fi - echo "" + mount 2>/dev/null | grep -E '/run/edgelet|/var/run/edgelet|/var/lib/edgelet' | awk '{print $3}' | \ + sort -r | while read -r mp; do + [ -n "$mp" ] || continue + umount -l "${mp}" 2>/dev/null || true + done } -install_config_samples() { +remove_init_service() { + _init=$(detect_init 2>/dev/null) || _init="unknown" + if [ -f /etc/systemd/system/edgelet.service ]; then + stop_systemd + elif [ -f /etc/init.d/edgelet ] && [ -x /sbin/procd ]; then + stop_procd + elif [ -f /etc/init.d/edgelet ] && command -v openrc >/dev/null 2>&1; then + stop_openrc + elif [ -f /etc/init/edgelet.conf ]; then + stop_upstart + elif [ -f /etc/init.d/edgelet ]; then + stop_sysvinit + elif [ -d /etc/s6/edgelet ]; then + stop_s6 + elif [ -d /etc/runit/edgelet ] || [ -L /var/service/edgelet ]; then + stop_runit + elif [ "$_init" != "" ] && [ "$_init" != "unknown" ]; then + case "$_init" in + systemd) stop_systemd ;; + openrc) stop_openrc ;; + procd) stop_procd ;; + sysvinit) stop_sysvinit ;; + upstart) stop_upstart ;; + s6) stop_s6 ;; + runit) stop_runit ;; + *) stop_fallback ;; + esac + else + stop_fallback + fi +} + +remove_init_service +lazy_umount_edgelet + +rm -f "$BINARY_PATH" +info "Binary removed." + +case "$OS" in + linux) + rm -rf /usr/libexec/edgelet + info "Init helpers removed from /usr/libexec/edgelet/" + ;; +esac + +rm -rf "$SHARE_DIR" +info "Bundled scripts removed from ${SHARE_DIR}/" + +if [ "${REMOVE_DATA}" = "true" ]; then + info "Removing agent data directories..." + lazy_umount_edgelet + case "$OS" in + linux) + rm -rf /var/lib/edgelet + rm -rf /var/lib/edgelet-containerd + rm -rf /run/edgelet + rm -rf /var/log/edgelet + rm -rf /var/backups/edgelet + rm -rf /etc/edgelet + ;; + darwin) + rm -rf /var/lib/edgelet + rm -rf /var/run/edgelet + rm -rf /var/log/edgelet + rm -rf /var/backups/edgelet + rm -rf /etc/edgelet + ;; + windows) + _pd=$(windows_program_data_edgelet) + rm -rf "${_pd}/data" + rm -rf "${_pd}/config" + rm -rf "${_pd}/run" + rm -rf "${_pd}/log" + rm -rf "${_pd}/scripts" + rm -rf "${_pd}" + ;; + esac + info "Data, backups, and configuration removed." +else + info "Data directories preserved (use --remove-data to remove):" + case "$OS" in + linux) + info " /var/lib/edgelet" + info " /var/lib/edgelet-containerd" + info " /run/edgelet" + info " /var/log/edgelet" + info " /var/backups/edgelet" + info " /etc/edgelet" + ;; + darwin) + info " /var/lib/edgelet" + info " /var/run/edgelet" + info " /var/log/edgelet" + info " /var/backups/edgelet" + info " /etc/edgelet" + ;; + windows) + _pd=$(windows_program_data_edgelet) + info " ${_pd}/data" + info " ${_pd}/config" + info " ${_pd}/run" + info " ${_pd}/log" + ;; + esac +fi + +info "" +info "Edgelet has been uninstalled." +EDGELET_UNINSTALL_EOF + chmod 755 "$_dest" +} +# ASSEMBLE:UNINSTALL_END + +# ASSEMBLE:INSTALL_SELF_BEGIN +# GENERATED by scripts/assemble-install.sh — DO NOT EDIT +# Authoring input: install.sh (excluding INSTALL_SELF region) + +write_embedded_install() { + _dest="$1" + cat > "$_dest" << 'EDGELET_INSTALL_SELF_EOF' +#!/bin/sh +# install.sh — Edgelet greenfield installer (binary-only, multi-OS) +# +# Usage: +# curl -fsSL .../install.sh | sudo sh -s -- --version=vX.Y.Z +# sudo ./install.sh --bin-path=build/edgelet-linux-amd64 --version=dev +# sudo ./install.sh --airgap --bin-path=./edgelet-linux-amd64 --expected-sha256=... +# +# Options: +# --version=VERSION Release tag (default: latest) +# --arch=ARCH Override arch (amd64, arm64, arm, riscv64) +# --container-engine=ENGINE edgelet, docker, or podman +# (linux default: edgelet; darwin/windows: docker) +# --airgap Do not download; use --bin-path +# --bin-path=PATH Local edgelet binary +# --checksum-path=PATH SHA256SUMS manifest for verification +# --expected-sha256=HASH Verify local binary SHA256 +# --upgrade / --rollback In-place thin-binary OTA (linux) +# --force-config Replace config from sample +# --with-sample-ca Install sample controller CA if cert missing +# --help, -h Show options and exit +# +# Environment: EDGELET_VERSION, EDGELET_GITHUB_REPO +# +# Provision after install: edgelet provision (not install.sh flags) + +set -e + +die() { echo "ERROR: $1" >&2; exit 1; } +info() { echo ">>> $1"; } + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) + +# ── argument parsing ────────────────────────────────────────────────────────── +EDGELET_VERSION="${EDGELET_VERSION:-latest}" +CONTAINER_ENGINE="" +ACTION="install" +AIRGAP=false +BIN_PATH="" +FORCE_CONFIG=false +WITH_SAMPLE_CA=false +ARCH_OVERRIDE="" +CHECKSUM_FILE="" +EXPECTED_SHA256="" + +for arg in "$@"; do + case "${arg}" in + --version=*) EDGELET_VERSION="${arg#*=}" ;; + --arch=*) ARCH_OVERRIDE="${arg#*=}" ;; + --container-engine=*) CONTAINER_ENGINE="${arg#*=}" ;; + --bin-path=*) BIN_PATH="${arg#*=}" ;; + --checksum-path=*) CHECKSUM_FILE="${arg#*=}" ;; + --expected-sha256=*) EXPECTED_SHA256="${arg#*=}" ;; + --airgap) AIRGAP=true ;; + --upgrade) ACTION="upgrade" ;; + --rollback) ACTION="rollback" ;; + --force-config) FORCE_CONFIG=true ;; + --with-sample-ca) WITH_SAMPLE_CA=true ;; + --help|-h) + cat </dev/null 2>&1; then + return 0 + fi + if [ -f /etc/inittab ] && grep -q '/sbin/openrc' /etc/inittab 2>/dev/null; then + return 0 + fi + _init="$(readlink -f /sbin/init 2>/dev/null || readlink /sbin/init 2>/dev/null || true)" + case "${_init}" in + *openrc*) return 0 ;; + esac + return 1 +} + +procd_is_openwrt() { + [ -x /sbin/procd ] && [ -f /etc/rc.common ] || return 1 + return 0 +} + +detect_init() { + if command -v systemctl >/dev/null 2>&1 && [ -d /etc/systemd/system ]; then + echo "systemd" + return 0 + fi + if procd_is_openwrt; then + echo "procd" + return 0 + fi + if openrc_is_pid1 && { + command -v openrc >/dev/null 2>&1 \ + || [ -x /sbin/openrc-run ] \ + || [ -f /sbin/openrc ] + }; then + echo "openrc" + return 0 + fi + if command -v initctl >/dev/null 2>&1 && [ -d /etc/init ]; then + echo "upstart" + return 0 + fi + if [ -d /etc/s6 ] || command -v s6-svc >/dev/null 2>&1; then + echo "s6" + return 0 + fi + if command -v runsvdir >/dev/null 2>&1 || [ -d /etc/runit ]; then + echo "runit" + return 0 + fi + if [ -f /etc/inittab ] || command -v update-rc.d >/dev/null 2>&1 || command -v chkconfig >/dev/null 2>&1; then + echo "sysvinit" + return 0 + fi + echo "unknown" +} + +EDGELET_LIBEXEC="/usr/libexec/edgelet" + +init_packaging_root() { + if [ -n "${EDGELET_INIT_DIR}" ] && [ -d "${EDGELET_INIT_DIR}" ]; then + echo "${EDGELET_INIT_DIR}" + return 0 + fi + echo "" +} + +install_shutdown_helper() { + mkdir -p "${EDGELET_LIBEXEC}" + cat > "/usr/libexec/edgelet/edgelet-shutdown" << 'EDGELET_SHUTDOWN_EOF' +#!/bin/sh +# edgelet-shutdown — shared control-plane stop entry for all init systems (Plan 10). +# Plan 11: v1 default skips MS drain on control stop (shutdownPolicy=leave-running for docker/podman; +# embedded split uses attach-only). shutdownGracePeriodSeconds applies to optional maintenance drain +# and data-plane (edgelet-containerd) stop — not control-plane MS drain by default. +set -e +EDGELET="${EDGELET_BIN:-/usr/local/bin/edgelet}" +exec "${EDGELET}" shutdown "$@" +EDGELET_SHUTDOWN_EOF + chmod 755 "/usr/libexec/edgelet/edgelet-shutdown" +} +write_systemd_edgelet_service() { + cat > "/etc/systemd/system/edgelet.service" << 'EDGELET_SYSTEMD_EDGELET_SERVICE_EOF' +[Unit] +Description=Edgelet daemon (control plane) +Documentation=https://github.com/eclipse-iofog/edgelet +Wants=network-online.target +After=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=20 + +[Service] +Type=simple +ExecStartPre=/bin/sh -c 'mountpoint -q /sys/fs/bpf || mount -t bpf bpf /sys/fs/bpf 2>/dev/null || true' +ExecStart=/usr/local/bin/edgelet daemon +ExecStop=/usr/libexec/edgelet/edgelet-shutdown +Restart=always +RestartSec=2s +# Default 120s = shutdownGracePeriodSeconds (90) + 30s buffer; edgelet-engine drop-in may override. +TimeoutStopSec=120s +KillMode=process +Delegate=yes +DelegateSubgroup=supervisor +KillSignal=SIGTERM +SendSIGKILL=yes +User=root +StandardOutput=journal +StandardError=journal +SyslogIdentifier=edgelet + +# Embedded engine needs host paths under /etc/cni, /run, /var, /opt (monolithic unit). +# Tighten on edgelet-containerd.service after Plan 11 data-plane split. +NoNewPrivileges=no + +# Resource limits +LimitNOFILE=65536 +LimitNPROC=4096 + +[Install] +WantedBy=multi-user.target +EDGELET_SYSTEMD_EDGELET_SERVICE_EOF + chmod 644 "/etc/systemd/system/edgelet.service" +} +write_systemd_edgelet_containerd_service() { + cat > "/etc/systemd/system/edgelet-containerd.service" << 'EDGELET_SYSTEMD_EDGELET_CONTAINERD_SERVICE_EOF' +# Edgelet embedded containerd (data plane) — Plan 11 workload continuity +[Unit] +Description=Edgelet embedded containerd (data plane) +Documentation=https://github.com/eclipse-iofog/edgelet +Before=edgelet.service +After=network-online.target +Wants=network-online.target +# Intentionally NOT PartOf=edgelet.service: control restart/stop must not +# stop the data plane (Plan 11 attach-only). Full teardown: stop both units. + +[Service] +Type=simple +ExecStartPre=/bin/sh -c 'mountpoint -q /sys/fs/bpf || mount -t bpf bpf /sys/fs/bpf 2>/dev/null || true' +ExecStart=/usr/local/bin/edgelet runtime-bootstrap +Restart=always +RestartSec=2s +# Data-plane stop: drain MS via runtime-bootstrap SIGTERM handler + shutdownGracePeriodSeconds +TimeoutStopSec=120s +KillMode=process +KillSignal=SIGTERM +SendSIGKILL=yes +User=root +StandardOutput=journal +StandardError=journal +SyslogIdentifier=edgelet-containerd +Delegate=yes +DelegateSubgroup=containerd +NoNewPrivileges=no +LimitNOFILE=65536 +LimitNPROC=4096 + +[Install] +WantedBy=multi-user.target +EDGELET_SYSTEMD_EDGELET_CONTAINERD_SERVICE_EOF + chmod 644 "/etc/systemd/system/edgelet-containerd.service" +} +write_systemd_dropin_docker() { + cat > "/etc/systemd/system/edgelet.service.d/docker.conf" << 'EDGELET_SYSTEMD_DROPIN_DOCKER_EOF' +# Engine ordering drop-in — docker (Plan 10). Installed when containerEngine=docker. +[Unit] +After=network-online.target docker.service +Wants=docker.service +EDGELET_SYSTEMD_DROPIN_DOCKER_EOF + chmod 644 "/etc/systemd/system/edgelet.service.d/docker.conf" +} +write_systemd_dropin_podman() { + cat > "/etc/systemd/system/edgelet.service.d/podman.conf" << 'EDGELET_SYSTEMD_DROPIN_PODMAN_EOF' +# Engine ordering drop-in — podman (Plan 10). Installed when containerEngine=podman. +[Unit] +After=network-online.target podman.socket +Wants=podman.socket +EDGELET_SYSTEMD_DROPIN_PODMAN_EOF + chmod 644 "/etc/systemd/system/edgelet.service.d/podman.conf" +} +write_systemd_dropin_edgelet() { + cat > "/etc/systemd/system/edgelet.service.d/edgelet.conf" << 'EDGELET_SYSTEMD_DROPIN_EDGELET_EOF' +# Engine ordering drop-in — embedded edgelet (Plan 11). Installed when containerEngine=edgelet. +[Unit] +Wants=edgelet-containerd.service +After=network-online.target edgelet-containerd.service + +[Service] +Environment=EDGELET_RUNTIME_SPLIT=1 +# Control-plane stop: default leave-running (skip MS drain). Grace + buffer matches shutdownGracePeriodSeconds default 90. +TimeoutStopSec=120s +EDGELET_SYSTEMD_DROPIN_EDGELET_EOF + chmod 644 "/etc/systemd/system/edgelet.service.d/edgelet.conf" +} +write_openrc_edgelet_init() { + cat > "/etc/init.d/edgelet" << 'EDGELET_OPENRC_EDGELET_INIT_EOF' +#!/sbin/openrc-run + +name="edgelet" +description="Edgelet daemon (control plane)" +command="/usr/local/bin/edgelet" +command_args="daemon" +supervisor="supervise-daemon" +respawn_delay=2 +respawn_max=0 +respawn_period=3600 +command_user="root" +pidfile="/run/${RC_SVCNAME}.pid" +output_log="/var/log/edgelet/daemon.log" +error_log="/var/log/edgelet/daemon.log" +shutdown="/usr/libexec/edgelet/edgelet-shutdown" + +depend() { + need net + after firewall +%%EDGELET_ENGINE_NEED%% +} + +wait_containerd_attach_socket() { + _timeout="${EDGELET_ATTACH_WAIT_SEC:-120}" + _elapsed=0 + _sock="/run/edgelet/containerd.sock" + while [ "${_elapsed}" -lt "${_timeout}" ]; do + if [ -S "${_sock}" ] || [ -S /var/run/edgelet/containerd.sock ]; then + [ -S "${_sock}" ] || _sock="/var/run/edgelet/containerd.sock" + _ctr="" + if [ -x /var/lib/edgelet/data/current/bin/ctr ]; then + _ctr=/var/lib/edgelet/data/current/bin/ctr + elif command -v ctr >/dev/null 2>&1; then + _ctr=ctr + fi + if [ -z "${_ctr}" ] || "${_ctr}" --address "${_sock}" version >/dev/null 2>&1; then + return 0 + fi + fi + sleep 2 + _elapsed=$(( _elapsed + 2 )) + done + ewarn "data-plane containerd socket not ready after ${_timeout}s" + return 1 +} + +start_pre() { + /usr/local/bin/edgelet cgroup-preflight || return $? + if [ -f /etc/init.d/edgelet-containerd ]; then + export EDGELET_RUNTIME_SPLIT=1 + wait_containerd_attach_socket || return $? + fi +} + +stop_pre() { + if [ -f /etc/init.d/edgelet-containerd ]; then + export EDGELET_RUNTIME_SPLIT=1 + fi +} + +stop() { + ebegin "Stopping ${RC_SVCNAME}" + if ! "${shutdown}"; then + # Fallback when EdgeletAPI is down: single TERM/KILL, no SSD --retry (hangs after graceful stop). + if [ -f "${pidfile}" ]; then + _pid=$(cat "${pidfile}" 2>/dev/null || true) + if [ -n "${_pid}" ] && kill -0 "${_pid}" 2>/dev/null; then + kill -TERM "${_pid}" 2>/dev/null || true + sleep 2 + kill -KILL "${_pid}" 2>/dev/null || true + fi + fi + rm -f "${pidfile}" /run/edgelet/edgelet.pid 2>/dev/null || true + eend 1 + return 1 + fi + rm -f "${pidfile}" /run/edgelet/edgelet.pid 2>/dev/null || true + # Plan 11 split: data-plane containerd is edgelet-containerd service — do not reap child here. + if [ ! -f /etc/init.d/edgelet-containerd ]; then + _pids=$(pgrep -f edgelet-containerd-child 2>/dev/null || true) + for _p in ${_pids}; do kill -TERM "${_p}" 2>/dev/null || true; done + sleep 2 + _pids=$(pgrep -f edgelet-containerd-child 2>/dev/null || true) + for _p in ${_pids}; do kill -KILL "${_p}" 2>/dev/null || true; done + fi + eend 0 +} +EDGELET_OPENRC_EDGELET_INIT_EOF + chmod 755 "/etc/init.d/edgelet" +} +write_openrc_edgelet_containerd_init() { + cat > "/etc/init.d/edgelet-containerd" << 'EDGELET_OPENRC_EDGELET_CONTAINERD_INIT_EOF' +#!/sbin/openrc-run + +# Edgelet embedded containerd (data plane) — Plan 11 workload continuity +name="edgelet-containerd" +description="Edgelet embedded containerd (data plane)" +command="/usr/local/bin/edgelet" +command_args="runtime-bootstrap" +command_background=true +command_user="root" +pidfile="/run/${RC_SVCNAME}.pid" +output_log="/var/log/edgelet/containerd.log" +error_log="/var/log/edgelet/containerd.log" + +depend() { + need net + need edgelet-cgroup-prep + before edgelet +} + +start_pre() { + # C3: light preflight only; primary cgroup bootstrap is in runtime-bootstrap (C1). + mountpoint -q /sys/fs/bpf 2>/dev/null || mount -t bpf bpf /sys/fs/bpf 2>/dev/null || true + /usr/local/bin/edgelet cgroup-preflight || return $? +} + +start_post() { + # Block dependency satisfaction until CRI socket answers (Plan 11 split). + _timeout="${EDGELET_CONTAINERD_READY_SEC:-120}" + _elapsed=0 + _sock="/run/edgelet/containerd.sock" + while [ "${_elapsed}" -lt "${_timeout}" ]; do + if [ -S "${_sock}" ] || [ -S /var/run/edgelet/containerd.sock ]; then + [ -S "${_sock}" ] || _sock="/var/run/edgelet/containerd.sock" + _ctr="" + if [ -x /var/lib/edgelet/data/current/bin/ctr ]; then + _ctr=/var/lib/edgelet/data/current/bin/ctr + elif command -v ctr >/dev/null 2>&1; then + _ctr=ctr + fi + if [ -z "${_ctr}" ] || "${_ctr}" --address "${_sock}" version >/dev/null 2>&1; then + return 0 + fi + fi + sleep 2 + _elapsed=$(( _elapsed + 2 )) + done + ewarn "containerd socket not ready after ${_timeout}s" + return 1 +} + +stop() { + ebegin "Stopping ${RC_SVCNAME}" + if [ -f "${pidfile}" ]; then + _pid=$(cat "${pidfile}" 2>/dev/null || true) + if [ -n "${_pid}" ] && kill -0 "${_pid}" 2>/dev/null; then + kill -TERM "${_pid}" 2>/dev/null || true + _grace="${EDGELET_CONTAINERD_STOP_SEC:-30}" + _elapsed=0 + while kill -0 "${_pid}" 2>/dev/null; do + if [ "${_elapsed}" -ge "${_grace}" ]; then + kill -KILL "${_pid}" 2>/dev/null || true + break + fi + sleep 2 + _elapsed=$(( _elapsed + 2 )) + done + fi + fi + rm -f "${pidfile}" 2>/dev/null || true + eend 0 +} +EDGELET_OPENRC_EDGELET_CONTAINERD_INIT_EOF + chmod 755 "/etc/init.d/edgelet-containerd" +} +write_openrc_edgelet_cgroup_prep_init() { + cat > "/etc/init.d/edgelet-cgroup-prep" << 'EDGELET_OPENRC_EDGELET_CGROUP_PREP_INIT_EOF' +#!/sbin/openrc-run + +# Early cgroup v2 delegation for LXC/VM machine roots (OrbStack Alpine, etc.). +# Moby/dind-style reparent before enabling subtree_control on root and /.lxc. +name="edgelet-cgroup-prep" +description="Edgelet cgroup delegation prep (machine root)" + +depend() { + before edgelet-containerd edgelet +} + +cgroup_delegate_controllers() { + _dir="$1" + _ctrl="${_dir}/cgroup.controllers" + _sub="${_dir}/cgroup.subtree_control" + [ -f "${_ctrl}" ] || return 0 + for _c in cpu memory pids; do + grep -qw "${_c}" "${_ctrl}" 2>/dev/null || continue + grep -qw "${_c}" "${_sub}" 2>/dev/null && continue + echo "+${_c}" >> "${_sub}" 2>/dev/null || true + done +} + +cgroup_reparent_procs() { + _from="$1" + _to="$2" + [ -f "${_from}/cgroup.procs" ] || return 0 + [ -s "${_from}/cgroup.procs" ] || return 0 + mkdir -p "${_to}" + xargs -rn1 < "${_from}/cgroup.procs" > "${_to}/cgroup.procs" 2>/dev/null || true +} + +start() { + # No-op on bare-metal / systemd VM layouts without LXC machine cgroup. + [ -d /sys/fs/cgroup/.lxc ] || return 0 + + ebegin "Preparing cgroup delegation for machine root" + _cg="/sys/fs/cgroup" + + cgroup_reparent_procs "${_cg}" "${_cg}/init" + cgroup_delegate_controllers "${_cg}" + + cgroup_reparent_procs "${_cg}/.lxc" "${_cg}/.lxc/init" + cgroup_delegate_controllers "${_cg}/.lxc" + + eend 0 +} + +stop() { + return 0 +} +EDGELET_OPENRC_EDGELET_CGROUP_PREP_INIT_EOF + chmod 755 "/etc/init.d/edgelet-cgroup-prep" +} +write_procd_edgelet() { + cat > "/etc/init.d/edgelet" << 'EDGELET_PROCD_EDGELET_EOF' +#!/bin/sh /etc/rc.common +# Edgelet control plane (OpenWrt procd) + +START=99 +STOP=10 +USE_PROCD=1 + +start_service() { + /usr/local/bin/edgelet cgroup-preflight || return $? + + procd_open_instance + procd_set_param command /usr/local/bin/edgelet + procd_append_param command daemon + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +stop_service() { + /usr/libexec/edgelet/edgelet-shutdown +} +EDGELET_PROCD_EDGELET_EOF + chmod 755 "/etc/init.d/edgelet" +} +write_sysvinit_edgelet() { + cat > "/etc/init.d/edgelet" << 'EDGELET_SYSVINIT_EDGELET_EOF' +#!/bin/sh +### BEGIN INIT INFO +# Provides: edgelet +# Required-Start: $network $remote_fs +# Required-Stop: $network $remote_fs +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Edgelet daemon +### END INIT INFO + +DAEMON=/usr/local/bin/edgelet +SHUTDOWN=/usr/libexec/edgelet/edgelet-shutdown +PIDFILE=/var/run/edgelet.pid +LOGFILE=/var/log/edgelet/daemon.log +KILLTIMEOUT=30 + +. /lib/lsb/init-functions + +preflight() { + "${DAEMON}" cgroup-preflight +} + +case "$1" in + start) + log_daemon_msg "Starting edgelet" + preflight || exit $? + start-stop-daemon --start --background --make-pidfile --pidfile "$PIDFILE" \ + --exec "$DAEMON" -- daemon >>"$LOGFILE" 2>&1 + log_end_msg $? + ;; + stop) + log_daemon_msg "Stopping edgelet" + if [ -x "$SHUTDOWN" ]; then + "$SHUTDOWN" && log_end_msg 0 && exit 0 + fi + start-stop-daemon --stop --pidfile "$PIDFILE" --retry TERM/${KILLTIMEOUT}/KILL/5 + log_end_msg $? + ;; + restart|force-reload) + $0 stop + $0 start + ;; + status) + status_of_proc -p "$PIDFILE" "$DAEMON" edgelet + ;; + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac + +exit 0 +EDGELET_SYSVINIT_EDGELET_EOF + chmod 755 "/etc/init.d/edgelet" +} +write_upstart_edgelet() { + cat > "/etc/init/edgelet.conf" << 'EDGELET_UPSTART_EDGELET_EOF' +description "Edgelet daemon (control plane)" +author "Datasance" + +start on runlevel [2345] +stop on runlevel [!2345] + +respawn +respawn limit 20 300 + +pre-start script + /usr/local/bin/edgelet cgroup-preflight || exit $? +end script + +exec /usr/local/bin/edgelet daemon >> /var/log/edgelet/daemon.log 2>&1 + +post-stop script + /usr/libexec/edgelet/edgelet-shutdown || true +end script +EDGELET_UPSTART_EDGELET_EOF + chmod 644 "/etc/init/edgelet.conf" +} +write_s6_run() { + cat > "/etc/s6/edgelet/run" << 'EDGELET_S6_RUN_EOF' +#!/bin/sh +exec 2>&1 +/usr/local/bin/edgelet cgroup-preflight || exit $? +exec /usr/local/bin/edgelet daemon +EDGELET_S6_RUN_EOF + chmod 755 "/etc/s6/edgelet/run" +} +write_s6_finish() { + cat > "/etc/s6/edgelet/finish" << 'EDGELET_S6_FINISH_EOF' +#!/bin/sh +/usr/libexec/edgelet/edgelet-shutdown 2>/dev/null || true +exit "${1:-0}" +EDGELET_S6_FINISH_EOF + chmod 755 "/etc/s6/edgelet/finish" +} +write_runit_run() { + cat > "/etc/runit/edgelet/run" << 'EDGELET_RUNIT_RUN_EOF' +#!/bin/sh +LOG=/var/log/edgelet/daemon.log +exec 2>&1 +/usr/local/bin/edgelet cgroup-preflight >>"$LOG" 2>&1 || exit $? +exec /usr/local/bin/edgelet daemon >>"$LOG" 2>&1 +EDGELET_RUNIT_RUN_EOF + chmod 755 "/etc/runit/edgelet/run" +} +write_runit_finish() { + cat > "/etc/runit/edgelet/finish" << 'EDGELET_RUNIT_FINISH_EOF' +#!/bin/sh +/usr/libexec/edgelet/edgelet-shutdown 2>/dev/null || true +exit "${1:-0}" +EDGELET_RUNIT_FINISH_EOF + chmod 755 "/etc/runit/edgelet/finish" +} + +openrc_engine_need_line() { + case "$1" in + docker) printf '%s\n' ' need docker' ;; + podman) printf '%s\n' ' need podman' ;; + edgelet) printf '%s\n' ' need edgelet-containerd' ;; + *) printf '%s\n' '' ;; + esac +} + +apply_openrc_engine_deps() { + _eng="$1" + _dest="$2" + _need="$(openrc_engine_need_line "${_eng}")" + if [ -f "${_dest}" ]; then + # shellcheck disable=SC2016 + awk -v need="${_need}" ' + /%%EDGELET_ENGINE_NEED%%/ { + if (need != "") print need + next + } + { print } + ' "${_dest}" > "${_dest}.tmp" && mv "${_dest}.tmp" "${_dest}" + fi +} + +install_init_helpers() { + install_shutdown_helper +} + +install_systemd_dropin() { + _eng="$1" + _root="$2" + _dropdir="/etc/systemd/system/edgelet.service.d" + mkdir -p "${_dropdir}" + rm -f "${_dropdir}/docker.conf" "${_dropdir}/podman.conf" "${_dropdir}/edgelet.conf" + if [ -n "${_root}" ]; then + case "${_eng}" in + docker) + install -m 644 "${_root}/systemd/edgelet.service.d/docker.conf" "${_dropdir}/docker.conf" + ;; + podman) + install -m 644 "${_root}/systemd/edgelet.service.d/podman.conf" "${_dropdir}/podman.conf" + ;; + edgelet) + install -m 644 "${_root}/systemd/edgelet.service.d/edgelet.conf" "${_dropdir}/edgelet.conf" + systemctl enable edgelet-containerd 2>/dev/null || true + ;; + esac + else + case "${_eng}" in + docker) write_systemd_dropin_docker ;; + podman) write_systemd_dropin_podman ;; + edgelet) + write_systemd_dropin_edgelet + systemctl enable edgelet-containerd 2>/dev/null || true + ;; + esac + fi +} + +install_init_unit() { + _init="$1" + _eng="$2" + _root="$(init_packaging_root)" + mkdir -p /var/log/edgelet + install_init_helpers + + case "${_init}" in + systemd) + mkdir -p /etc/cni/net.d /run/edgelet /run/containerd + chmod 755 /run/edgelet /run/containerd 2>/dev/null || true + if [ -n "${_root}" ]; then + _unit="${_root}/systemd/edgelet.service" + [ -f "$_unit" ] || die "Missing ${_unit}" + install -m 644 "$_unit" /etc/systemd/system/edgelet.service + _containerd="${_root}/systemd/edgelet-containerd.service" + if [ -f "${_containerd}" ]; then + install -m 644 "${_containerd}" /etc/systemd/system/edgelet-containerd.service + fi + else + write_systemd_edgelet_service + write_systemd_edgelet_containerd_service + fi + install_systemd_dropin "${_eng}" "${_root}" + systemctl daemon-reload + if [ "${_eng}" = "edgelet" ]; then + systemctl enable edgelet-containerd 2>/dev/null || true + systemctl start edgelet-containerd 2>/dev/null || true + fi + systemctl enable edgelet + systemctl stop edgelet 2>/dev/null || true + systemctl reset-failed edgelet 2>/dev/null || true + systemctl start edgelet + info "systemd unit edgelet.service installed (engine=${_eng} drop-in)." + ;; + openrc) + if [ -n "${_root}" ]; then + install -m 755 "${_root}/openrc/edgelet.init" /etc/init.d/edgelet + if [ -f "${_root}/openrc/edgelet-cgroup-prep.init" ]; then + install -m 755 "${_root}/openrc/edgelet-cgroup-prep.init" /etc/init.d/edgelet-cgroup-prep + rc-update add edgelet-cgroup-prep sysinit 2>/dev/null || true + fi + if [ -f "${_root}/openrc/edgelet-containerd.init" ]; then + install -m 755 "${_root}/openrc/edgelet-containerd.init" /etc/init.d/edgelet-containerd + fi + else + write_openrc_edgelet_init + write_openrc_edgelet_cgroup_prep_init + write_openrc_edgelet_containerd_init + rc-update add edgelet-cgroup-prep sysinit 2>/dev/null || true + fi + apply_openrc_engine_deps "${_eng}" /etc/init.d/edgelet + chmod 755 /etc/init.d/edgelet + if [ "${_eng}" = "edgelet" ]; then + rc-update add edgelet-containerd default 2>/dev/null || true + rc-service edgelet-containerd start 2>/dev/null || true + fi + rc-update add edgelet default 2>/dev/null || true + rc-service edgelet restart 2>/dev/null || rc-service edgelet start + info "OpenRC service edgelet installed (engine=${_eng})." + ;; + procd) + if [ -n "${_root}" ]; then + install -m 755 "${_root}/procd/edgelet" /etc/init.d/edgelet + else + write_procd_edgelet + fi + /etc/init.d/edgelet enable 2>/dev/null || true + /etc/init.d/edgelet stop 2>/dev/null || true + /etc/init.d/edgelet start + info "procd init script edgelet installed (engine=${_eng})." + ;; + sysvinit) + if [ -n "${_root}" ]; then + install -m 755 "${_root}/sysvinit/edgelet.init" /etc/init.d/edgelet + else + write_sysvinit_edgelet + fi + if command -v update-rc.d >/dev/null 2>&1; then + update-rc.d edgelet defaults 2>/dev/null || true + elif command -v chkconfig >/dev/null 2>&1; then + chkconfig --add edgelet 2>/dev/null || true + fi + /etc/init.d/edgelet restart 2>/dev/null || /etc/init.d/edgelet start + info "SysV init script edgelet installed." + ;; + upstart) + if [ -n "${_root}" ]; then + install -m 644 "${_root}/upstart/edgelet.conf" /etc/init/edgelet.conf + else + write_upstart_edgelet + fi + initctl reload-configuration 2>/dev/null || true + initctl restart edgelet 2>/dev/null || initctl start edgelet + info "Upstart job edgelet installed." + ;; + s6) + mkdir -p /etc/s6/edgelet + if [ -n "${_root}" ]; then + install -m 755 "${_root}/s6/run" /etc/s6/edgelet/run + install -m 755 "${_root}/s6/finish" /etc/s6/edgelet/finish + else + write_s6_run + write_s6_finish + fi + if command -v s6-svc >/dev/null 2>&1 && [ -d /var/run/s6/services ]; then + mkdir -p /var/run/s6/services/edgelet + ln -sf /etc/s6/edgelet /var/run/s6/services/edgelet/supervise 2>/dev/null || true + s6-svc -u /var/run/s6/services/edgelet 2>/dev/null || true + fi + info "s6 service installed under /etc/s6/edgelet (start via your s6 scan)." + ;; + runit) + mkdir -p /etc/runit/edgelet + if [ -n "${_root}" ]; then + install -m 755 "${_root}/runit/run" /etc/runit/edgelet/run + if [ -f "${_root}/runit/finish" ]; then + install -m 755 "${_root}/runit/finish" /etc/runit/edgelet/finish + fi + else + write_runit_run + write_runit_finish + fi + if [ -d /etc/runit ]; then + ln -sf /etc/runit/edgelet /etc/service/edgelet 2>/dev/null || \ + ln -sf /etc/runit/edgelet /var/service/edgelet 2>/dev/null || true + sv restart edgelet 2>/dev/null || sv start edgelet 2>/dev/null || true + fi + info "runit service installed under /etc/runit/edgelet." + ;; + *) + die "No supported init system detected (${_init}). Install systemd, procd, openrc, sysvinit, upstart, s6, or runit." + ;; + esac +} + +stop_edgelet_service() { + _init="${1:-$(detect_init)}" + case "${_init}" in + systemd) systemctl stop edgelet 2>/dev/null || true ;; + openrc) rc-service edgelet stop 2>/dev/null || true ;; + procd) /etc/init.d/edgelet stop 2>/dev/null || true ;; + sysvinit) /etc/init.d/edgelet stop 2>/dev/null || true ;; + upstart) initctl stop edgelet 2>/dev/null || true ;; + s6) s6-svc -d /var/run/s6/services/edgelet 2>/dev/null || true ;; + runit) sv down edgelet 2>/dev/null || true ;; + *) "${EDGELET_LIBEXEC}/edgelet-shutdown" 2>/dev/null || pkill -f "/usr/local/bin/edgelet daemon" 2>/dev/null || true ;; + esac +} + +# ASSEMBLE:EMBEDDED_END + +# ASSEMBLE:UNINSTALL_BEGIN +# GENERATED by scripts/assemble-install.sh — DO NOT EDIT +# Authoring input: uninstall.sh + +write_embedded_uninstall() { + _dest="$1" + cat > "$_dest" << 'EDGELET_UNINSTALL_EOF' +#!/bin/sh +# uninstall.sh — Edgelet uninstaller +# +# Usage: +# sudo sh uninstall.sh [--remove-data] +# +# --remove-data also removes config, data, runtime, logs, and backup directories + +set -e + +die() { echo "ERROR: $1" >&2; exit 1; } +info() { echo ">>> $1"; } + +detect_os() { + _u=$(uname -s) + case "${_u}" in + Linux) echo "linux" ;; + Darwin) echo "darwin" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) echo "windows" ;; + *) die "Unsupported OS: ${_u}" ;; + esac +} + +windows_program_data_edgelet() { + echo "${ProgramData:-/c/ProgramData}/Edgelet" +} + +share_dir_for_os() { + case "$1" in + linux) echo "/usr/share/edgelet" ;; + darwin) echo "/usr/local/share/edgelet" ;; + windows) echo "$(windows_program_data_edgelet)/scripts" ;; + *) die "Unsupported OS: $1" ;; + esac +} + +binary_path_for_os() { + case "$1" in + linux|darwin) echo "/usr/local/bin/edgelet" ;; + windows) + _pf="${ProgramFiles:-/c/Program Files}" + echo "${_pf}/Edgelet/edgelet.exe" + ;; + *) die "Unsupported OS: $1" ;; + esac +} + +# OpenRC ships /sbin/openrc-run on Alpine even when PID 1 is busybox (Lima +# template:alpine). Require a running OpenRC supervisor, not merely openrc-run. +openrc_is_pid1() { + [ -x /sbin/openrc-run ] || return 1 + if rc-status -s >/dev/null 2>&1; then + return 0 + fi + if [ -f /etc/inittab ] && grep -q '/sbin/openrc' /etc/inittab 2>/dev/null; then + return 0 + fi + _init="$(readlink -f /sbin/init 2>/dev/null || readlink /sbin/init 2>/dev/null || true)" + case "${_init}" in + *openrc*) return 0 ;; + esac + return 1 +} + +procd_is_openwrt() { + [ -x /sbin/procd ] && [ -f /etc/rc.common ] || return 1 + return 0 +} + +detect_init() { + if command -v systemctl >/dev/null 2>&1 && [ -d /etc/systemd/system ]; then + echo "systemd" + return 0 + fi + if procd_is_openwrt; then + echo "procd" + return 0 + fi + if openrc_is_pid1 && { + command -v openrc >/dev/null 2>&1 \ + || [ -x /sbin/openrc-run ] \ + || [ -f /sbin/openrc ] + }; then + echo "openrc" + return 0 + fi + if command -v initctl >/dev/null 2>&1 && [ -d /etc/init ]; then + echo "upstart" + return 0 + fi + if [ -d /etc/s6 ] || command -v s6-svc >/dev/null 2>&1; then + echo "s6" + return 0 + fi + if command -v runsvdir >/dev/null 2>&1 || [ -d /etc/runit ]; then + echo "runit" + return 0 + fi + if [ -f /etc/inittab ] || command -v update-rc.d >/dev/null 2>&1 || command -v chkconfig >/dev/null 2>&1; then + echo "sysvinit" + return 0 + fi + echo "unknown" +} + +[ "$(id -u)" -eq 0 ] || die "Must be run as root. Try: sudo $0 $*" + +OS=$(detect_os) +SHARE_DIR=$(share_dir_for_os "$OS") +BINARY_PATH=$(binary_path_for_os "$OS") + +REMOVE_DATA=false +for arg in "$@"; do + case "${arg}" in + --remove-data) REMOVE_DATA=true ;; + --help|-h) + echo "Usage: $0 [--remove-data]" + echo "" + echo " --remove-data also delete config, data, and backup directories" + exit 0 ;; + *) die "Unknown option: ${arg}" ;; + esac +done + +stop_systemd() { + if systemctl is-active --quiet edgelet-containerd 2>/dev/null; then + systemctl stop edgelet-containerd + fi + if systemctl is-active --quiet edgelet 2>/dev/null; then + systemctl stop edgelet + fi + systemctl disable edgelet edgelet-containerd 2>/dev/null || true + rm -f /etc/systemd/system/edgelet.service + rm -f /etc/systemd/system/edgelet-containerd.service + rm -rf /etc/systemd/system/edgelet.service.d + systemctl daemon-reload 2>/dev/null || true + info "systemd service removed." +} + +stop_procd() { + /etc/init.d/edgelet stop 2>/dev/null || true + /etc/init.d/edgelet disable 2>/dev/null || true + rm -f /etc/init.d/edgelet + info "procd init script removed." +} + +stop_openrc() { + rc-service edgelet-containerd stop 2>/dev/null || true + rc-service edgelet stop 2>/dev/null || true + rc-update del edgelet-containerd default 2>/dev/null || true + rc-update del edgelet default 2>/dev/null || true + rm -f /etc/init.d/edgelet /etc/init.d/edgelet-containerd + info "OpenRC service removed." +} + +stop_sysvinit() { + /etc/init.d/edgelet stop 2>/dev/null || true + update-rc.d -f edgelet remove 2>/dev/null || true + chkconfig --del edgelet 2>/dev/null || true + rm -f /etc/init.d/edgelet + info "SysV init service removed." +} + +stop_upstart() { + initctl stop edgelet 2>/dev/null || true + rm -f /etc/init/edgelet.conf + initctl reload-configuration 2>/dev/null || true + info "Upstart service removed." +} + +stop_s6() { + s6-svc -d /var/run/s6/services/edgelet 2>/dev/null || true + rm -rf /var/run/s6/services/edgelet + rm -rf /etc/s6/edgelet + info "s6 service removed." +} + +stop_runit() { + sv down edgelet 2>/dev/null || true + rm -f /etc/service/edgelet /var/service/edgelet /service/edgelet 2>/dev/null || true + rm -rf /etc/runit/edgelet + info "runit service removed." +} + +stop_fallback() { + pkill -f "/usr/local/bin/edgelet daemon" 2>/dev/null || true + pkill -f "edgelet daemon" 2>/dev/null || true + info "Background edgelet processes stopped (if any)." +} + +lazy_umount_edgelet() { + if ! command -v umount >/dev/null 2>&1; then + return 0 + fi + mount 2>/dev/null | grep -E '/run/edgelet|/var/run/edgelet|/var/lib/edgelet' | awk '{print $3}' | \ + sort -r | while read -r mp; do + [ -n "$mp" ] || continue + umount -l "${mp}" 2>/dev/null || true + done +} + +remove_init_service() { + _init=$(detect_init 2>/dev/null) || _init="unknown" + if [ -f /etc/systemd/system/edgelet.service ]; then + stop_systemd + elif [ -f /etc/init.d/edgelet ] && [ -x /sbin/procd ]; then + stop_procd + elif [ -f /etc/init.d/edgelet ] && command -v openrc >/dev/null 2>&1; then + stop_openrc + elif [ -f /etc/init/edgelet.conf ]; then + stop_upstart + elif [ -f /etc/init.d/edgelet ]; then + stop_sysvinit + elif [ -d /etc/s6/edgelet ]; then + stop_s6 + elif [ -d /etc/runit/edgelet ] || [ -L /var/service/edgelet ]; then + stop_runit + elif [ "$_init" != "" ] && [ "$_init" != "unknown" ]; then + case "$_init" in + systemd) stop_systemd ;; + openrc) stop_openrc ;; + procd) stop_procd ;; + sysvinit) stop_sysvinit ;; + upstart) stop_upstart ;; + s6) stop_s6 ;; + runit) stop_runit ;; + *) stop_fallback ;; + esac + else + stop_fallback + fi +} + +remove_init_service +lazy_umount_edgelet + +rm -f "$BINARY_PATH" +info "Binary removed." + +case "$OS" in + linux) + rm -rf /usr/libexec/edgelet + info "Init helpers removed from /usr/libexec/edgelet/" + ;; +esac + +rm -rf "$SHARE_DIR" +info "Bundled scripts removed from ${SHARE_DIR}/" + +if [ "${REMOVE_DATA}" = "true" ]; then + info "Removing agent data directories..." + lazy_umount_edgelet + case "$OS" in + linux) + rm -rf /var/lib/edgelet + rm -rf /var/lib/edgelet-containerd + rm -rf /run/edgelet + rm -rf /var/log/edgelet + rm -rf /var/backups/edgelet + rm -rf /etc/edgelet + ;; + darwin) + rm -rf /var/lib/edgelet + rm -rf /var/run/edgelet + rm -rf /var/log/edgelet + rm -rf /var/backups/edgelet + rm -rf /etc/edgelet + ;; + windows) + _pd=$(windows_program_data_edgelet) + rm -rf "${_pd}/data" + rm -rf "${_pd}/config" + rm -rf "${_pd}/run" + rm -rf "${_pd}/log" + rm -rf "${_pd}/scripts" + rm -rf "${_pd}" + ;; + esac + info "Data, backups, and configuration removed." +else + info "Data directories preserved (use --remove-data to remove):" + case "$OS" in + linux) + info " /var/lib/edgelet" + info " /var/lib/edgelet-containerd" + info " /run/edgelet" + info " /var/log/edgelet" + info " /var/backups/edgelet" + info " /etc/edgelet" + ;; + darwin) + info " /var/lib/edgelet" + info " /var/run/edgelet" + info " /var/log/edgelet" + info " /var/backups/edgelet" + info " /etc/edgelet" + ;; + windows) + _pd=$(windows_program_data_edgelet) + info " ${_pd}/data" + info " ${_pd}/config" + info " ${_pd}/run" + info " ${_pd}/log" + ;; + esac +fi + +info "" +info "Edgelet has been uninstalled." +EDGELET_UNINSTALL_EOF + chmod 755 "$_dest" +} +# ASSEMBLE:UNINSTALL_END + + +BACKUP_DIR="/var/backups/edgelet" +CACHE_DIR="${BACKUP_DIR}/cache" +RECEIPT_FILE="${BACKUP_DIR}/install-receipt" +PREVIOUS_FILE="${BACKUP_DIR}/previous-release" +GITHUB_REPO="${EDGELET_GITHUB_REPO:-eclipse-iofog/edgelet}" +SHARE_DIR="/usr/share/edgelet" +UNIT_NAME="edgelet" +CONFIG_DIR="/etc/edgelet" +CONFIG_FILE="${CONFIG_DIR}/config.yaml" +CERT_FILE="${CONFIG_DIR}/cert.crt" + +windows_program_data_edgelet() { + echo "${ProgramData:-/c/ProgramData}/Edgelet" +} + +init_platform_paths() { + _os="$1" + case "${_os}" in + linux) + SHARE_DIR="/usr/share/edgelet" + CONFIG_DIR="/etc/edgelet" + ;; + darwin) + SHARE_DIR="/usr/local/share/edgelet" + CONFIG_DIR="/etc/edgelet" + ;; + windows) + _pd=$(windows_program_data_edgelet) + SHARE_DIR="${_pd}/scripts" + CONFIG_DIR="${_pd}/config" + ;; + *) die "Unsupported OS for platform paths: ${_os}" ;; + esac + CONFIG_FILE="${CONFIG_DIR}/config.yaml" + CERT_FILE="${CONFIG_DIR}/cert.crt" +} + +detect_os() { + _u=$(uname -s) + case "${_u}" in + Linux) echo "linux" ;; + Darwin) echo "darwin" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) echo "windows" ;; + *) die "Unsupported OS: ${_u}" ;; + esac +} + +detect_arch() { + MACHINE=$(uname -m) + case "${MACHINE}" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + armv7l|armv6l|arm) echo "arm" ;; + riscv64) echo "riscv64" ;; + *) die "Unsupported architecture: ${MACHINE}" ;; + esac +} + +require_root() { + OS=$(detect_os) + if [ "$OS" = "windows" ]; then + return 0 + fi + [ "$(id -u)" -eq 0 ] || die "This script must be run as root. Try: sudo $0 $*" +} + +binary_basename() { + _os="$1" _arch="$2" + case "${_os}" in + windows) echo "edgelet-${_os}-${_arch}.exe" ;; + *) echo "edgelet-${_os}-${_arch}" ;; + esac +} + +binary_install_path() { + _os="$1" + case "${_os}" in + linux|darwin) echo "/usr/local/bin/edgelet" ;; + windows) + _pf="${ProgramFiles:-/c/Program Files}" + echo "${_pf}/Edgelet/edgelet.exe" + ;; + *) die "Unsupported OS for binary path" ;; + esac +} + +release_download_url() { + _ver="$1" _os="$2" _arch="$3" + _base="https://github.com/${GITHUB_REPO}/releases/download/${_ver}/$(binary_basename "$_os" "$_arch")" + echo "$_base" +} + +verify_binary_checksum() { + _bin="$1" + [ -f "$_bin" ] || die "Not a file: $_bin" + if [ -n "$EXPECTED_SHA256" ]; then + _sum=$(sha256sum "$_bin" | awk '{print $1}') + [ "$_sum" = "$EXPECTED_SHA256" ] || die "SHA256 mismatch (expected $EXPECTED_SHA256 got $_sum)" + info "SHA256 verified." + elif [ -n "$CHECKSUM_FILE" ] && [ -f "$CHECKSUM_FILE" ]; then + _bn=$(basename "$_bin") + ( cd "$(dirname "$_bin")" && grep " ${_bn}\$" "$CHECKSUM_FILE" >/dev/null ) || \ + ( cd "$(dirname "$CHECKSUM_FILE")" && sha256sum -c "$CHECKSUM_FILE" ) || \ + die "Checksum file verification failed" + fi +} + +sha256_file() { + sha256sum "$1" | awk '{print $1}' +} + +kv_get() { + _file="$1" _key="$2" + [ -f "$_file" ] || { echo ""; return 0; } + _line=$(grep "^${_key}=" "$_file" | head -1) || true + [ -n "$_line" ] || { echo ""; return 0; } + echo "$_line" | sed "s/^${_key}=//" +} + +write_install_receipt() { + _ver="$1" _os="$2" _arch="$3" _eng="$4" _url="$5" _sha="$6" _method="$7" + mkdir -p "$BACKUP_DIR" + _ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u) + { + printf 'installed_version=%s\n' "$_ver" + printf 'os=%s\n' "$_os" + printf 'arch=%s\n' "$_arch" + printf 'container_engine=%s\n' "$_eng" + printf 'source_url=%s\n' "$_url" + printf 'installed_at=%s\n' "$_ts" + printf 'install_method=%s\n' "$_method" + printf 'binary_sha256=%s\n' "$_sha" + } >"$RECEIPT_FILE" + chmod 600 "$RECEIPT_FILE" 2>/dev/null || true +} + +write_previous_release() { + _pv="$1" _pos="$2" _parch="$3" _peng="$4" _purl="$5" _psha="$6" _cfg="$7" + mkdir -p "$BACKUP_DIR" + { + printf 'previous_version=%s\n' "$_pv" + printf 'previous_os=%s\n' "$_pos" + printf 'previous_arch=%s\n' "$_parch" + printf 'previous_container_engine=%s\n' "$_peng" + printf 'previous_download_url=%s\n' "$_purl" + printf 'previous_binary_sha256=%s\n' "$_psha" + printf 'config_backup_path=%s\n' "$_cfg" + } >"$PREVIOUS_FILE" + chmod 600 "$PREVIOUS_FILE" 2>/dev/null || true +} + +cache_binary() { + _ver="$1" _os="$2" _arch="$3" _src="$4" + mkdir -p "$CACHE_DIR" + _dest="${CACHE_DIR}/edgelet-${_ver}-${_os}-${_arch}" + case "${_os}" in + windows) _dest="${_dest}.exe" ;; + esac + cp "$_src" "$_dest" + chmod 755 "$_dest" 2>/dev/null || true + info "Cached binary at ${_dest}" +} + +cached_binary_path() { + _ver="$1" _os="$2" _arch="$3" + _p="${CACHE_DIR}/edgelet-${_ver}-${_os}-${_arch}" + case "${_os}" in + windows) _p="${_p}.exe" ;; + esac + if [ -f "$_p" ]; then + echo "$_p" + return 0 + fi + echo "" +} + +packaging_etc_dir() { + if [ -d "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet" ]; then + echo "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet" + return 0 + fi + if [ -d "${SHARE_DIR}/etc/edgelet" ]; then + echo "${SHARE_DIR}/etc/edgelet" + return 0 + fi + echo "" +} + +install_config_samples() { + _etc="$(packaging_etc_dir)" + _sample_cfg="" + _sample_ca="" + if [ -n "$_etc" ]; then + [ -f "${_etc}/config.default.yaml" ] && _sample_cfg="${_etc}/config.default.yaml" + [ -f "${_etc}/controller-ca.sample.crt" ] && _sample_ca="${_etc}/controller-ca.sample.crt" + fi + if [ -f "${SHARE_DIR}/edgelet-config.yaml.sample" ]; then + _sample_cfg="${SHARE_DIR}/edgelet-config.yaml.sample" + fi + if [ -f "${SHARE_DIR}/edgelet-controller-ca.crt.sample" ]; then + _sample_ca="${SHARE_DIR}/edgelet-controller-ca.crt.sample" + fi + + if [ ! -f "$CONFIG_FILE" ]; then + if [ -n "$_sample_cfg" ]; then + mkdir -p "$CONFIG_DIR" + install -m 640 "$_sample_cfg" "$CONFIG_FILE" + info "Config installed from sample." + else + write_default_config_if_missing + fi + elif [ "$FORCE_CONFIG" = true ]; then + [ -n "$_sample_cfg" ] || die "--force-config requires a config sample" + install -m 640 "$_sample_cfg" "$CONFIG_FILE" + info "Config replaced (--force-config)." + else + info "Existing config preserved at ${CONFIG_FILE}" + fi + + if [ "$WITH_SAMPLE_CA" = true ] && [ ! -f "$CERT_FILE" ]; then + [ -n "$_sample_ca" ] || die "--with-sample-ca requires controller-ca sample" + install -m 644 "$_sample_ca" "$CERT_FILE" + info "Sample controller CA installed at ${CERT_FILE}" + fi +} + +default_container_engine_for_os() { + case "$1" in + linux) echo "edgelet" ;; + *) echo "docker" ;; + esac +} + +default_container_engine_url_for_engine() { + _eng="$1" + _os="${2:-linux}" + case "${_eng}" in + docker) echo "unix:///var/run/docker.sock" ;; + podman) echo "unix:///run/podman/podman.sock" ;; + edgelet) echo "unix:///run/edgelet/containerd.sock" ;; + *) die "Unsupported engine: ${_eng}" ;; + esac +} + +default_data_dir_for_os() { + case "$1" in + windows) echo "$(windows_program_data_edgelet)/data/" ;; + *) echo "/var/lib/edgelet/" ;; + esac +} + +default_log_dir_for_os() { + case "$1" in + windows) echo "$(windows_program_data_edgelet)/log/" ;; + *) echo "/var/log/edgelet/" ;; + esac +} + +write_default_config_if_missing() { + [ -f "$CONFIG_FILE" ] && return 0 + _eng="${CONTAINER_ENGINE}" + if [ -z "$_eng" ]; then + _eng=$(default_container_engine_for_os "$OS") + fi + _du=$(default_container_engine_url_for_engine "$_eng" "$OS") + _disk=$(default_data_dir_for_os "$OS") + _log_disk=$(default_log_dir_for_os "$OS") + mkdir -p "$CONFIG_DIR" + cat >"$CONFIG_FILE" </dev/null 2>&1; then + pkill -f '[e]dgelet daemon' 2>/dev/null || true + sleep 1 + info "edgelet daemon stopped." + fi + case "${_os}" in + darwin) rm -f /var/run/edgelet/edgelet.pid ;; + windows) rm -f "$(windows_program_data_edgelet)/run/edgelet.pid" ;; + esac +} + +start_edgelet_daemon_desktop() { + _os="$1" + _log_dir="/var/log/edgelet" + _pid_file="/var/run/edgelet/edgelet.pid" + case "${_os}" in + darwin) + mkdir -p "$_log_dir" /var/run/edgelet + ;; + windows) + _pd=$(windows_program_data_edgelet) + _log_dir="${_pd}/log" + _pid_file="${_pd}/run/edgelet.pid" + mkdir -p "$_log_dir" "$(dirname "$_pid_file")" + ;; + *) return 0 ;; + esac + + if pgrep -f '[e]dgelet daemon' >/dev/null 2>&1; then + info "edgelet daemon already running" + return 0 + fi + + nohup edgelet daemon & + echo $! > "$_pid_file" + info "edgelet daemon started in background (pid=$(cat "$_pid_file"))" + info " logs: ${_log_dir}/edgelet.0.log" +} + +install_dirs() { + OS="$1" + case "${OS}" in + linux) + mkdir -p "$CONFIG_DIR" /var/log/edgelet /var/lib/edgelet /run/edgelet \ + /var/lib/edgelet-containerd "$BACKUP_DIR" "$CACHE_DIR" "$SHARE_DIR" + chmod 750 "$CONFIG_DIR" /var/log/edgelet /var/lib/edgelet 2>/dev/null || true + ;; + darwin) + mkdir -p "$CONFIG_DIR" /var/log/edgelet /var/lib/edgelet /var/run/edgelet \ + "$BACKUP_DIR" "$CACHE_DIR" "$SHARE_DIR" + chmod 750 "$CONFIG_DIR" /var/log/edgelet /var/lib/edgelet 2>/dev/null || true + ;; + windows) + _pd=$(windows_program_data_edgelet) + mkdir -p "${_pd}/data" "${_pd}/config" "${_pd}/run" "${_pd}/log" "${_pd}/scripts" 2>/dev/null || true + ;; + esac +} + +install_script_to_share() { + _dest="${SHARE_DIR}/install.sh" + if [ -f "${SCRIPT_DIR}/install.sh" ]; then + install -m 755 "${SCRIPT_DIR}/install.sh" "$_dest" + return 0 + fi + case "$0" in + */install.sh) + if [ -f "$0" ]; then + install -m 755 "$0" "$_dest" + return 0 + fi + ;; + esac + write_embedded_install "$_dest" +} + +uninstall_script_to_share() { + _dest="${SHARE_DIR}/uninstall.sh" + if [ -f "${SCRIPT_DIR}/uninstall.sh" ]; then + install -m 755 "${SCRIPT_DIR}/uninstall.sh" "$_dest" + return 0 + fi + write_embedded_uninstall "$_dest" +} + +copy_bundled_scripts() { + OS="$1" + mkdir -p "$SHARE_DIR" + install_script_to_share + uninstall_script_to_share + if [ -d "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet" ]; then + mkdir -p "${SHARE_DIR}/etc/edgelet" + for _f in config.default.yaml controller-ca.sample.crt; do + [ -f "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet/${_f}" ] && \ + cp "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet/${_f}" "${SHARE_DIR}/etc/edgelet/${_f}" 2>/dev/null || true + done + [ -f "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet/config.default.yaml" ] && \ + cp "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet/config.default.yaml" \ + "${SHARE_DIR}/edgelet-config.yaml.sample" + [ -f "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet/controller-ca.sample.crt" ] && \ + cp "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet/controller-ca.sample.crt" \ + "${SHARE_DIR}/edgelet-controller-ca.crt.sample" + fi + info "Bundled install scripts at ${SHARE_DIR}/" +} + +install_binary_file() { + _src="$1" _dest="$2" + _dir=$(dirname "$_dest") + mkdir -p "$_dir" + install -m 755 "$_src" "$_dest" + info "Installed ${_dest}" +} + +install_cli_completion() { + command -v edgelet >/dev/null 2>&1 || return 0 + if [ -d /etc/bash_completion.d ]; then + if edgelet completion bash >/etc/bash_completion.d/edgelet 2>/dev/null; then + chmod 644 /etc/bash_completion.d/edgelet + info "Bash completion installed." + fi + fi +} + +apply_container_engine_to_config() { + [ -f "$CONFIG_FILE" ] || return 0 + command -v sed >/dev/null 2>&1 || return 0 + sed -i "s|containerEngine:.*|containerEngine: ${CONTAINER_ENGINE}|" "$CONFIG_FILE" 2>/dev/null || true + _durl=$(default_container_engine_url_for_engine "$CONTAINER_ENGINE" "$OS") + sed -i "s|containerEngineUrl:.*|containerEngineUrl: ${_durl}|" "$CONFIG_FILE" 2>/dev/null || true +} + +download_or_stage_binary() { + _dest="$1" + if [ -n "$BIN_PATH" ]; then + [ -f "$BIN_PATH" ] || die "Local binary not found: $BIN_PATH" + verify_binary_checksum "$BIN_PATH" + cp "$BIN_PATH" "$_dest" + info "Using local binary: ${BIN_PATH}" + return 0 + fi + if [ "$AIRGAP" = true ]; then + die "--airgap requires --bin-path" + fi + _url=$(release_download_url "$EDGELET_VERSION" "$OS" "$ARCH") + info "Downloading ${_url} ..." + curl -fsSL -o "$_dest" "$_url" || die "Failed to download release binary" +} + +compute_source_url() { + if [ -n "$BIN_PATH" ]; then + _real=$(cd "$(dirname "$BIN_PATH")" && pwd)/$(basename "$BIN_PATH") + echo "file://${_real}" + else + release_download_url "$EDGELET_VERSION" "$OS" "$ARCH" + fi +} + +# ── main ────────────────────────────────────────────────────────────────────── +OS=$(detect_os) +init_platform_paths "$OS" +ARCH="${ARCH_OVERRIDE:-$(detect_arch)}" +BINARY_PATH=$(binary_install_path "$OS") + +if [ "$OS" = "linux" ]; then + INIT=$(detect_init) +else + INIT="none" +fi + +if [ "$ACTION" != "rollback" ]; then + if [ -z "$CONTAINER_ENGINE" ]; then + CONTAINER_ENGINE=$(default_container_engine_for_os "$OS") + fi + case "$CONTAINER_ENGINE" in + edgelet) + [ "$OS" = "linux" ] || die "containerEngine=edgelet is linux-only" + ;; + docker|podman) ;; + *) die "Invalid --container-engine (use edgelet, docker, or podman)" ;; + esac +fi + +require_root + +info "OS : ${OS}" +info "Architecture : ${ARCH}" +info "Init system : ${INIT}" +if [ "$ACTION" != "rollback" ]; then + info "Engine : ${CONTAINER_ENGINE}" +fi +info "Action : ${ACTION}" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "${TMPDIR}"' EXIT + +if [ "$AIRGAP" = false ] && [ "$EDGELET_VERSION" = "latest" ] && [ "$ACTION" != "rollback" ] && [ -z "$BIN_PATH" ]; then + info "Fetching latest release tag..." + EDGELET_VERSION=$(curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \ + | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') + [ -n "${EDGELET_VERSION}" ] || die "Failed to determine latest version" +fi + +info "Version: ${EDGELET_VERSION}" + +# ── rollback ────────────────────────────────────────────────────────────────── +if [ "$ACTION" = "rollback" ]; then + [ -f "$PREVIOUS_FILE" ] || die "No ${PREVIOUS_FILE} found." + _pv=$(kv_get "$PREVIOUS_FILE" "previous_version") + _pos=$(kv_get "$PREVIOUS_FILE" "previous_os") + _parch=$(kv_get "$PREVIOUS_FILE" "previous_arch") + _peng=$(kv_get "$PREVIOUS_FILE" "previous_container_engine") + _purl=$(kv_get "$PREVIOUS_FILE" "previous_download_url") + _cfgbak=$(kv_get "$PREVIOUS_FILE" "config_backup_path") + [ -n "$_pos" ] || _pos="$OS" + [ -n "$_parch" ] || _parch="$ARCH" + CONTAINER_ENGINE="${_peng:-edgelet}" + EDGELET_VERSION="$_pv" + _staged="${TMPDIR}/edgelet-bin" + _cached=$(cached_binary_path "$_pv" "$_pos" "$_parch") + if [ -n "$_cached" ]; then + cp "$_cached" "$_staged" + info "Rollback from cache: ${_cached}" + elif [ -n "$BIN_PATH" ]; then + verify_binary_checksum "$BIN_PATH" + cp "$BIN_PATH" "$_staged" + elif [ "$AIRGAP" = true ]; then + die "rollback with --airgap requires --bin-path or a cached binary" + else + curl -fsSL -o "$_staged" "$_purl" || die "Failed to download rollback binary" + fi + [ "$OS" = "linux" ] && stop_edgelet_service "$INIT" + install_binary_file "$_staged" "$BINARY_PATH" + install_dirs "$OS" + if [ "$FORCE_CONFIG" != true ] && [ -f "$_cfgbak" ]; then + install -m 640 "$_cfgbak" "$CONFIG_FILE" + fi + if [ "$OS" = "linux" ]; then + install_init_unit "$INIT" "$CONTAINER_ENGINE" + fi + _sha=$(sha256_file "$BINARY_PATH") + write_install_receipt "$EDGELET_VERSION" "$_pos" "$_parch" "$CONTAINER_ENGINE" "$_purl" "$_sha" "rollback" + info "Rollback to ${EDGELET_VERSION} complete." + exit 0 +fi + +# ── upgrade ─────────────────────────────────────────────────────────────────── +if [ "$ACTION" = "upgrade" ]; then + [ -f "$BINARY_PATH" ] || die "Edgelet not installed; run install first" + [ -f "$RECEIPT_FILE" ] || die "Missing ${RECEIPT_FILE}" + _cur_ver=$(kv_get "$RECEIPT_FILE" "installed_version") + _cur_os=$(kv_get "$RECEIPT_FILE" "os") + _cur_arch=$(kv_get "$RECEIPT_FILE" "arch") + _cur_eng=$(kv_get "$RECEIPT_FILE" "container_engine") + _cur_src=$(kv_get "$RECEIPT_FILE" "source_url") + _cur_sha=$(kv_get "$RECEIPT_FILE" "binary_sha256") + [ -n "$_cur_os" ] || _cur_os="$OS" + [ -n "$_cur_arch" ] || _cur_arch="$ARCH" + [ -n "$_cur_eng" ] || _cur_eng="$CONTAINER_ENGINE" + if [ "$EDGELET_VERSION" = "latest" ] && [ -z "$BIN_PATH" ]; then + EDGELET_VERSION=$(curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \ + | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') + fi + _cfg_backup="${BACKUP_DIR}/config.yaml.$(date +%Y%m%d%H%M%S 2>/dev/null || date +%s)" + cp "$CONFIG_FILE" "$_cfg_backup" 2>/dev/null || true + cache_binary "$_cur_ver" "$_cur_os" "$_cur_arch" "$BINARY_PATH" + write_previous_release "$_cur_ver" "$_cur_os" "$_cur_arch" "$_cur_eng" "$_cur_src" "$_cur_sha" "$_cfg_backup" + [ "$OS" = "linux" ] && stop_edgelet_service "$INIT" + stop_edgelet_daemon_desktop "$OS" + _staged="${TMPDIR}/edgelet-bin" + download_or_stage_binary "$_staged" + verify_binary_checksum "$_staged" + install_binary_file "$_staged" "$BINARY_PATH" + install_dirs "$OS" + if [ "$FORCE_CONFIG" = true ]; then + rm -f "$CONFIG_FILE" + install_config_samples + fi + apply_container_engine_to_config + _sha=$(sha256_file "$BINARY_PATH") + _method="upgrade" + [ "$AIRGAP" = true ] && _method="upgrade-airgap" + write_install_receipt "$EDGELET_VERSION" "$OS" "$ARCH" "$CONTAINER_ENGINE" "$(compute_source_url)" "$_sha" "$_method" + copy_bundled_scripts "$OS" + if [ "$OS" = "linux" ]; then + install_init_unit "$INIT" "$CONTAINER_ENGINE" + else + start_edgelet_daemon_desktop "$OS" + fi + info "Upgrade to ${EDGELET_VERSION} complete." + exit 0 +fi + +# ── fresh install ───────────────────────────────────────────────────────────── +_staged="${TMPDIR}/edgelet-bin" +download_or_stage_binary "$_staged" +verify_binary_checksum "$_staged" +install_dirs "$OS" +install_binary_file "$_staged" "$BINARY_PATH" +install_config_samples +apply_container_engine_to_config +_sha=$(sha256_file "$BINARY_PATH") +_method="install" +[ "$AIRGAP" = true ] && _method="install-airgap" +write_install_receipt "$EDGELET_VERSION" "$OS" "$ARCH" "$CONTAINER_ENGINE" "$(compute_source_url)" "$_sha" "$_method" +copy_bundled_scripts "$OS" +if [ "$OS" = "linux" ]; then + install_init_unit "$INIT" "$CONTAINER_ENGINE" + install_cli_completion +else + start_edgelet_daemon_desktop "$OS" +fi + +info "" +info "edgelet ${EDGELET_VERSION} installed (os=${OS} engine=${CONTAINER_ENGINE})." +info " Binary : ${BINARY_PATH}" +case "$OS" in + linux) + info " Unit : ${INIT}" + info " Config : ${CONFIG_FILE}" + info " Data : /var/lib/edgelet/" + info " Scripts: ${SHARE_DIR}/" + ;; + darwin) + info " Config : ${CONFIG_FILE}" + info " Data : /var/lib/edgelet/" + info " Scripts: ${SHARE_DIR}/" + ;; + windows) + info " Config : ${CONFIG_FILE}" + _pd=$(windows_program_data_edgelet) + info " Data : ${_pd}/data/" + info " Scripts: ${SHARE_DIR}/" + ;; +esac +info "" +info "Check status: edgelet system status" +info "Configure Controller: edgelet config --a " +info "Provision: edgelet provision " +EDGELET_INSTALL_SELF_EOF + chmod 755 "$_dest" +} +# ASSEMBLE:INSTALL_SELF_END + +BACKUP_DIR="/var/backups/edgelet" +CACHE_DIR="${BACKUP_DIR}/cache" +RECEIPT_FILE="${BACKUP_DIR}/install-receipt" +PREVIOUS_FILE="${BACKUP_DIR}/previous-release" +GITHUB_REPO="${EDGELET_GITHUB_REPO:-eclipse-iofog/edgelet}" +SHARE_DIR="/usr/share/edgelet" +UNIT_NAME="edgelet" +CONFIG_DIR="/etc/edgelet" +CONFIG_FILE="${CONFIG_DIR}/config.yaml" +CERT_FILE="${CONFIG_DIR}/cert.crt" + +windows_program_data_edgelet() { + echo "${ProgramData:-/c/ProgramData}/Edgelet" +} + +init_platform_paths() { + _os="$1" + case "${_os}" in + linux) + SHARE_DIR="/usr/share/edgelet" + CONFIG_DIR="/etc/edgelet" + ;; + darwin) + SHARE_DIR="/usr/local/share/edgelet" + CONFIG_DIR="/etc/edgelet" + ;; + windows) + _pd=$(windows_program_data_edgelet) + SHARE_DIR="${_pd}/scripts" + CONFIG_DIR="${_pd}/config" + ;; + *) die "Unsupported OS for platform paths: ${_os}" ;; + esac + CONFIG_FILE="${CONFIG_DIR}/config.yaml" + CERT_FILE="${CONFIG_DIR}/cert.crt" +} + +detect_os() { + _u=$(uname -s) + case "${_u}" in + Linux) echo "linux" ;; + Darwin) echo "darwin" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) echo "windows" ;; + *) die "Unsupported OS: ${_u}" ;; + esac +} + +detect_arch() { + MACHINE=$(uname -m) + case "${MACHINE}" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + armv7l|armv6l|arm) echo "arm" ;; + riscv64) echo "riscv64" ;; + *) die "Unsupported architecture: ${MACHINE}" ;; + esac +} + +require_root() { + OS=$(detect_os) + if [ "$OS" = "windows" ]; then + return 0 + fi + [ "$(id -u)" -eq 0 ] || die "This script must be run as root. Try: sudo $0 $*" +} + +binary_basename() { + _os="$1" _arch="$2" + case "${_os}" in + windows) echo "edgelet-${_os}-${_arch}.exe" ;; + *) echo "edgelet-${_os}-${_arch}" ;; + esac +} + +binary_install_path() { + _os="$1" + case "${_os}" in + linux|darwin) echo "/usr/local/bin/edgelet" ;; + windows) + _pf="${ProgramFiles:-/c/Program Files}" + echo "${_pf}/Edgelet/edgelet.exe" + ;; + *) die "Unsupported OS for binary path" ;; + esac +} + +release_download_url() { + _ver="$1" _os="$2" _arch="$3" + _base="https://github.com/${GITHUB_REPO}/releases/download/${_ver}/$(binary_basename "$_os" "$_arch")" + echo "$_base" +} + +verify_binary_checksum() { + _bin="$1" + [ -f "$_bin" ] || die "Not a file: $_bin" + if [ -n "$EXPECTED_SHA256" ]; then + _sum=$(sha256sum "$_bin" | awk '{print $1}') + [ "$_sum" = "$EXPECTED_SHA256" ] || die "SHA256 mismatch (expected $EXPECTED_SHA256 got $_sum)" + info "SHA256 verified." + elif [ -n "$CHECKSUM_FILE" ] && [ -f "$CHECKSUM_FILE" ]; then + _bn=$(basename "$_bin") + ( cd "$(dirname "$_bin")" && grep " ${_bn}\$" "$CHECKSUM_FILE" >/dev/null ) || \ + ( cd "$(dirname "$CHECKSUM_FILE")" && sha256sum -c "$CHECKSUM_FILE" ) || \ + die "Checksum file verification failed" + fi +} + +sha256_file() { + sha256sum "$1" | awk '{print $1}' +} + +kv_get() { + _file="$1" _key="$2" + [ -f "$_file" ] || { echo ""; return 0; } + _line=$(grep "^${_key}=" "$_file" | head -1) || true + [ -n "$_line" ] || { echo ""; return 0; } + echo "$_line" | sed "s/^${_key}=//" +} + +write_install_receipt() { + _ver="$1" _os="$2" _arch="$3" _eng="$4" _url="$5" _sha="$6" _method="$7" + mkdir -p "$BACKUP_DIR" + _ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u) + { + printf 'installed_version=%s\n' "$_ver" + printf 'os=%s\n' "$_os" + printf 'arch=%s\n' "$_arch" + printf 'container_engine=%s\n' "$_eng" + printf 'source_url=%s\n' "$_url" + printf 'installed_at=%s\n' "$_ts" + printf 'install_method=%s\n' "$_method" + printf 'binary_sha256=%s\n' "$_sha" + } >"$RECEIPT_FILE" + chmod 600 "$RECEIPT_FILE" 2>/dev/null || true +} + +write_previous_release() { + _pv="$1" _pos="$2" _parch="$3" _peng="$4" _purl="$5" _psha="$6" _cfg="$7" + mkdir -p "$BACKUP_DIR" + { + printf 'previous_version=%s\n' "$_pv" + printf 'previous_os=%s\n' "$_pos" + printf 'previous_arch=%s\n' "$_parch" + printf 'previous_container_engine=%s\n' "$_peng" + printf 'previous_download_url=%s\n' "$_purl" + printf 'previous_binary_sha256=%s\n' "$_psha" + printf 'config_backup_path=%s\n' "$_cfg" + } >"$PREVIOUS_FILE" + chmod 600 "$PREVIOUS_FILE" 2>/dev/null || true +} + +cache_binary() { + _ver="$1" _os="$2" _arch="$3" _src="$4" + mkdir -p "$CACHE_DIR" + _dest="${CACHE_DIR}/edgelet-${_ver}-${_os}-${_arch}" + case "${_os}" in + windows) _dest="${_dest}.exe" ;; + esac + cp "$_src" "$_dest" + chmod 755 "$_dest" 2>/dev/null || true + info "Cached binary at ${_dest}" +} + +cached_binary_path() { + _ver="$1" _os="$2" _arch="$3" + _p="${CACHE_DIR}/edgelet-${_ver}-${_os}-${_arch}" + case "${_os}" in + windows) _p="${_p}.exe" ;; + esac + if [ -f "$_p" ]; then + echo "$_p" + return 0 + fi + echo "" +} + +packaging_etc_dir() { + if [ -d "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet" ]; then + echo "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet" + return 0 + fi + if [ -d "${SHARE_DIR}/etc/edgelet" ]; then + echo "${SHARE_DIR}/etc/edgelet" + return 0 + fi + echo "" +} + +install_config_samples() { _etc="$(packaging_etc_dir)" _sample_cfg="" _sample_ca="" @@ -994,10 +3239,47 @@ install_config_samples() { fi } +default_container_engine_for_os() { + case "$1" in + linux) echo "edgelet" ;; + *) echo "docker" ;; + esac +} + +default_container_engine_url_for_engine() { + _eng="$1" + _os="${2:-linux}" + case "${_eng}" in + docker) echo "unix:///var/run/docker.sock" ;; + podman) echo "unix:///run/podman/podman.sock" ;; + edgelet) echo "unix:///run/edgelet/containerd.sock" ;; + *) die "Unsupported engine: ${_eng}" ;; + esac +} + +default_data_dir_for_os() { + case "$1" in + windows) echo "$(windows_program_data_edgelet)/data/" ;; + *) echo "/var/lib/edgelet/" ;; + esac +} + +default_log_dir_for_os() { + case "$1" in + windows) echo "$(windows_program_data_edgelet)/log/" ;; + *) echo "/var/log/edgelet/" ;; + esac +} + write_default_config_if_missing() { [ -f "$CONFIG_FILE" ] && return 0 - _eng="${CONTAINER_ENGINE:-edgelet}" - _du=$(default_container_engine_url_for_engine "$_eng") + _eng="${CONTAINER_ENGINE}" + if [ -z "$_eng" ]; then + _eng=$(default_container_engine_for_os "$OS") + fi + _du=$(default_container_engine_url_for_engine "$_eng" "$OS") + _disk=$(default_data_dir_for_os "$OS") + _log_disk=$(default_log_dir_for_os "$OS") mkdir -p "$CONFIG_DIR" cat >"$CONFIG_FILE" </dev/null 2>&1; then + pkill -f '[e]dgelet daemon' 2>/dev/null || true + sleep 1 + info "edgelet daemon stopped." + fi + case "${_os}" in + darwin) rm -f /var/run/edgelet/edgelet.pid ;; + windows) rm -f "$(windows_program_data_edgelet)/run/edgelet.pid" ;; + esac +} + +start_edgelet_daemon_desktop() { + _os="$1" + _log_dir="/var/log/edgelet" + _pid_file="/var/run/edgelet/edgelet.pid" + case "${_os}" in + darwin) + mkdir -p "$_log_dir" /var/run/edgelet + ;; + windows) + _pd=$(windows_program_data_edgelet) + _log_dir="${_pd}/log" + _pid_file="${_pd}/run/edgelet.pid" + mkdir -p "$_log_dir" "$(dirname "$_pid_file")" + ;; + *) return 0 ;; esac + + if pgrep -f '[e]dgelet daemon' >/dev/null 2>&1; then + info "edgelet daemon already running" + return 0 + fi + + nohup edgelet daemon & + echo $! > "$_pid_file" + info "edgelet daemon started in background (pid=$(cat "$_pid_file"))" + info " logs: ${_log_dir}/edgelet.0.log" } install_dirs() { OS="$1" case "${OS}" in - linux|darwin) + linux) mkdir -p "$CONFIG_DIR" /var/log/edgelet /var/lib/edgelet /run/edgelet \ /var/lib/edgelet-containerd "$BACKUP_DIR" "$CACHE_DIR" "$SHARE_DIR" chmod 750 "$CONFIG_DIR" /var/log/edgelet /var/lib/edgelet 2>/dev/null || true ;; + darwin) + mkdir -p "$CONFIG_DIR" /var/log/edgelet /var/lib/edgelet /var/run/edgelet \ + "$BACKUP_DIR" "$CACHE_DIR" "$SHARE_DIR" + chmod 750 "$CONFIG_DIR" /var/log/edgelet /var/lib/edgelet 2>/dev/null || true + ;; windows) - _pd="${ProgramData:-/c/ProgramData}/Edgelet" - mkdir -p "${_pd}/data" "${_pd}/config" 2>/dev/null || true + _pd=$(windows_program_data_edgelet) + mkdir -p "${_pd}/data" "${_pd}/config" "${_pd}/run" "${_pd}/log" "${_pd}/scripts" 2>/dev/null || true ;; esac } -copy_bundled_scripts() { - OS="$1" - [ "$OS" = "linux" ] || return 0 - mkdir -p "$SHARE_DIR" +install_script_to_share() { + _dest="${SHARE_DIR}/install.sh" if [ -f "${SCRIPT_DIR}/install.sh" ]; then - install -m 755 "${SCRIPT_DIR}/install.sh" "${SHARE_DIR}/install.sh" + install -m 755 "${SCRIPT_DIR}/install.sh" "$_dest" + return 0 fi + case "$0" in + */install.sh) + if [ -f "$0" ]; then + install -m 755 "$0" "$_dest" + return 0 + fi + ;; + esac + write_embedded_install "$_dest" +} + +uninstall_script_to_share() { + _dest="${SHARE_DIR}/uninstall.sh" if [ -f "${SCRIPT_DIR}/uninstall.sh" ]; then - install -m 755 "${SCRIPT_DIR}/uninstall.sh" "${SHARE_DIR}/uninstall.sh" + install -m 755 "${SCRIPT_DIR}/uninstall.sh" "$_dest" + return 0 fi + write_embedded_uninstall "$_dest" +} + +copy_bundled_scripts() { + OS="$1" + mkdir -p "$SHARE_DIR" + install_script_to_share + uninstall_script_to_share if [ -d "${SCRIPT_DIR}/packaging/edgelet/etc/edgelet" ]; then mkdir -p "${SHARE_DIR}/etc/edgelet" for _f in config.default.yaml controller-ca.sample.crt; do @@ -1088,7 +3432,7 @@ apply_container_engine_to_config() { [ -f "$CONFIG_FILE" ] || return 0 command -v sed >/dev/null 2>&1 || return 0 sed -i "s|containerEngine:.*|containerEngine: ${CONTAINER_ENGINE}|" "$CONFIG_FILE" 2>/dev/null || true - _durl=$(default_container_engine_url_for_engine "$CONTAINER_ENGINE") + _durl=$(default_container_engine_url_for_engine "$CONTAINER_ENGINE" "$OS") sed -i "s|containerEngineUrl:.*|containerEngineUrl: ${_durl}|" "$CONFIG_FILE" 2>/dev/null || true } @@ -1118,60 +3462,9 @@ compute_source_url() { fi } -# ── argument parsing ────────────────────────────────────────────────────────── -EDGELET_VERSION="${EDGELET_VERSION:-latest}" -CONTAINER_ENGINE="" -ACTION="install" -AIRGAP=false -BIN_PATH="" -FORCE_CONFIG=false -WITH_SAMPLE_CA=false -ARCH_OVERRIDE="" -CHECKSUM_FILE="" -EXPECTED_SHA256="" - -for arg in "$@"; do - case "${arg}" in - --version=*) EDGELET_VERSION="${arg#*=}" ;; - --arch=*) ARCH_OVERRIDE="${arg#*=}" ;; - --container-engine=*) CONTAINER_ENGINE="${arg#*=}" ;; - --bin-path=*) BIN_PATH="${arg#*=}" ;; - --checksum-path=*) CHECKSUM_FILE="${arg#*=}" ;; - --expected-sha256=*) EXPECTED_SHA256="${arg#*=}" ;; - --airgap) AIRGAP=true ;; - --upgrade) ACTION="upgrade" ;; - --rollback) ACTION="rollback" ;; - --force-config) FORCE_CONFIG=true ;; - --with-sample-ca) WITH_SAMPLE_CA=true ;; - --help|-h) - cat <" diff --git a/internal/cgroups/detect_linux_test.go b/internal/cgroups/detect_linux_test.go index 884071b..2b5ec1e 100644 --- a/internal/cgroups/detect_linux_test.go +++ b/internal/cgroups/detect_linux_test.go @@ -265,7 +265,6 @@ func asErrDelegation(err error, target **ErrDelegation) bool { } func TestMachineRootDelegationSatisfied(t *testing.T) { - t.Parallel() const mount = "/sys/fs/cgroup" tests := []struct { @@ -313,7 +312,6 @@ func TestMachineRootDelegationSatisfied(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - t.Parallel() prevRead := readFileFn prevStat := statFn t.Cleanup(func() { diff --git a/internal/cli/cmd/system.go b/internal/cli/cmd/system.go index 01473f9..c6fb9fa 100644 --- a/internal/cli/cmd/system.go +++ b/internal/cli/cmd/system.go @@ -133,7 +133,7 @@ func runSystemReload(cmd *cobra.Command, args []string) error { if err != nil { return err } - return run.WriteRouteData(appCtx, path, data) + return writeHumanOrRoute(appCtx, path, output.FormatEdgeletAPIHuman(path, data), data) } func runSystemPrune(cmd *cobra.Command, args []string) error { diff --git a/internal/cli/output/edgeletapi_mutations.go b/internal/cli/output/edgeletapi_mutations.go index 4cc405c..f370856 100644 --- a/internal/cli/output/edgeletapi_mutations.go +++ b/internal/cli/output/edgeletapi_mutations.go @@ -15,6 +15,8 @@ func formatMutationRoute(routePath string, result map[string]any) string { return "controller certificate updated successfully" case "/v1/system/config/switch": return FormatSwitchResult(result) + case "/v1/system/reload": + return "configuration reloaded successfully" case "/v1/images:pull": return formatImagePullResult(result) case "/v1/images:load": diff --git a/internal/cli/output/format_test.go b/internal/cli/output/format_test.go index bf69918..2b04968 100644 --- a/internal/cli/output/format_test.go +++ b/internal/cli/output/format_test.go @@ -21,6 +21,13 @@ func TestFormatConfigPatchResult_PrintsRejectedKeys(t *testing.T) { } } +func TestFormatMutationRoute_SystemReload(t *testing.T) { + out := formatMutationRoute("/v1/system/reload", map[string]any{"status": "ok"}) + if out != "configuration reloaded successfully" { + t.Fatalf("unexpected output: %q", out) + } +} + func TestFormatEdgeletAPIHuman_StatusOrder(t *testing.T) { out := FormatEdgeletAPIHuman("/v1/system/status", map[string]any{ "controllerUrl": "u", diff --git a/internal/config/reload.go b/internal/config/reload.go new file mode 100644 index 0000000..c2703e7 --- /dev/null +++ b/internal/config/reload.go @@ -0,0 +1,62 @@ +package config + +import ( + "errors" + "fmt" + + "github.com/eclipse-iofog/edgelet/internal/utils/logging" +) + +const reloadModuleName = "ConfigReload" + +// ReloadHooks supplies supervisor-specific steps for a full config reload. +type ReloadHooks struct { + ConfigPath string + BeginReload func() + NotifyModules func() error +} + +// FullReload reads config from disk, validates it, updates the logger, and notifies modules. +func FullReload(hooks ReloadHooks) error { + logging.LogInfo(reloadModuleName, "Reloading configuration...") + SetLastReloadSuccessful(false) + + if hooks.BeginReload != nil { + hooks.BeginReload() + } + + configPath := hooks.ConfigPath + if configPath == "" { + return errors.New("config path is required") + } + + if err := LoadConfig(configPath); err != nil { + logging.LogError(reloadModuleName, "Failed to reload configuration", err) + logging.LogWarn(reloadModuleName, "Rejected configuration reload; keeping last-known-good runtime config") + return fmt.Errorf("failed to reload configuration: %w", err) + } + + cfg := GetInstance() + if err := ValidateConfig(cfg); err != nil { + logging.LogError(reloadModuleName, "Configuration validation failed after reload", err) + logging.LogWarn(reloadModuleName, "Rejected configuration reload; keeping last-known-good runtime config") + return fmt.Errorf("configuration validation failed: %w", err) + } + SetLastReloadSuccessful(true) + + logDiskLimitMB := int(cfg.LogDiskLimit * 1024) + if err := logging.InstanceConfigUpdated(cfg.LogDiskDirectory, logDiskLimitMB, cfg.LogFileCount, cfg.LogLevel); err != nil { + logging.LogError(reloadModuleName, "Failed to update logger configuration", err) + } + + if hooks.NotifyModules != nil { + if err := hooks.NotifyModules(); err != nil { + logging.LogError(reloadModuleName, "Failed to notify modules of config reload", err) + SetLastReloadSuccessful(false) + return err + } + } + + logging.LogInfo(reloadModuleName, "Configuration reloaded successfully") + return nil +} diff --git a/internal/config/reload_test.go b/internal/config/reload_test.go new file mode 100644 index 0000000..779ed93 --- /dev/null +++ b/internal/config/reload_test.go @@ -0,0 +1,93 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/eclipse-iofog/edgelet/internal/buildmeta" + "github.com/eclipse-iofog/edgelet/internal/constants" + "github.com/eclipse-iofog/edgelet/internal/utils/logging" + "github.com/sirupsen/logrus" +) + +func TestFullReload_UpdatesLogLevelWithoutRestart(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + engine := constants.EngineDocker + engineURL := "unix:///var/run/docker.sock" + if buildmeta.HasEmbeddedEngine() { + engine = constants.EngineEdgelet + engineURL = constants.EdgeletEngineSocketURL() + } + + yaml := `currentProfile: default +profiles: + default: + logLevel: "DEBUG" + diskLimit: "10" + memoryLimit: "4096" + cpuLimit: "80" + containerEngine: "` + engine + `" + containerEngineUrl: "` + engineURL + `" +` + if err := os.WriteFile(configPath, []byte(yaml), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + logDir := filepath.Join(tmpDir, "logs") + if err := os.MkdirAll(logDir, 0o700); err != nil { + t.Fatalf("mkdir logs: %v", err) + } + if err := logging.SetupLogger(logDir, 10, 10, "INFO"); err != nil { + t.Fatalf("setup logger: %v", err) + } + if logging.GetInstance().GetLevel() != logrus.InfoLevel { + t.Fatalf("expected initial log level INFO, got %v", logging.GetInstance().GetLevel()) + } + + notifyCalled := false + if err := FullReload(ReloadHooks{ + ConfigPath: configPath, + NotifyModules: func() error { + notifyCalled = true + return nil + }, + }); err != nil { + t.Fatalf("FullReload failed: %v", err) + } + if !notifyCalled { + t.Fatal("expected NotifyModules to be called") + } + if logging.GetInstance().GetLevel() != logrus.DebugLevel { + t.Fatalf("expected log level DEBUG after reload, got %v", logging.GetInstance().GetLevel()) + } + if !IsLastReloadSuccessful() { + t.Fatal("expected last reload to be marked successful") + } +} + +func TestFullReload_InvalidConfigReturnsError(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + yaml := `currentProfile: default +profiles: + default: + logLevel: "NOT_A_LEVEL" + diskLimit: "10" + memoryLimit: "4096" + cpuLimit: "80" +` + if err := os.WriteFile(configPath, []byte(yaml), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + if err := FullReload(ReloadHooks{ConfigPath: configPath}); err == nil { + t.Fatal("expected FullReload to fail for invalid config") + } + if IsLastReloadSuccessful() { + t.Fatal("expected last reload to be marked unsuccessful") + } +} diff --git a/internal/controlplane/env.go b/internal/controlplane/env.go index 241f0ba..c2e5400 100644 --- a/internal/controlplane/env.go +++ b/internal/controlplane/env.go @@ -4,7 +4,6 @@ package controlplane import ( "errors" "fmt" - "os" "path/filepath" "strconv" "strings" @@ -45,42 +44,94 @@ func BuildControllerEnv(doc *models.ControlPlaneManifest, controllerUUID string) "CONTROLLER_NAME": strings.TrimSpace(doc.Metadata.Name), } + setEnvIfNonEmpty(env, "CONTROLLER_PUBLIC_URL", doc.Spec.Controller.PublicURL) + if doc.Spec.Controller.TrustProxy != nil { + setEnv(env, "TRUST_PROXY", strconv.FormatBool(*doc.Spec.Controller.TrustProxy)) + } if doc.Spec.Controller.Port != nil { - setEnv(env, "SERVER_PORT", strconv.Itoa(*doc.Spec.Controller.Port)) + setEnv(env, "API_PORT", strconv.Itoa(*doc.Spec.Controller.Port)) } - if doc.Spec.ECNViewerPort != nil { - setEnv(env, "VIEWER_PORT", strconv.Itoa(*doc.Spec.ECNViewerPort)) + if doc.Spec.Console.Port != nil { + setEnv(env, "CONSOLE_PORT", strconv.Itoa(*doc.Spec.Console.Port)) } - setEnvIfNonEmpty(env, "VIEWER_URL", doc.Spec.ECNViewerURL) + setEnvIfNonEmpty(env, "CONSOLE_URL", doc.Spec.Console.URL) setEnvIfNonEmpty(env, "LOG_LEVEL", doc.Spec.LogLevel) - projectAuthEnv(env, &doc.Spec.Auth) + projectAuthEnv(env, doc.Spec.Auth) projectDatabaseEnv(env, doc.Spec.Database) projectEventsEnv(env, doc.Spec.Events) projectSystemMicroserviceEnv(env, doc.Spec.SystemMicroservices) projectNATSEnv(env, doc.Spec.NATS) - projectHTTPSEnv(env, doc.Spec.HTTPS) + projectTLSEnv(env, doc.Spec.TLS) projectVaultEnv(env, doc.Spec.Vault) return env, nil } -func projectAuthEnv(env map[string]string, auth *struct { - URL string `yaml:"url,omitempty" json:"url,omitempty"` - Realm string `yaml:"realm,omitempty" json:"realm,omitempty"` - RealmKey string `yaml:"realmKey,omitempty" json:"realmKey,omitempty"` - SSL string `yaml:"ssl,omitempty" json:"ssl,omitempty"` - ControllerClient string `yaml:"controllerClient,omitempty" json:"controllerClient,omitempty"` - ControllerSecret string `yaml:"controllerSecret,omitempty" json:"controllerSecret,omitempty"` - ViewerClient string `yaml:"viewerClient,omitempty" json:"viewerClient,omitempty"` -}) { - setEnvIfNonEmpty(env, "KC_URL", auth.URL) - setEnvIfNonEmpty(env, "KC_REALM", auth.Realm) - setEnvIfNonEmpty(env, "KC_REALM_KEY", auth.RealmKey) - setEnvIfNonEmpty(env, "KC_SSL_REQ", auth.SSL) - setEnvIfNonEmpty(env, "KC_CLIENT", auth.ControllerClient) - setEnvIfNonEmpty(env, "KC_CLIENT_SECRET", auth.ControllerSecret) - setEnvIfNonEmpty(env, "KC_VIEWER_CLIENT", auth.ViewerClient) +func projectAuthEnv(env map[string]string, auth *models.ControlPlaneAuthSpec) { + if auth == nil { + return + } + setEnvIfNonEmpty(env, "AUTH_MODE", auth.Mode) + if auth.InsecureAllowHTTP != nil { + setEnv(env, "AUTH_INSECURE_ALLOW_HTTP", strconv.FormatBool(*auth.InsecureAllowHTTP)) + } + if auth.InsecureAllowBootstrapLog != nil { + setEnv(env, "AUTH_INSECURE_ALLOW_BOOTSTRAP_LOG", strconv.FormatBool(*auth.InsecureAllowBootstrapLog)) + } + if auth.Bootstrap != nil { + setEnvIfNonEmpty(env, "OIDC_BOOTSTRAP_ADMIN_USERNAME", auth.Bootstrap.Username) + setEnvIfNonEmpty(env, "OIDC_BOOTSTRAP_ADMIN_PASSWORD", auth.Bootstrap.Password) + } + setEnvIfNonEmpty(env, "OIDC_ISSUER_URL", auth.IssuerURL) + if auth.Client != nil { + setEnvIfNonEmpty(env, "OIDC_CLIENT_ID", auth.Client.ID) + setEnvIfNonEmpty(env, "OIDC_CLIENT_SECRET", auth.Client.Secret) + } + setEnvIfNonEmpty(env, "OIDC_CONSOLE_CLIENT_ID", auth.ConsoleClient) + if auth.ConsoleClientEnabled != nil { + setEnv(env, "AUTH_CONSOLE_CLIENT_ENABLED", strconv.FormatBool(*auth.ConsoleClientEnabled)) + } + if auth.RateLimit != nil { + if auth.RateLimit.Enabled != nil { + setEnv(env, "AUTH_RATE_LIMIT_ENABLED", strconv.FormatBool(*auth.RateLimit.Enabled)) + } + if auth.RateLimit.MaxRequestsPerWindow != nil { + setEnv(env, "AUTH_RATE_LIMIT_MAX_REQUESTS", strconv.Itoa(*auth.RateLimit.MaxRequestsPerWindow)) + } + if auth.RateLimit.WindowMs != nil { + setEnv(env, "AUTH_RATE_LIMIT_WINDOW_MS", strconv.Itoa(*auth.RateLimit.WindowMs)) + } + } + if auth.SessionStore != nil { + setEnvIfNonEmpty(env, "AUTH_SESSION_STORE_TYPE", auth.SessionStore.Type) + if auth.SessionStore.TTLMs != nil { + setEnv(env, "AUTH_SESSION_STORE_TTL_MS", strconv.Itoa(*auth.SessionStore.TTLMs)) + } + setEnvIfNonEmpty(env, "AUTH_SESSION_SECRET", auth.SessionStore.Secret) + } + if auth.TokenTTL != nil { + if auth.TokenTTL.AccessTokenTTLSeconds != nil { + setEnv(env, "AUTH_ACCESS_TOKEN_TTL_SECONDS", strconv.Itoa(*auth.TokenTTL.AccessTokenTTLSeconds)) + } + if auth.TokenTTL.RefreshTokenTTLSeconds != nil { + setEnv(env, "AUTH_REFRESH_TOKEN_TTL_SECONDS", strconv.Itoa(*auth.TokenTTL.RefreshTokenTTLSeconds)) + } + } + if auth.OIDCTTL != nil { + if auth.OIDCTTL.InteractionTTLSeconds != nil { + setEnv(env, "AUTH_OIDC_INTERACTION_TTL_SECONDS", strconv.Itoa(*auth.OIDCTTL.InteractionTTLSeconds)) + } + if auth.OIDCTTL.GrantTTLSeconds != nil { + setEnv(env, "AUTH_OIDC_GRANT_TTL_SECONDS", strconv.Itoa(*auth.OIDCTTL.GrantTTLSeconds)) + } + if auth.OIDCTTL.SessionTTLSeconds != nil { + setEnv(env, "AUTH_OIDC_SESSION_TTL_SECONDS", strconv.Itoa(*auth.OIDCTTL.SessionTTLSeconds)) + } + if auth.OIDCTTL.IDTokenTTLSeconds != nil { + setEnv(env, "AUTH_OIDC_ID_TOKEN_TTL_SECONDS", strconv.Itoa(*auth.OIDCTTL.IDTokenTTLSeconds)) + } + } } func projectDatabaseEnv(env map[string]string, db *struct { @@ -159,24 +210,25 @@ func projectNATSEnv(env map[string]string, nats *struct { setEnv(env, "NATS_ENABLED", strconv.FormatBool(*nats.Enabled)) } -func projectHTTPSEnv(env map[string]string, https *models.ControlPlaneHTTPSConfig) { - if https == nil { +func projectTLSEnv(env map[string]string, tls *models.ControlPlaneTLSConfig) { + if tls == nil { return } - if path := strings.TrimSpace(https.Path); path != "" { - setEnv(env, "SSL_PATH_CERT", models.ControlPlaneHTTPSCertFilename) - setEnv(env, "SSL_PATH_KEY", models.ControlPlaneHTTPSKeyFilename) - if _, err := os.Stat(filepath.Join(path, models.ControlPlaneHTTPSCAFilename)); err == nil { - setEnv(env, "SSL_PATH_INTERMEDIATE_CERT", models.ControlPlaneHTTPSCAFilename) + if path := strings.TrimSpace(tls.Path); path != "" { + setEnv(env, "TLS_PATH_CERT", models.ControlPlaneTLSCertFilename) + setEnv(env, "TLS_PATH_KEY", models.ControlPlaneTLSKeyFilename) + if _, err := models.StatControlPlaneTLSFile(path, models.ControlPlaneTLSCAFilename); err == nil { + setEnv(env, "TLS_PATH_INTERMEDIATE_CERT", models.ControlPlaneTLSCAFilename) + setEnv(env, "INTERMEDIATE_CERT", filepath.Join(ContainerCertMountPath, models.ControlPlaneTLSCAFilename)) } return } - if https.Base64 == nil { + if tls.Base64 == nil { return } - setEnvIfNonEmpty(env, "SSL_BASE64_CERT", https.Base64.Cert) - setEnvIfNonEmpty(env, "SSL_BASE64_KEY", https.Base64.Key) - setEnvIfNonEmpty(env, "SSL_BASE64_INTERMEDIATE_CERT", https.Base64.CA) + setEnvIfNonEmpty(env, "TLS_BASE64_CERT", tls.Base64.Cert) + setEnvIfNonEmpty(env, "TLS_BASE64_KEY", tls.Base64.Key) + setEnvIfNonEmpty(env, "TLS_BASE64_INTERMEDIATE_CERT", tls.Base64.CA) } func projectVaultEnv(env map[string]string, vault *models.ControlPlaneVaultSpec) { diff --git a/internal/controlplane/env_test.go b/internal/controlplane/env_test.go index bc676ae..6538874 100644 --- a/internal/controlplane/env_test.go +++ b/internal/controlplane/env_test.go @@ -10,12 +10,20 @@ import ( "github.com/eclipse-iofog/edgelet/internal/models" ) -func TestBuildControllerEnv_MinimalRemoteIdentity(t *testing.T) { +const controlPlaneTestImage = "ghcr.io/datasance/controller:3.8.0-beta.0" + +func validControlPlaneDocForEnvTest() *models.ControlPlaneManifest { doc := &models.ControlPlaneManifest{} doc.APIVersion = "edgelet.iofog.org/v1" doc.Kind = "ControlPlane" doc.Metadata.Name = "pot" - doc.Spec.Controller.Image = "ghcr.io/datasance/controller:3.7.0" + doc.Spec.Controller.Image = controlPlaneTestImage + doc.Spec.Auth = models.ValidEmbeddedAuthForTest() + return doc +} + +func TestBuildControllerEnv_MinimalRemoteIdentity(t *testing.T) { + doc := validControlPlaneDocForEnvTest() env, err := BuildControllerEnv(doc, "uuid-1") if err != nil { @@ -33,14 +41,17 @@ func TestBuildControllerEnv_MinimalRemoteIdentity(t *testing.T) { if env["CONTROLLER_NAME"] != "pot" { t.Fatalf("CONTROLLER_NAME=%q", env["CONTROLLER_NAME"]) } - if _, ok := env["SERVER_PORT"]; ok { - t.Fatal("expected SERVER_PORT omitted when port unset") + if env["AUTH_MODE"] != "embedded" { + t.Fatalf("AUTH_MODE=%q", env["AUTH_MODE"]) + } + if _, ok := env["API_PORT"]; ok { + t.Fatal("expected API_PORT omitted when port unset") } } func TestBuildControllerEnv_FullProjection(t *testing.T) { port := 51121 - viewerPort := 8008 + consolePort := 8008 dbPort := 5432 dbSSL := true audit := true @@ -50,25 +61,66 @@ func TestBuildControllerEnv_FullProjection(t *testing.T) { natsEnabled := true vaultEnabled := true registry := 1 + trustProxy := true + insecureAllowHTTP := false + insecureAllowBootstrapLog := false + consoleClientEnabled := true + rateLimitEnabled := true + maxRequests := 60 + windowMs := 60000 + sessionTTLMs := 600000 + accessTokenTTL := 900 + refreshTokenTTL := 3600 + interactionTTL := 600 + grantTTL := 600 + oidcSessionTTL := 3600 + idTokenTTL := 900 doc := &models.ControlPlaneManifest{} doc.APIVersion = "edgelet.iofog.org/v1" doc.Kind = "ControlPlane" doc.Metadata.Name = "pot" doc.Metadata.Namespace = "cp-ns" - doc.Spec.Controller.Image = "ghcr.io/datasance/controller:3.7.0" + doc.Spec.Controller.Image = controlPlaneTestImage doc.Spec.Controller.Registry = ®istry doc.Spec.Controller.Port = &port - doc.Spec.ECNViewerPort = &viewerPort - doc.Spec.ECNViewerURL = "https://viewer.example" + doc.Spec.Controller.PublicURL = "https://controller.example.com" + doc.Spec.Controller.TrustProxy = &trustProxy + doc.Spec.Console.Port = &consolePort + doc.Spec.Console.URL = "https://console.example.com" doc.Spec.LogLevel = "info" - doc.Spec.Auth.URL = "https://auth.example/" - doc.Spec.Auth.Realm = "realm" - doc.Spec.Auth.RealmKey = "key" - doc.Spec.Auth.SSL = "external" - doc.Spec.Auth.ControllerClient = "pot-controller" - doc.Spec.Auth.ControllerSecret = "secret" - doc.Spec.Auth.ViewerClient = "ecn-viewer" + doc.Spec.Auth = &models.ControlPlaneAuthSpec{ + Mode: "external", + InsecureAllowHTTP: &insecureAllowHTTP, + InsecureAllowBootstrapLog: &insecureAllowBootstrapLog, + IssuerURL: "https://auth.example.com/realms/pot", + Client: &models.ControlPlaneAuthClient{ + ID: "pot-controller", + Secret: "secret", + }, + ConsoleClient: "ecn-viewer", + ConsoleClientEnabled: &consoleClientEnabled, + RateLimit: &models.ControlPlaneAuthRateLimit{ + Enabled: &rateLimitEnabled, + MaxRequestsPerWindow: &maxRequests, + WindowMs: &windowMs, + }, + SessionStore: &models.ControlPlaneAuthSessionStore{ + Type: "database", + TTLMs: &sessionTTLMs, + Secret: "session-secret", + }, + TokenTTL: &models.ControlPlaneAuthTokenTTL{ + AccessTokenTTLSeconds: &accessTokenTTL, + RefreshTokenTTLSeconds: &refreshTokenTTL, + }, + OIDCTTL: &models.ControlPlaneAuthOIDCTTL{ + InteractionTTLSeconds: &interactionTTL, + GrantTTLSeconds: &grantTTL, + SessionTTLSeconds: &oidcSessionTTL, + IDTokenTTLSeconds: &idTokenTTL, + }, + } doc.Spec.Database = &struct { Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` User string `yaml:"user,omitempty" json:"user,omitempty"` @@ -133,8 +185,8 @@ func TestBuildControllerEnv_FullProjection(t *testing.T) { } cert := base64.StdEncoding.EncodeToString([]byte("cert")) key := base64.StdEncoding.EncodeToString([]byte("key")) - doc.Spec.HTTPS = &models.ControlPlaneHTTPSConfig{ - Base64: &models.ControlPlaneHTTPSBase64{Cert: cert, Key: key}, + doc.Spec.TLS = &models.ControlPlaneTLSConfig{ + Base64: &models.ControlPlaneTLSBase64{Cert: cert, Key: key}, } env, err := BuildControllerEnv(doc, "uuid-full") @@ -142,13 +194,32 @@ func TestBuildControllerEnv_FullProjection(t *testing.T) { t.Fatalf("BuildControllerEnv: %v", err) } - assertEnv(t, env, "SERVER_PORT", "51121") - assertEnv(t, env, "VIEWER_PORT", "8008") - assertEnv(t, env, "VIEWER_URL", "https://viewer.example") + assertEnv(t, env, "CONTROLLER_PUBLIC_URL", "https://controller.example.com") + assertEnv(t, env, "TRUST_PROXY", "true") + assertEnv(t, env, "API_PORT", "51121") + assertEnv(t, env, "CONSOLE_PORT", "8008") + assertEnv(t, env, "CONSOLE_URL", "https://console.example.com") assertEnv(t, env, "LOG_LEVEL", "info") - assertEnv(t, env, "KC_URL", "https://auth.example/") - assertEnv(t, env, "KC_REALM", "realm") - assertEnv(t, env, "KC_CLIENT", "pot-controller") + assertEnv(t, env, "AUTH_MODE", "external") + assertEnv(t, env, "AUTH_INSECURE_ALLOW_HTTP", "false") + assertEnv(t, env, "AUTH_INSECURE_ALLOW_BOOTSTRAP_LOG", "false") + assertEnv(t, env, "OIDC_ISSUER_URL", "https://auth.example.com/realms/pot") + assertEnv(t, env, "OIDC_CLIENT_ID", "pot-controller") + assertEnv(t, env, "OIDC_CLIENT_SECRET", "secret") + assertEnv(t, env, "OIDC_CONSOLE_CLIENT_ID", "ecn-viewer") + assertEnv(t, env, "AUTH_CONSOLE_CLIENT_ENABLED", "true") + assertEnv(t, env, "AUTH_RATE_LIMIT_ENABLED", "true") + assertEnv(t, env, "AUTH_RATE_LIMIT_MAX_REQUESTS", "60") + assertEnv(t, env, "AUTH_RATE_LIMIT_WINDOW_MS", "60000") + assertEnv(t, env, "AUTH_SESSION_STORE_TYPE", "database") + assertEnv(t, env, "AUTH_SESSION_STORE_TTL_MS", "600000") + assertEnv(t, env, "AUTH_SESSION_SECRET", "session-secret") + assertEnv(t, env, "AUTH_ACCESS_TOKEN_TTL_SECONDS", "900") + assertEnv(t, env, "AUTH_REFRESH_TOKEN_TTL_SECONDS", "3600") + assertEnv(t, env, "AUTH_OIDC_INTERACTION_TTL_SECONDS", "600") + assertEnv(t, env, "AUTH_OIDC_GRANT_TTL_SECONDS", "600") + assertEnv(t, env, "AUTH_OIDC_SESSION_TTL_SECONDS", "3600") + assertEnv(t, env, "AUTH_OIDC_ID_TOKEN_TTL_SECONDS", "900") assertEnv(t, env, "DB_PROVIDER", "postgres") assertEnv(t, env, "DB_PORT", "5432") assertEnv(t, env, "DB_USE_SSL", "true") @@ -166,32 +237,56 @@ func TestBuildControllerEnv_FullProjection(t *testing.T) { assertEnv(t, env, "NATS_ENABLED", "true") assertEnv(t, env, "VAULT_ENABLED", "true") assertEnv(t, env, "VAULT_HASHICORP_ADDRESS", "http://vault:8200") - assertEnv(t, env, "SSL_BASE64_CERT", cert) - assertEnv(t, env, "SSL_BASE64_KEY", key) + assertEnv(t, env, "TLS_BASE64_CERT", cert) + assertEnv(t, env, "TLS_BASE64_KEY", key) } -func TestBuildControllerEnv_HTTPSPathFilenames(t *testing.T) { +func TestBuildControllerEnv_EmbeddedBootstrapProjection(t *testing.T) { + insecureAllowHTTP := true + doc := validControlPlaneDocForEnvTest() + doc.Spec.Auth.InsecureAllowHTTP = &insecureAllowHTTP + + env, err := BuildControllerEnv(doc, "uuid-bootstrap") + if err != nil { + t.Fatalf("BuildControllerEnv: %v", err) + } + assertEnv(t, env, "AUTH_MODE", "embedded") + assertEnv(t, env, "AUTH_INSECURE_ALLOW_HTTP", "true") + assertEnv(t, env, "OIDC_BOOTSTRAP_ADMIN_USERNAME", "admin") + assertEnv(t, env, "OIDC_BOOTSTRAP_ADMIN_PASSWORD", "AdminPass123!") +} + +func TestBuildControllerEnv_TLSPathFilenames(t *testing.T) { dir := t.TempDir() - for _, name := range []string{models.ControlPlaneHTTPSCertFilename, models.ControlPlaneHTTPSKeyFilename, models.ControlPlaneHTTPSCAFilename} { + for _, name := range []string{ + models.ControlPlaneTLSCertFilename, + models.ControlPlaneTLSKeyFilename, + models.ControlPlaneTLSCAFilename, + } { if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil { t.Fatal(err) } } - doc := &models.ControlPlaneManifest{} - doc.APIVersion = "edgelet.iofog.org/v1" - doc.Kind = "ControlPlane" - doc.Metadata.Name = "pot" - doc.Spec.Controller.Image = "ghcr.io/datasance/controller:3.7.0" - doc.Spec.HTTPS = &models.ControlPlaneHTTPSConfig{Path: dir} + doc := validControlPlaneDocForEnvTest() + doc.Spec.TLS = &models.ControlPlaneTLSConfig{Path: dir} env, err := BuildControllerEnv(doc, "uuid-tls") if err != nil { t.Fatalf("BuildControllerEnv: %v", err) } - assertEnv(t, env, "SSL_PATH_CERT", models.ControlPlaneHTTPSCertFilename) - assertEnv(t, env, "SSL_PATH_KEY", models.ControlPlaneHTTPSKeyFilename) - assertEnv(t, env, "SSL_PATH_INTERMEDIATE_CERT", models.ControlPlaneHTTPSCAFilename) + assertEnv(t, env, "TLS_PATH_CERT", models.ControlPlaneTLSCertFilename) + assertEnv(t, env, "TLS_PATH_KEY", models.ControlPlaneTLSKeyFilename) + assertEnv(t, env, "TLS_PATH_INTERMEDIATE_CERT", models.ControlPlaneTLSCAFilename) + assertEnv(t, env, "INTERMEDIATE_CERT", filepath.Join(ContainerCertMountPath, models.ControlPlaneTLSCAFilename)) +} + +func TestBuildControllerEnv_RequiresAuth(t *testing.T) { + doc := validControlPlaneDocForEnvTest() + doc.Spec.Auth = nil + if _, err := BuildControllerEnv(doc, "uuid-no-auth"); err == nil { + t.Fatal("expected auth validation error") + } } func assertEnv(t *testing.T, env map[string]string, key, want string) { diff --git a/internal/controlplane/runtime.go b/internal/controlplane/runtime.go index 28ea794..095ad1a 100644 --- a/internal/controlplane/runtime.go +++ b/internal/controlplane/runtime.go @@ -9,12 +9,12 @@ import ( const ( HostAPIPort = 51121 - HostViewerPort = 80 + HostConsolePort = 80 DefaultContainerAPIPort = 51121 - DefaultViewerPort = 8008 + DefaultConsolePort = 8008 VolumeDBName = "iofog-controller-db" VolumeLogName = "iofog-controller-log" - ContainerDBPath = "/home/runner/.npm-global/lib/node_modules/@datasance/iofogcontroller/src/data/sqlite_files/" + ContainerDBPath = "/home/runner/.npm-global/lib/node_modules/controller/src/data/sqlite_files/" ContainerLogPath = "/var/log/iofog-controller" ContainerCertMountPath = "/etc/iofog/controller-cert/" ) @@ -51,13 +51,13 @@ func BuildMicroserviceFromControlPlane(doc *models.ControlPlaneManifest, control if doc.Spec.Controller.Port != nil { apiPort = *doc.Spec.Controller.Port } - viewerPort := DefaultViewerPort - if doc.Spec.ECNViewerPort != nil { - viewerPort = *doc.Spec.ECNViewerPort + consolePort := DefaultConsolePort + if doc.Spec.Console.Port != nil { + consolePort = *doc.Spec.Console.Port } ms.PortMappings = append(ms.PortMappings, models.NewPortMapping(HostAPIPort, apiPort, false), - models.NewPortMapping(HostViewerPort, viewerPort, false), + models.NewPortMapping(HostConsolePort, consolePort, false), ) ms.CapAdd, ms.CapDrop = mergeControlPlaneCapabilities(nil, nil) @@ -67,8 +67,8 @@ func BuildMicroserviceFromControlPlane(doc *models.ControlPlaneManifest, control models.NewVolumeMapping(VolumeLogName, ContainerLogPath, "rw", models.VolumeMappingTypeVolume), ) - if doc.Spec.HTTPS != nil { - if path := strings.TrimSpace(doc.Spec.HTTPS.Path); path != "" { + if doc.Spec.TLS != nil { + if path := strings.TrimSpace(doc.Spec.TLS.Path); path != "" { ms.VolumeMappings = append(ms.VolumeMappings, models.NewVolumeMapping( path, ContainerCertMountPath, diff --git a/internal/controlplane/runtime_test.go b/internal/controlplane/runtime_test.go index ef4178f..1d6fc16 100644 --- a/internal/controlplane/runtime_test.go +++ b/internal/controlplane/runtime_test.go @@ -16,16 +16,17 @@ func validControlPlaneManifestForRuntimeTest() *models.ControlPlaneManifest { doc.Kind = "ControlPlane" doc.Metadata.Name = "pot" doc.Metadata.Namespace = "default" - doc.Spec.Controller.Image = "ghcr.io/datasance/controller:3.7.0" + doc.Spec.Controller.Image = controlPlaneTestImage + doc.Spec.Auth = models.ValidEmbeddedAuthForTest() return doc } func TestBuildMicroserviceFromControlPlaneLaunchSpec(t *testing.T) { doc := validControlPlaneManifestForRuntimeTest() port := 51121 - viewer := 8008 + console := 8008 doc.Spec.Controller.Port = &port - doc.Spec.ECNViewerPort = &viewer + doc.Spec.Console.Port = &console ms, err := BuildMicroserviceFromControlPlane(doc, "cp-uuid-1", doc.ManifestControllerImage()) if err != nil { @@ -51,8 +52,8 @@ func TestBuildMicroserviceFromControlPlaneLaunchSpec(t *testing.T) { if ms.PortMappings[0].Outside != HostAPIPort || ms.PortMappings[0].Inside != port { t.Fatalf("unexpected API port mapping: %+v", ms.PortMappings[0]) } - if ms.PortMappings[1].Outside != HostViewerPort || ms.PortMappings[1].Inside != viewer { - t.Fatalf("unexpected viewer port mapping: %+v", ms.PortMappings[1]) + if ms.PortMappings[1].Outside != HostConsolePort || ms.PortMappings[1].Inside != console { + t.Fatalf("unexpected console port mapping: %+v", ms.PortMappings[1]) } if len(ms.VolumeMappings) != 2 { @@ -115,16 +116,16 @@ func TestBuildMicroserviceFromControlPlaneLaunchSpec(t *testing.T) { } } -func TestBuildMicroserviceFromControlPlaneHTTPSPathMount(t *testing.T) { +func TestBuildMicroserviceFromControlPlaneTLSPathMount(t *testing.T) { dir := t.TempDir() - for _, name := range []string{models.ControlPlaneHTTPSCertFilename, models.ControlPlaneHTTPSKeyFilename} { + for _, name := range []string{models.ControlPlaneTLSCertFilename, models.ControlPlaneTLSKeyFilename} { if err := os.WriteFile(filepath.Join(dir, name), []byte("test"), 0o600); err != nil { t.Fatalf("write cert file: %v", err) } } doc := validControlPlaneManifestForRuntimeTest() - doc.Spec.HTTPS = &models.ControlPlaneHTTPSConfig{ + doc.Spec.TLS = &models.ControlPlaneTLSConfig{ Path: dir, } @@ -139,7 +140,7 @@ func TestBuildMicroserviceFromControlPlaneHTTPSPathMount(t *testing.T) { if certMount.Type != models.VolumeMappingTypeBind { t.Fatalf("expected bind mount for cert path, got %q", certMount.Type) } - if certMount.HostDestination != dir || certMount.ContainerDestination != ContainerCertMountPath { + if certMount.HostDestination != filepath.Clean(dir) || certMount.ContainerDestination != ContainerCertMountPath { t.Fatalf("unexpected cert mount: %+v", certMount) } if certMount.AccessMode != "ro" { @@ -147,6 +148,23 @@ func TestBuildMicroserviceFromControlPlaneHTTPSPathMount(t *testing.T) { } } +func TestBuildMicroserviceFromControlPlaneTLSPathRejectsTraversal(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{models.ControlPlaneTLSCertFilename, models.ControlPlaneTLSKeyFilename} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("test"), 0o600); err != nil { + t.Fatalf("write cert file: %v", err) + } + } + doc := validControlPlaneManifestForRuntimeTest() + doc.Spec.TLS = &models.ControlPlaneTLSConfig{ + Path: dir + string(filepath.Separator) + ".." + string(filepath.Separator) + filepath.Base(dir), + } + if _, err := BuildMicroserviceFromControlPlane(doc, "cp-uuid-3", doc.ManifestControllerImage()); err == nil || + !strings.Contains(err.Error(), "traversal") { + t.Fatalf("expected traversal rejection, got: %v", err) + } +} + func TestMergeControlPlaneCapabilitiesAlwaysAddsNetRaw(t *testing.T) { add, drop := mergeControlPlaneCapabilities(nil, []string{"NET_RAW", "SYS_ADMIN"}) if len(drop) != 1 || drop[0] != "SYS_ADMIN" { diff --git a/internal/edgeletapi/handlers/config_reload_test.go b/internal/edgeletapi/handlers/config_reload_test.go new file mode 100644 index 0000000..7f08ebd --- /dev/null +++ b/internal/edgeletapi/handlers/config_reload_test.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/eclipse-iofog/edgelet/internal/buildmeta" + "github.com/eclipse-iofog/edgelet/internal/config" + "github.com/eclipse-iofog/edgelet/internal/constants" + "github.com/eclipse-iofog/edgelet/internal/models" + "github.com/eclipse-iofog/edgelet/internal/utils" + "github.com/eclipse-iofog/edgelet/internal/utils/logging" + "github.com/sirupsen/logrus" +) + +func TestHandleConfigPatch_LogLevelTriggersFullReload(t *testing.T) { + cfg := setupConfigForGPSTests(t) + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + engine := constants.EngineDocker + engineURL := "unix:///var/run/docker.sock" + if buildmeta.HasEmbeddedEngine() { + engine = constants.EngineEdgelet + engineURL = constants.EdgeletEngineSocketURL() + } + + yaml := `currentProfile: default +profiles: + default: + logLevel: "INFO" + diskLimit: "10" + memoryLimit: "4096" + cpuLimit: "80" + containerEngine: "` + engine + `" + containerEngineUrl: "` + engineURL + `" +` + if err := os.WriteFile(configPath, []byte(yaml), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + cfg.SetConfigPath(configPath) + + yamlCfg := models.NewYamlConfig() + yamlCfg.CurrentProfile = utils.ConfigSwitcherStateDefault.FullValue() + profile := models.NewProfileConfig() + profile.SetProperty("logLevel", "INFO") + profile.SetProperty("diskLimit", "10") + profile.SetProperty("memoryLimit", "4096") + profile.SetProperty("cpuLimit", "80") + profile.SetProperty("containerEngine", engine) + profile.SetProperty("containerEngineUrl", engineURL) + yamlCfg.Profiles[utils.ConfigSwitcherStateDefault.FullValue()] = profile + cfg.SetYamlConfig(yamlCfg) + cfg.LogLevel = "INFO" + + logDir := filepath.Join(tmpDir, "logs") + if err := os.MkdirAll(logDir, 0o700); err != nil { + t.Fatalf("mkdir logs: %v", err) + } + if err := logging.SetupLogger(logDir, 10, 10, "INFO"); err != nil { + t.Fatalf("setup logger: %v", err) + } + + notifyCalled := false + cfg.SetReloadCallback(func() error { + return config.FullReload(config.ReloadHooks{ + ConfigPath: configPath, + NotifyModules: func() error { + notifyCalled = true + return nil + }, + }) + }) + + handler := NewEdgeletAPIHandler() + req := httptest.NewRequest(http.MethodPatch, "/v1/system/config", bytes.NewBufferString(`{"set":{"logLevel":"DEBUG"}}`)) + rec := httptest.NewRecorder() + handler.HandleConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if !notifyCalled { + t.Fatal("expected full reload to notify modules") + } + if logging.GetInstance().GetLevel() != logrus.DebugLevel { + t.Fatalf("expected log level DEBUG after PATCH reload, got %v", logging.GetInstance().GetLevel()) + } +} + +func TestHandleSystemReload_Returns500OnReloadFailure(t *testing.T) { + cfg := setupConfigForGPSTests(t) + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(`currentProfile: default +profiles: + default: + logLevel: "NOT_A_LEVEL" + diskLimit: "10" + memoryLimit: "4096" + cpuLimit: "80" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + cfg.SetReloadCallback(func() error { + return config.FullReload(config.ReloadHooks{ConfigPath: configPath}) + }) + + handler := NewEdgeletAPIHandler() + req := httptest.NewRequest(http.MethodPost, "/v1/system/reload", nil) + rec := httptest.NewRecorder() + handler.HandleSystemReload(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/edgeletapi/handlers/controlplane.go b/internal/edgeletapi/handlers/controlplane.go index 21b4672..b4604e9 100644 --- a/internal/edgeletapi/handlers/controlplane.go +++ b/internal/edgeletapi/handlers/controlplane.go @@ -93,6 +93,10 @@ func (h *EdgeletAPIHandler) handleSystemControlPlaneManifest(w http.ResponseWrit func (h *EdgeletAPIHandler) handleSystemControlPlaneDelete(w http.ResponseWriter) { if err := h.facade.DeleteControlPlane(); err != nil { + if runtimeapi.IsControlPlaneDeleteBlocked(err) { + writeAPIError(w, http.StatusForbidden, ErrCodeForbidden, err.Error(), nil) + return + } if errors.Is(err, runtimeapi.ErrControlPlaneNotFound) { writeAPIError(w, http.StatusNotFound, ErrCodeNotFound, "control plane deployment not found", nil) return diff --git a/internal/edgeletapi/handlers/controlplane_test.go b/internal/edgeletapi/handlers/controlplane_test.go index 1476678..27e3570 100644 --- a/internal/edgeletapi/handlers/controlplane_test.go +++ b/internal/edgeletapi/handlers/controlplane_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/eclipse-iofog/edgelet/internal/fieldagent" "github.com/eclipse-iofog/edgelet/internal/models" "github.com/eclipse-iofog/edgelet/internal/processmanager" "github.com/eclipse-iofog/edgelet/internal/store" @@ -108,6 +109,7 @@ var _ engine.ContainerEngine = (*controlPlaneAPITestEngine)(nil) func setupControlPlaneAPITest(t *testing.T) (*EdgeletAPIHandler, *controlPlaneAPITestEngine) { t.Helper() ensureControlPlaneStoreDB(t) + fieldagent.GetInstance().SetControllerStatus(models.ControllerStatusNotProvisioned) eng := &controlPlaneAPITestEngine{} processmanager.ConfigureEngineForTest(eng) return NewEdgeletAPIHandler(), eng @@ -227,8 +229,8 @@ func TestControlPlaneHandlers_ApplyGetManifestDelete(t *testing.T) { if !strings.Contains(manifestYAML, "***") { t.Fatalf("expected masked secret marker, got %q", manifestYAML) } - if strings.Contains(manifestYAML, "super-secret") { - t.Fatal("expected controllerSecret value to be redacted") + if strings.Contains(manifestYAML, "SuperSecret12!") { + t.Fatal("expected bootstrap password value to be redacted") } deleteReq := httptest.NewRequest(http.MethodDelete, "/v1/system/controlplane", nil) @@ -409,8 +411,11 @@ metadata: namespace: default spec: controller: - image: ghcr.io/datasance/controller:3.7.0 + image: ghcr.io/datasance/controller:3.8.0-beta.0 auth: - controllerSecret: super-secret + mode: embedded + bootstrap: + username: admin + password: SuperSecret12! ` } diff --git a/internal/fieldagent/agent.go b/internal/fieldagent/agent.go index f38f855..3573c2b 100644 --- a/internal/fieldagent/agent.go +++ b/internal/fieldagent/agent.go @@ -21,6 +21,7 @@ import ( "github.com/eclipse-iofog/edgelet/internal/store" "github.com/eclipse-iofog/edgelet/internal/utils" "github.com/eclipse-iofog/edgelet/internal/utils/logging" + "github.com/eclipse-iofog/edgelet/internal/version" "github.com/eclipse-iofog/edgelet/internal/volumemount" ) @@ -77,6 +78,8 @@ type FieldAgent struct { // test hook: allows status POST override in unit tests. postStatusFn func(ctx context.Context, status map[string]any) error + + controllerRegister *controllerRegisterState } var ( @@ -97,6 +100,7 @@ func GetInstance() *FieldAgent { currentMicroservices: make([]*models.Microservice, 0), registries: make([]*models.Registry, 0), containerConfigMap: make(map[string]string), + controllerRegister: newControllerRegisterState(), } }) return instance @@ -115,6 +119,8 @@ func (fa *FieldAgent) Start() error { fa.apiClient = apiClient logging.LogDebug(moduleName, "API client initialized") + version.GetInstance().SetVersionRefreshFunc(fa.fetchControllerVersion) + // Initialize Orchestrator fa.orchestrator = NewOrchestrator(apiClient) logging.LogDebug(moduleName, "Orchestrator initialized") @@ -131,6 +137,7 @@ func (fa *FieldAgent) Start() error { cfg.PrivateKey = "" auth.GetJWTManager().Reset() } + fa.hydrateControllerRegisterState() // Enforce invariant: unprovisioned agent cannot keep edge guard enabled. if cfg.IOFogUUID == "" && cfg.EdgeGuardFrequency > 0 { @@ -191,6 +198,8 @@ func (fa *FieldAgent) Start() error { return fmt.Errorf("failed to reconcile edgelet-api JWT token: %w", err) } + fa.maybeReprovisionAfterOTA() + // Ping controller logging.LogDebug(moduleName, "Pinging controller to verify connectivity") isConnected := fa.ping() @@ -268,13 +277,14 @@ func (fa *FieldAgent) Start() error { // Start background workers logging.LogDebug(moduleName, "Starting background workers") - fa.wg.Add(6) + fa.wg.Add(7) go fa.pingControllerWorker() go fa.runChangesWorker() go fa.postStatusWorker() go fa.upgradeScanWorker() go fa.localAPITokenRotationWorker() go fa.serviceAccountTokenRotationWorker() + go fa.controllerRegisterWorker() logging.LogInfo(moduleName, "Field Agent started successfully") return nil @@ -341,14 +351,19 @@ func (fa *FieldAgent) SetProcessManager(pm *processmanager.ProcessManager) { fa.processManager = pm } +// SetControllerStatus updates the agent controller connection status. +func (fa *FieldAgent) SetControllerStatus(status models.ControllerStatus) { + fa.state.SetControllerStatus(status) +} + // NotProvisioned checks if the agent is not provisioned func (fa *FieldAgent) NotProvisioned() bool { logging.LogDebug(moduleName, "Started checking provisioned") status := fa.state.GetControllerStatus() notProvisioned := status == models.ControllerStatusNotProvisioned - if notProvisioned { - logging.LogWarn(moduleName, "Not provisioned") - } + // if notProvisioned { + // logging.LogWarn(moduleName, "Not provisioned") + // } logging.LogDebug(moduleName, fmt.Sprintf("Finished checking provisioned: %v", !notProvisioned)) return notProvisioned } @@ -451,7 +466,7 @@ func (fa *FieldAgent) ping() bool { if fa.NotProvisioned() { logging.LogDebug(moduleName, "Agent not provisioned, skipping ping") - logging.LogInfo(moduleName, "Finished Ping: false (not provisioned)") + // logging.LogInfo(moduleName, "Finished Ping: false (not provisioned)") return false } @@ -887,6 +902,8 @@ func (fa *FieldAgent) DeprovisionWithScope(clearCredentials bool, scope string) // Clear service-account token projections and metadata. serviceaccount.GetInstance().Clear() + fa.resetControllerRegisterState() + // Run again after runtime cleanup for best-effort convergence. fa.clearSQLiteCacheTablesOnDeprovision(preserveLocal) diff --git a/internal/fieldagent/api_client.go b/internal/fieldagent/api_client.go index 6547aee..badaacf 100644 --- a/internal/fieldagent/api_client.go +++ b/internal/fieldagent/api_client.go @@ -160,6 +160,27 @@ func (c *APIClient) Request(ctx context.Context, command string, requestType Req return nil, fmt.Errorf("request failed after %d attempts: %w", maxRetries+1, lastErr) } +const maxErrorResponseBody = 4096 + +func readLimitedResponseBody(resp *http.Response) string { + if resp == nil || resp.Body == nil { + return "" + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorResponseBody)) + if err != nil { + return fmt.Sprintf("", err) + } + return strings.TrimSpace(string(body)) +} + +func controllerHTTPError(status int, label, body string) error { + body = strings.TrimSpace(body) + if body == "" { + return fmt.Errorf("%s: status %d", label, status) + } + return fmt.Errorf("%s: status %d: %s", label, status, body) +} + // doRequest performs a single HTTP request to the controller func (c *APIClient) doRequest(ctx context.Context, command string, requestType RequestType, queryParams map[string]string, body any) (map[string]any, error) { url := c.baseURL + "/agent/" + command @@ -247,22 +268,22 @@ func (c *APIClient) doRequest(ctx context.Context, command string, requestType R return make(map[string]any), nil case http.StatusUnauthorized: // Token invalid - trigger deprovision - return nil, errors.New("unauthorized: invalid JWT token") + return nil, controllerHTTPError(resp.StatusCode, "unauthorized: invalid JWT token", readLimitedResponseBody(resp)) case http.StatusNotFound: - return nil, errors.New("not found: controller endpoint not found") + return nil, controllerHTTPError(resp.StatusCode, "not found: controller endpoint not found", readLimitedResponseBody(resp)) case http.StatusBadRequest: - return nil, errors.New("bad request: invalid request") + return nil, controllerHTTPError(resp.StatusCode, "bad request", readLimitedResponseBody(resp)) case http.StatusForbidden: - return nil, errors.New("forbidden: access forbidden") + return nil, controllerHTTPError(resp.StatusCode, "forbidden: access forbidden", readLimitedResponseBody(resp)) case http.StatusInternalServerError: - return nil, errors.New("internal server error") + return nil, controllerHTTPError(resp.StatusCode, "internal server error", readLimitedResponseBody(resp)) default: if resp.StatusCode >= 400 && resp.StatusCode < 500 { - return nil, fmt.Errorf("client error: status %d", resp.StatusCode) + return nil, controllerHTTPError(resp.StatusCode, "client error", readLimitedResponseBody(resp)) } else if resp.StatusCode >= 500 { - return nil, fmt.Errorf("server error: status %d", resp.StatusCode) + return nil, controllerHTTPError(resp.StatusCode, "server error", readLimitedResponseBody(resp)) } - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + return nil, controllerHTTPError(resp.StatusCode, "unexpected status code", readLimitedResponseBody(resp)) } } diff --git a/internal/fieldagent/api_client_test.go b/internal/fieldagent/api_client_test.go new file mode 100644 index 0000000..64fbc9f --- /dev/null +++ b/internal/fieldagent/api_client_test.go @@ -0,0 +1,46 @@ +package fieldagent + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAPIClient_BadRequestIncludesResponseBody(t *testing.T) { + const wantDetail = "Required field 'internal' is missing" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"` + wantDetail + `"}`)) + })) + t.Cleanup(srv.Close) + + client := &APIClient{ + baseURL: srv.URL, + httpClient: srv.Client(), + } + + _, err := client.Request(context.Background(), "provision", POST, nil, nil) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "bad request: status 400") { + t.Fatalf("unexpected error prefix: %v", err) + } + if !strings.Contains(err.Error(), wantDetail) { + t.Fatalf("expected controller body in error, got: %v", err) + } +} + +func TestControllerHTTPError(t *testing.T) { + err := controllerHTTPError(http.StatusBadRequest, "bad request", `{"message":"invalid"}`) + if err == nil || !strings.Contains(err.Error(), "bad request: status 400: {\"message\":\"invalid\"}") { + t.Fatalf("unexpected error: %v", err) + } + + err = controllerHTTPError(http.StatusForbidden, "forbidden", "") + if err == nil || !strings.Contains(err.Error(), "forbidden: status 403") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/fieldagent/changes.go b/internal/fieldagent/changes.go index ac37dae..7102457 100644 --- a/internal/fieldagent/changes.go +++ b/internal/fieldagent/changes.go @@ -216,14 +216,19 @@ func (fa *FieldAgent) changeVersion() error { return fmt.Errorf("unable to get version command: %w", err) } - // Extract version command from result - if versionData, ok := result["versionCommand"].(map[string]any); ok { - versionHandler := version.GetInstance() - if err := versionHandler.ChangeVersion(versionData); err != nil { - return fmt.Errorf("failed to change version: %w", err) - } - } else { + // Extract version command from result (flat v3.8 or legacy nested). + actionData, err := version.NormalizeVersionResponse(result) + if err != nil { + return fmt.Errorf("failed to normalize version response: %w", err) + } + if actionData == nil { logging.LogDebug(moduleName, fmt.Sprintf("Version change result: %+v", result)) + return nil + } + + versionHandler := version.GetInstance() + if err := versionHandler.ChangeVersion(actionData); err != nil { + return fmt.Errorf("failed to change version: %w", err) } logging.LogInfo(moduleName, "Finished change version operation, received from ioFog controller") diff --git a/internal/fieldagent/controller_reconcile.go b/internal/fieldagent/controller_reconcile.go new file mode 100644 index 0000000..4a33caf --- /dev/null +++ b/internal/fieldagent/controller_reconcile.go @@ -0,0 +1,152 @@ +package fieldagent + +import ( + "fmt" + "strings" + + "github.com/eclipse-iofog/edgelet/internal/models" + "github.com/eclipse-iofog/edgelet/internal/store" + "github.com/eclipse-iofog/edgelet/internal/utils/logging" + "github.com/eclipse-iofog/edgelet/pkg/imageref" + "gopkg.in/yaml.v3" +) + +func (fa *FieldAgent) reconcileControllerMicroservice(microservices []*models.Microservice) { + if len(microservices) == 0 { + return + } + db := store.GetInstance() + if db.Conn() == nil { + return + } + cp, found, err := db.GetSystemControlPlane() + if err != nil || !found || cp == nil { + return + } + cp.NormalizeDefaults() + cpUUID := strings.TrimSpace(cp.ControllerUUID) + if cpUUID == "" { + return + } + + var controllerMS *models.Microservice + for _, ms := range microservices { + if ms == nil { + continue + } + if strings.TrimSpace(ms.MicroserviceUUID) != cpUUID { + continue + } + if ms.IsController { + controllerMS = ms + break + } + controllerMS = ms + ms.IsController = true + } + if controllerMS == nil { + return + } + + if controllerMS.Delete { + logging.LogWarn(moduleName, fmt.Sprintf( + "ignoring delete request for controller microservice uuid=%s while control plane deployment exists", + cpUUID, + )) + controllerMS.Delete = false + } + + changed := false + pullOnRecreate := false + if controllerMS.Rebuild { + controllerMS.Rebuild = false + if fa.controllerRegister != nil && + fa.controllerRegister.isSucceeded(cpUUID) && + !fa.controllerRegister.isInitialRebuildSkipped(cpUUID) { + fa.controllerRegister.markInitialRebuildSkipped(cpUUID) + fa.persistControllerInitialRebuildSkipped(cpUUID) + logging.LogDebug(moduleName, fmt.Sprintf( + "skipping initial controller rebuild after register uuid=%s", + cpUUID, + )) + } else { + changed = true + pullOnRecreate = true + } + } + + potImage := strings.TrimSpace(controllerMS.ImageName) + cpImage := strings.TrimSpace(cp.Image) + if potImage != "" && cpImage != "" { + doc, parseErr := models.ParseControlPlaneManifest(cp.ManifestYAML) + if parseErr != nil { + logging.LogWarn(moduleName, fmt.Sprintf("controller reconcile manifest parse failed: %v", parseErr)) + return + } + registry := resolveControllerReconcileRegistry(controllerMS.RegistryID, doc) + fromCache := strings.EqualFold(strings.TrimSpace(registry.URL), "from_cache") + imageChanged := !imageref.Match(potImage, cpImage, registry.URL, fromCache) + registryChanged := controllerRegistryDrift(controllerMS.RegistryID, doc) + if imageChanged || registryChanged { + if imageChanged { + doc.Spec.Controller.Image = potImage + cp.Image = potImage + } + if registryChanged { + regID := controllerMS.RegistryID + doc.Spec.Controller.Registry = ®ID + } + manifestYAML, marshalErr := yaml.Marshal(doc) + if marshalErr != nil { + logging.LogWarn(moduleName, fmt.Sprintf("controller reconcile manifest marshal failed: %v", marshalErr)) + return + } + cp.ManifestYAML = string(manifestYAML) + changed = true + } + } + + if !changed { + return + } + + cp.Generation++ + cp.LastTransitionAt = cp.LastReconcileAt + if err := db.UpsertSystemControlPlane(cp); err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("controller reconcile persist failed uuid=%s err=%v", cpUUID, err)) + return + } + if pullOnRecreate && fa.processManager != nil { + fa.processManager.SetControlPlanePullOnRecreate(true) + } + if fa.processManager != nil { + fa.processManager.Update() + } + logging.LogInfo(moduleName, fmt.Sprintf("merged controller microservice spec uuid=%s generation=%d", cpUUID, cp.Generation)) +} + +func resolveControllerReconcileRegistry(potRegistryID int, doc *models.ControlPlaneManifest) *models.Registry { + regID := potRegistryID + if regID <= 0 && doc != nil { + if id, ok := doc.ControllerRegistryID(); ok { + regID = id + } + } + if regID > 0 { + if reg, err := store.GetInstance().GetLocalRegistry(regID); err == nil && reg != nil { + return reg + } + } + return models.NewRegistry(2, "from_cache", true, "", "", "") +} + +func controllerRegistryDrift(potRegistryID int, doc *models.ControlPlaneManifest) bool { + if potRegistryID <= 0 || doc == nil { + return false + } + manifestRegID, ok := doc.ControllerRegistryID() + if !ok { + return true + } + return potRegistryID != manifestRegID +} diff --git a/internal/fieldagent/controller_reconcile_test.go b/internal/fieldagent/controller_reconcile_test.go new file mode 100644 index 0000000..72085ea --- /dev/null +++ b/internal/fieldagent/controller_reconcile_test.go @@ -0,0 +1,317 @@ +package fieldagent + +import ( + "testing" + + "github.com/eclipse-iofog/edgelet/internal/models" + "github.com/eclipse-iofog/edgelet/internal/store" +) + +func openFieldAgentTestDB(t *testing.T) { + t.Helper() + db := store.GetInstance() + _ = db.Close() + if err := db.Open(t.TempDir()); err != nil { + t.Fatalf("open sqlite DB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) +} + +func minimalControlPlaneForReconcileTest(uuid string) *models.ControlPlaneDeployment { + return &models.ControlPlaneDeployment{ + ControllerUUID: uuid, + Namespace: "default", + Name: "pot", + ManifestYAML: minimalReconcileManifestYAML(), + DesiredState: "running", + Generation: 1, + ObservedGeneration: 1, + RuntimeState: "running", + Image: "ghcr.io/datasance/controller:3.8.0-beta.0", + } +} + +func minimalReconcileManifestYAML() string { + return `apiVersion: edgelet.iofog.org/v1 +kind: ControlPlane +metadata: + name: pot + namespace: default +spec: + controller: + image: ghcr.io/datasance/controller:3.8.0-beta.0 + auth: + mode: embedded + bootstrap: + username: admin + password: AdminPass123! +` +} + +func TestReconcileControllerMicroservice_SkipsInitialRebuildAfterRegister(t *testing.T) { + openFieldAgentTestDB(t) + + const uuid = "cp-reconcile-skip" + cp := minimalControlPlaneForReconcileTest(uuid) + if err := store.GetInstance().UpsertSystemControlPlane(cp); err != nil { + t.Fatalf("upsert control plane: %v", err) + } + + fa := &FieldAgent{ + controllerRegister: newControllerRegisterState(), + } + fa.controllerRegister.markSucceeded(uuid) + + msList := []*models.Microservice{{ + MicroserviceUUID: uuid, + IsController: true, + Rebuild: true, + ImageName: cp.Image, + }} + fa.reconcileControllerMicroservice(msList) + + got, found, err := store.GetInstance().GetSystemControlPlane() + if err != nil || !found { + t.Fatalf("get control plane: found=%v err=%v", found, err) + } + if got.Generation != 1 { + t.Fatalf("expected generation unchanged at 1, got %d", got.Generation) + } + if !fa.controllerRegister.isInitialRebuildSkipped(uuid) { + t.Fatal("expected initial rebuild skip marker") + } + + got, found, err = store.GetInstance().GetSystemControlPlane() + if err != nil || !found { + t.Fatalf("get control plane after skip: found=%v err=%v", found, err) + } + if !got.InitialRebuildSkipped { + t.Fatal("expected initial_rebuild_skipped persisted in sqlite") + } + if msList[0].Rebuild { + t.Fatal("expected rebuild flag cleared on microservice") + } +} + +func TestReconcileControllerMicroservice_HonorsLaterRebuild(t *testing.T) { + openFieldAgentTestDB(t) + + const uuid = "cp-reconcile-pull" + cp := minimalControlPlaneForReconcileTest(uuid) + if err := store.GetInstance().UpsertSystemControlPlane(cp); err != nil { + t.Fatalf("upsert control plane: %v", err) + } + + fa := &FieldAgent{ + controllerRegister: newControllerRegisterState(), + } + fa.controllerRegister.markSucceeded(uuid) + fa.controllerRegister.markInitialRebuildSkipped(uuid) + + msList := []*models.Microservice{{ + MicroserviceUUID: uuid, + IsController: true, + Rebuild: true, + ImageName: cp.Image, + }} + fa.reconcileControllerMicroservice(msList) + + got, found, err := store.GetInstance().GetSystemControlPlane() + if err != nil || !found { + t.Fatalf("get control plane: found=%v err=%v", found, err) + } + if got.Generation != 2 { + t.Fatalf("expected generation 2, got %d", got.Generation) + } +} + +func TestReconcileControllerMicroservice_IgnoresEquivalentDockerHubImageRef(t *testing.T) { + openFieldAgentTestDB(t) + if err := store.GetInstance().EnsureDefaultLocalRegistries(); err != nil { + t.Fatalf("seed registries: %v", err) + } + + const uuid = "cp-reconcile-image-alias" + cp := &models.ControlPlaneDeployment{ + ControllerUUID: uuid, + Namespace: "default", + Name: "pot", + ManifestYAML: `apiVersion: edgelet.iofog.org/v1 +kind: ControlPlane +metadata: + name: pot + namespace: default +spec: + controller: + image: emirhandurmus/controller:3.8.0-beta.1 + registry: 1 + auth: + mode: embedded + bootstrap: + username: admin + password: AdminPass123! +`, + DesiredState: "running", + Generation: 1, + ObservedGeneration: 1, + RuntimeState: "running", + Image: "docker.io/emirhandurmus/controller:3.8.0-beta.1", + } + if err := store.GetInstance().UpsertSystemControlPlane(cp); err != nil { + t.Fatalf("upsert control plane: %v", err) + } + + fa := &FieldAgent{} + msList := []*models.Microservice{{ + MicroserviceUUID: uuid, + IsController: true, + ImageName: "emirhandurmus/controller:3.8.0-beta.1", + RegistryID: 1, + }} + fa.reconcileControllerMicroservice(msList) + + got, found, err := store.GetInstance().GetSystemControlPlane() + if err != nil || !found { + t.Fatalf("get control plane: found=%v err=%v", found, err) + } + if got.Generation != 1 { + t.Fatalf("expected generation unchanged at 1, got %d", got.Generation) + } +} + +func TestReconcileControllerMicroservice_BumpsGenerationOnImageChange(t *testing.T) { + openFieldAgentTestDB(t) + if err := store.GetInstance().EnsureDefaultLocalRegistries(); err != nil { + t.Fatalf("seed registries: %v", err) + } + + const uuid = "cp-reconcile-image-change" + cp := &models.ControlPlaneDeployment{ + ControllerUUID: uuid, + Namespace: "default", + Name: "pot", + ManifestYAML: `apiVersion: edgelet.iofog.org/v1 +kind: ControlPlane +metadata: + name: pot + namespace: default +spec: + controller: + image: emirhandurmus/controller:3.8.0-beta.1 + registry: 1 + auth: + mode: embedded + bootstrap: + username: admin + password: AdminPass123! +`, + DesiredState: "running", + Generation: 1, + ObservedGeneration: 1, + RuntimeState: "running", + Image: "docker.io/emirhandurmus/controller:3.8.0-beta.1", + } + if err := store.GetInstance().UpsertSystemControlPlane(cp); err != nil { + t.Fatalf("upsert control plane: %v", err) + } + + fa := &FieldAgent{} + msList := []*models.Microservice{{ + MicroserviceUUID: uuid, + IsController: true, + ImageName: "emirhandurmus/controller:3.8.0-beta.2", + RegistryID: 1, + }} + fa.reconcileControllerMicroservice(msList) + + got, found, err := store.GetInstance().GetSystemControlPlane() + if err != nil || !found { + t.Fatalf("get control plane: found=%v err=%v", found, err) + } + if got.Generation != 2 { + t.Fatalf("expected generation 2, got %d", got.Generation) + } + if got.Image != "emirhandurmus/controller:3.8.0-beta.2" { + t.Fatalf("expected merged pot image ref, got %q", got.Image) + } +} + +func TestReconcileControllerMicroservice_BumpsGenerationOnRegistryDrift(t *testing.T) { + openFieldAgentTestDB(t) + if err := store.GetInstance().EnsureDefaultLocalRegistries(); err != nil { + t.Fatalf("seed registries: %v", err) + } + + const uuid = "cp-reconcile-registry-drift" + cp := &models.ControlPlaneDeployment{ + ControllerUUID: uuid, + Namespace: "default", + Name: "pot", + ManifestYAML: `apiVersion: edgelet.iofog.org/v1 +kind: ControlPlane +metadata: + name: pot + namespace: default +spec: + controller: + image: emirhandurmus/controller:3.8.0-beta.1 + registry: 2 + auth: + mode: embedded + bootstrap: + username: admin + password: AdminPass123! +`, + DesiredState: "running", + Generation: 1, + ObservedGeneration: 1, + RuntimeState: "running", + Image: "emirhandurmus/controller:3.8.0-beta.1", + } + if err := store.GetInstance().UpsertSystemControlPlane(cp); err != nil { + t.Fatalf("upsert control plane: %v", err) + } + + fa := &FieldAgent{} + msList := []*models.Microservice{{ + MicroserviceUUID: uuid, + IsController: true, + ImageName: "emirhandurmus/controller:3.8.0-beta.1", + RegistryID: 1, + }} + fa.reconcileControllerMicroservice(msList) + + got, found, err := store.GetInstance().GetSystemControlPlane() + if err != nil || !found { + t.Fatalf("get control plane: found=%v err=%v", found, err) + } + if got.Generation != 2 { + t.Fatalf("expected generation 2, got %d", got.Generation) + } +} + +func TestControllerRegistryDrift(t *testing.T) { + regTwo := 2 + doc := &models.ControlPlaneManifest{ + Spec: models.ControlPlaneManifestSpec{ + Controller: struct { + Image string `yaml:"image" json:"image"` + Registry *int `yaml:"registry,omitempty" json:"registry,omitempty"` + Port *int `yaml:"port,omitempty" json:"port,omitempty"` + PublicURL string `yaml:"publicUrl,omitempty" json:"publicUrl,omitempty"` + TrustProxy *bool `yaml:"trustProxy,omitempty" json:"trustProxy,omitempty"` + }{ + Registry: ®Two, + }, + }, + } + if !controllerRegistryDrift(1, doc) { + t.Fatal("expected registry drift when pot registry differs from manifest") + } + if controllerRegistryDrift(2, doc) { + t.Fatal("expected no registry drift when registries match") + } + if !controllerRegistryDrift(1, &models.ControlPlaneManifest{}) { + t.Fatal("expected registry drift when manifest registry is unset") + } +} diff --git a/internal/fieldagent/controller_register.go b/internal/fieldagent/controller_register.go new file mode 100644 index 0000000..241f1f8 --- /dev/null +++ b/internal/fieldagent/controller_register.go @@ -0,0 +1,334 @@ +package fieldagent + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/eclipse-iofog/edgelet/internal/config" + "github.com/eclipse-iofog/edgelet/internal/controlplane" + "github.com/eclipse-iofog/edgelet/internal/models" + "github.com/eclipse-iofog/edgelet/internal/store" + "github.com/eclipse-iofog/edgelet/internal/utils/logging" +) + +const controllerRegisterInterval = 30 * time.Second + +type controllerRegisterState struct { + mu sync.Mutex + succeeded map[string]struct{} + initialRebuildSkipped map[string]struct{} +} + +func newControllerRegisterState() *controllerRegisterState { + return &controllerRegisterState{ + succeeded: make(map[string]struct{}), + initialRebuildSkipped: make(map[string]struct{}), + } +} + +func (s *controllerRegisterState) markSucceeded(uuid string) { + uuid = strings.TrimSpace(uuid) + if uuid == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.succeeded[uuid] = struct{}{} +} + +func (s *controllerRegisterState) isSucceeded(uuid string) bool { + uuid = strings.TrimSpace(uuid) + if uuid == "" { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.succeeded[uuid] + return ok +} + +func (s *controllerRegisterState) markInitialRebuildSkipped(uuid string) { + uuid = strings.TrimSpace(uuid) + if uuid == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.initialRebuildSkipped == nil { + s.initialRebuildSkipped = make(map[string]struct{}) + } + s.initialRebuildSkipped[uuid] = struct{}{} +} + +func (s *controllerRegisterState) isInitialRebuildSkipped(uuid string) bool { + uuid = strings.TrimSpace(uuid) + if uuid == "" { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.initialRebuildSkipped[uuid] + return ok +} + +func (s *controllerRegisterState) reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.succeeded = make(map[string]struct{}) + s.initialRebuildSkipped = make(map[string]struct{}) +} + +func (fa *FieldAgent) resetControllerRegisterState() { + if fa.controllerRegister == nil { + return + } + fa.controllerRegister.reset() +} + +func (fa *FieldAgent) hydrateControllerRegisterState() { + if fa.controllerRegister == nil { + return + } + cp, found, err := store.GetInstance().GetSystemControlPlane() + if err != nil || !found || cp == nil { + return + } + uuid := strings.TrimSpace(cp.ControllerUUID) + if uuid == "" { + return + } + if cp.ControllerRegistered { + fa.controllerRegister.markSucceeded(uuid) + } + if cp.InitialRebuildSkipped { + fa.controllerRegister.markInitialRebuildSkipped(uuid) + } +} + +func (fa *FieldAgent) persistControllerRegistered(uuid string) { + uuid = strings.TrimSpace(uuid) + if uuid == "" { + return + } + cp, found, err := store.GetInstance().GetSystemControlPlane() + if err != nil || !found || cp == nil { + return + } + if strings.TrimSpace(cp.ControllerUUID) != uuid { + return + } + cp.ControllerRegistered = true + if err := store.GetInstance().UpsertSystemControlPlane(cp); err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("persist controller registered uuid=%s err=%v", uuid, err)) + } +} + +func (fa *FieldAgent) persistControllerInitialRebuildSkipped(uuid string) { + uuid = strings.TrimSpace(uuid) + if uuid == "" { + return + } + cp, found, err := store.GetInstance().GetSystemControlPlane() + if err != nil || !found || cp == nil { + return + } + if strings.TrimSpace(cp.ControllerUUID) != uuid { + return + } + cp.InitialRebuildSkipped = true + if err := store.GetInstance().UpsertSystemControlPlane(cp); err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("persist controller initial rebuild skipped uuid=%s err=%v", uuid, err)) + } +} + +func (fa *FieldAgent) controllerRegisterWorker() { + defer fa.wg.Done() + + timer := time.NewTimer(controllerRegisterInterval) + defer timer.Stop() + + for { + select { + case <-fa.ctx.Done(): + return + case <-timer.C: + fa.tryControllerRegister() + timer.Reset(controllerRegisterInterval) + } + } +} + +func (fa *FieldAgent) tryControllerRegister() { + if fa.NotProvisioned() || !fa.IsControllerConnected(false) { + return + } + if fa.apiClient == nil { + return + } + + cp, found, err := store.GetInstance().GetSystemControlPlane() + if err != nil || !found || cp == nil { + return + } + cp.NormalizeDefaults() + controllerUUID := strings.TrimSpace(cp.ControllerUUID) + if controllerUUID == "" { + return + } + if fa.controllerRegister != nil && fa.controllerRegister.isSucceeded(controllerUUID) { + return + } + if !strings.EqualFold(strings.TrimSpace(cp.DesiredState), "running") { + return + } + if !strings.EqualFold(strings.TrimSpace(cp.RuntimeState), "running") { + return + } + if fa.processManager == nil { + return + } + container, err := fa.processManager.GetContainerForMicroservice(controllerUUID) + if err != nil || container == nil { + logging.LogDebug(moduleName, fmt.Sprintf("controller register waiting for running container uuid=%s", controllerUUID)) + return + } + + body, err := buildControllerRegisterBody(fa.config, cp) + if err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("controller register body build failed uuid=%s err=%v", controllerUUID, err)) + return + } + + ctx, cancel := context.WithTimeout(fa.ctx, 30*time.Second) + result, reqErr := fa.apiClient.Request(ctx, "controller/register", POST, nil, body) + cancel() + if reqErr != nil { + logging.LogWarn(moduleName, fmt.Sprintf("controller register request failed uuid=%s err=%v", controllerUUID, reqErr)) + return + } + + respUUID, ok := result["uuid"].(string) + if !ok || strings.TrimSpace(respUUID) == "" { + respUUID = controllerUUID + } + if strings.TrimSpace(respUUID) != controllerUUID { + logging.LogWarn(moduleName, fmt.Sprintf( + "controller register uuid mismatch request=%s response=%s", + controllerUUID, + respUUID, + )) + return + } + + if fa.controllerRegister != nil { + fa.controllerRegister.markSucceeded(controllerUUID) + } + fa.persistControllerRegistered(controllerUUID) + logging.LogInfo(moduleName, fmt.Sprintf("controller microservice registered uuid=%s", controllerUUID)) +} + +func buildControllerRegisterBody(cfg *config.Config, cp *models.ControlPlaneDeployment) (map[string]any, error) { + if cp == nil { + return nil, errors.New("control plane deployment is nil") + } + doc, err := models.ParseControlPlaneManifest(cp.ManifestYAML) + if err != nil { + return nil, err + } + image := doc.ManifestControllerImage() + ms, err := controlplane.BuildMicroserviceFromControlPlane(doc, cp.ControllerUUID, image) + if err != nil { + return nil, err + } + + archID := config.ArchitectureCode(cfg.Arch) + body := map[string]any{ + "uuid": strings.TrimSpace(cp.ControllerUUID), + "name": "controller", + "schedule": 0, + "images": []map[string]any{{"containerImage": image, "archId": archID}}, + "registryId": ms.RegistryID, + } + if len(ms.PortMappings) > 0 { + body["ports"] = portMappingsToRegisterBody(ms.PortMappings) + } + if len(ms.VolumeMappings) > 0 { + body["volumeMappings"] = volumeMappingsToRegisterBody(ms.VolumeMappings) + } + envMap, err := controlplane.BuildControllerEnv(doc, cp.ControllerUUID) + if err != nil { + return nil, err + } + body["env"] = envVarsToRegisterBody(envMap) + if ms.Runtime != nil && strings.TrimSpace(*ms.Runtime) != "" { + body["runtime"] = strings.TrimSpace(*ms.Runtime) + } + if ms.HostNetworkMode { + body["hostNetworkMode"] = true + } + if ms.Config != nil { + body["config"] = *ms.Config + } + return body, nil +} + +func portMappingsToRegisterBody(mappings []*models.PortMapping) []map[string]any { + out := make([]map[string]any, 0, len(mappings)) + for _, pm := range mappings { + if pm == nil { + continue + } + protocol := "tcp" + if pm.UDP { + protocol = "udp" + } + out = append(out, map[string]any{ + "internal": pm.Inside, + "external": pm.Outside, + "protocol": protocol, + }) + } + return out +} + +func volumeMappingsToRegisterBody(mappings []*models.VolumeMapping) []map[string]any { + out := make([]map[string]any, 0, len(mappings)) + for _, vm := range mappings { + if vm == nil { + continue + } + typeName := "bind" + switch vm.Type { + case models.VolumeMappingTypeVolume: + typeName = "volume" + case models.VolumeMappingTypeVolumeMount: + typeName = "volumeMount" + } + out = append(out, map[string]any{ + "hostDestination": vm.HostDestination, + "containerDestination": vm.ContainerDestination, + "accessMode": vm.AccessMode, + "type": typeName, + }) + } + return out +} + +func envVarsToRegisterBody(env map[string]string) []map[string]any { + out := make([]map[string]any, 0, len(env)) + for key, value := range env { + key = strings.TrimSpace(key) + if key == "" { + continue + } + out = append(out, map[string]any{ + "key": key, + "value": value, + }) + } + return out +} diff --git a/internal/fieldagent/controller_register_test.go b/internal/fieldagent/controller_register_test.go new file mode 100644 index 0000000..05eeb6a --- /dev/null +++ b/internal/fieldagent/controller_register_test.go @@ -0,0 +1,187 @@ +package fieldagent + +import ( + "testing" + + "github.com/eclipse-iofog/edgelet/internal/config" + "github.com/eclipse-iofog/edgelet/internal/controlplane" + "github.com/eclipse-iofog/edgelet/internal/models" + "github.com/eclipse-iofog/edgelet/internal/store" +) + +func TestBuildControllerRegisterBody(t *testing.T) { + cfg := config.GetInstance() + cfg.Arch = "amd64" + + cp := &models.ControlPlaneDeployment{ + ControllerUUID: "cp-reg-1", + Namespace: "default", + Name: "pot", + ManifestYAML: `apiVersion: edgelet.iofog.org/v1 +kind: ControlPlane +metadata: + namespace: default + name: pot +spec: + controller: + image: ghcr.io/datasance/controller:3.8.0-beta.0 + registry: 2 + auth: + mode: embedded + bootstrap: + username: admin + password: AdminPass123! +`, + } + + body, err := buildControllerRegisterBody(cfg, cp) + if err != nil { + t.Fatalf("build body: %v", err) + } + if body["uuid"] != "cp-reg-1" { + t.Fatalf("uuid: got %#v", body["uuid"]) + } + if body["name"] != "controller" { + t.Fatalf("name: got %#v", body["name"]) + } + if schedule, ok := body["schedule"].(int); !ok || schedule != 0 { + t.Fatalf("schedule: got %#v", body["schedule"]) + } + images, ok := body["images"].([]map[string]any) + if !ok || len(images) != 1 { + t.Fatalf("images: got %#v", body["images"]) + } + if images[0]["archId"] != 1 { + t.Fatalf("archId: got %#v", images[0]["archId"]) + } + env, ok := body["env"].([]map[string]any) + if !ok || len(env) == 0 { + t.Fatalf("env: got %#v", body["env"]) + } + for _, item := range env { + if _, hasKey := item["key"]; !hasKey { + t.Fatalf("env item missing key: %#v", item) + } + if _, hasValue := item["value"]; !hasValue { + t.Fatalf("env item missing value: %#v", item) + } + } + + ports, ok := body["ports"].([]map[string]any) + if !ok || len(ports) != 2 { + t.Fatalf("ports: got %#v", body["ports"]) + } + for _, port := range ports { + if _, ok := port["internal"]; !ok { + t.Fatalf("port missing internal: %#v", port) + } + if _, ok := port["external"]; !ok { + t.Fatalf("port missing external: %#v", port) + } + if protocol, ok := port["protocol"].(string); !ok || protocol == "" { + t.Fatalf("port missing protocol: %#v", port) + } + if _, ok := port["portExternal"]; ok { + t.Fatalf("port must not use portExternal wire key: %#v", port) + } + } + if ports[0]["internal"] != controlplane.DefaultContainerAPIPort { + t.Fatalf("api internal port: got %#v want %d", ports[0]["internal"], controlplane.DefaultContainerAPIPort) + } + if ports[0]["external"] != controlplane.HostAPIPort { + t.Fatalf("api external port: got %#v want %d", ports[0]["external"], controlplane.HostAPIPort) + } + + volumeMappings, ok := body["volumeMappings"].([]map[string]any) + if !ok || len(volumeMappings) < 2 { + t.Fatalf("volumeMappings: got %#v", body["volumeMappings"]) + } + for _, vm := range volumeMappings { + for _, key := range []string{"hostDestination", "containerDestination", "accessMode", "type"} { + if _, ok := vm[key]; !ok { + t.Fatalf("volume mapping missing %s: %#v", key, vm) + } + } + } +} + +func TestControllerRegisterState(t *testing.T) { + state := newControllerRegisterState() + if state.isSucceeded("uuid-1") { + t.Fatal("expected not succeeded initially") + } + state.markSucceeded("uuid-1") + if !state.isSucceeded("uuid-1") { + t.Fatal("expected succeeded after mark") + } + state.reset() + if state.isSucceeded("uuid-1") { + t.Fatal("expected reset to clear succeeded state") + } +} + +func TestParseMicroservice_IsController(t *testing.T) { + ms, err := parseMicroservice(map[string]any{ + "uuid": "ms-1", + "imageId": "alpine:3.19", + "isController": true, + "isSystem": true, + }) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !ms.IsController || !ms.IsSystem { + t.Fatalf("flags: controller=%v system=%v", ms.IsController, ms.IsSystem) + } +} + +func TestControllerRegisterState_InitialRebuildSkipped(t *testing.T) { + state := newControllerRegisterState() + const uuid = "cp-state-1" + + state.markSucceeded(uuid) + if !state.isSucceeded(uuid) { + t.Fatal("expected register succeeded marker") + } + if state.isInitialRebuildSkipped(uuid) { + t.Fatal("expected initial rebuild not skipped yet") + } + + state.markInitialRebuildSkipped(uuid) + if !state.isInitialRebuildSkipped(uuid) { + t.Fatal("expected initial rebuild skipped marker") + } + + state.reset() + if state.isSucceeded(uuid) { + t.Fatal("expected succeeded cleared on reset") + } + if state.isInitialRebuildSkipped(uuid) { + t.Fatal("expected initial rebuild skip cleared on reset") + } +} + +func TestHydrateControllerRegisterStateFromDB(t *testing.T) { + openFieldAgentTestDB(t) + + const uuid = "cp-hydrate" + if err := store.GetInstance().UpsertSystemControlPlane(&models.ControlPlaneDeployment{ + ControllerUUID: uuid, + Name: "pot", + ManifestYAML: minimalReconcileManifestYAML(), + ControllerRegistered: true, + InitialRebuildSkipped: true, + }); err != nil { + t.Fatalf("upsert control plane: %v", err) + } + + fa := &FieldAgent{controllerRegister: newControllerRegisterState()} + fa.hydrateControllerRegisterState() + + if !fa.controllerRegister.isSucceeded(uuid) { + t.Fatal("expected register state hydrated from sqlite") + } + if !fa.controllerRegister.isInitialRebuildSkipped(uuid) { + t.Fatal("expected initial rebuild skip hydrated from sqlite") + } +} diff --git a/internal/fieldagent/ota_reprovision.go b/internal/fieldagent/ota_reprovision.go new file mode 100644 index 0000000..a69fc2b --- /dev/null +++ b/internal/fieldagent/ota_reprovision.go @@ -0,0 +1,150 @@ +package fieldagent + +import ( + "context" + "errors" + "fmt" + "strings" + "sync/atomic" + "time" + + "github.com/eclipse-iofog/edgelet/internal/utils/logging" + "github.com/eclipse-iofog/edgelet/internal/version" +) + +var otaReprovisionRetry atomic.Bool + +func (fa *FieldAgent) fetchControllerVersion() (map[string]any, error) { + if fa.apiClient == nil { + return nil, errors.New("api client not initialized") + } + ctx, cancel := context.WithTimeout(fa.ctx, 30*time.Second) + defer cancel() + return fa.apiClient.Request(ctx, "version", GET, nil, nil) +} + +func (fa *FieldAgent) maybeReprovisionAfterOTA() { + receipt, err := version.NewReleaseManager().ReadInstallReceipt() + if err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("OTA reprovision skipped: read install receipt failed: %v", err)) + return + } + if receipt == nil || !isOTAInstallMethod(receipt.InstallMethod) { + return + } + + pending, err := version.ReadOTAReprovisionPending() + if err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("OTA reprovision skipped: read pending failed: %v", err)) + return + } + if pending == nil { + return + } + + now := time.Now() + if pending.IsExpired(now) { + if fa.tryRefreshOTAReprovisionPending() { + pending, err = version.ReadOTAReprovisionPending() + if err != nil || pending == nil { + logging.LogWarn(moduleName, "OTA reprovision refresh did not yield pending state") + otaReprovisionRetry.Store(true) + return + } + } else { + logging.LogWarn(moduleName, "OTA reprovision key expired; keeping existing credentials and retrying on upgrade scan") + _ = version.DeleteOTAReprovisionPending() + otaReprovisionRetry.Store(true) + return + } + } + + if pending.IsExpired(time.Now()) { + logging.LogWarn(moduleName, "OTA reprovision key still expired after refresh; keeping existing credentials") + _ = version.DeleteOTAReprovisionPending() + otaReprovisionRetry.Store(true) + return + } + + if err := fa.Provision(pending.ProvisionKey); err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("OTA reprovision failed: %v", err)) + otaReprovisionRetry.Store(true) + return + } + + if err := version.DeleteOTAReprovisionPending(); err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("OTA reprovision succeeded but failed to delete pending file: %v", err)) + } else { + logging.LogInfo(moduleName, "OTA reprovision completed; pending file removed") + } + otaReprovisionRetry.Store(false) + + if err := fa.postFogConfig(); err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("postFogConfig after OTA reprovision failed: %v", err)) + } +} + +func (fa *FieldAgent) tryRefreshOTAReprovisionPending() bool { + raw, err := fa.fetchControllerVersion() + if err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("OTA reprovision version refresh failed: %v", err)) + return false + } + + actionData, err := version.NormalizeVersionResponse(raw) + if err != nil || actionData == nil { + logging.LogWarn(moduleName, fmt.Sprintf("OTA reprovision version refresh normalize failed: %v", err)) + return false + } + + pending, err := version.PendingFromAction(actionData) + if err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("OTA reprovision refresh missing valid key: %v", err)) + return false + } + if pending.IsExpired(time.Now()) { + return false + } + + if err := version.WriteOTAReprovisionPending( + pending.ProvisionKey, + pending.Command, + pending.TargetVersion, + pending.ExpirationTime, + ); err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("OTA reprovision refresh write pending failed: %v", err)) + return false + } + logging.LogInfo(moduleName, "OTA reprovision key refreshed from controller") + return true +} + +func (fa *FieldAgent) retryOTAReprovisionIfNeeded() { + if !otaReprovisionRetry.Load() { + return + } + if fa.NotProvisioned() || !fa.IsControllerConnected(false) { + return + } + + pending, err := version.ReadOTAReprovisionPending() + if err != nil || pending == nil { + if pending == nil { + if fa.tryRefreshOTAReprovisionPending() { + fa.maybeReprovisionAfterOTA() + } + } + return + } + + fa.maybeReprovisionAfterOTA() +} + +func isOTAInstallMethod(method string) bool { + switch strings.ToLower(strings.TrimSpace(method)) { + case "upgrade", "upgrade-airgap", "rollback": + return true + default: + return false + } +} diff --git a/internal/fieldagent/ota_reprovision_test.go b/internal/fieldagent/ota_reprovision_test.go new file mode 100644 index 0000000..4c411ff --- /dev/null +++ b/internal/fieldagent/ota_reprovision_test.go @@ -0,0 +1,20 @@ +package fieldagent + +import ( + "testing" +) + +func TestIsOTAInstallMethod(t *testing.T) { + cases := map[string]bool{ + "upgrade": true, + "upgrade-airgap": true, + "rollback": true, + "install": false, + "": false, + } + for method, want := range cases { + if got := isOTAInstallMethod(method); got != want { + t.Fatalf("method=%q got=%v want=%v", method, got, want) + } + } +} diff --git a/internal/fieldagent/sync.go b/internal/fieldagent/sync.go index 2126a54..be6f932 100644 --- a/internal/fieldagent/sync.go +++ b/internal/fieldagent/sync.go @@ -81,9 +81,9 @@ func (fa *FieldAgent) loadMicroservices(fromFile bool) ([]*models.Microservice, } } - // Store microservices for MicroserviceManagerInterface fa.setLatestMicroservices(microserviceList) fa.SetCurrentMicroservices(microserviceList) + fa.reconcileControllerMicroservice(microserviceList) // Reconcile service-account token projections for controller-managed microservices. if err := serviceaccount.GetInstance().ReconcileManagedMicroservices(microserviceList); err != nil { @@ -101,6 +101,38 @@ func (fa *FieldAgent) loadMicroservices(fromFile bool) ([]*models.Microservice, return microserviceList, nil } +// parsePortMappingFromController maps Controller wire-format ports (internal/external/protocol) +// to edgelet models. Legacy portInternal/portExternal/isUdp keys are accepted as fallback. +func parsePortMappingFromController(pmMap map[string]any) *models.PortMapping { + var outside, inside int + var udp bool + hasPort := false + + if v, ok := pmMap["external"].(float64); ok { + outside = int(v) + hasPort = true + } else if v, ok := pmMap["portExternal"].(float64); ok { + outside = int(v) + hasPort = true + } + if v, ok := pmMap["internal"].(float64); ok { + inside = int(v) + hasPort = true + } else if v, ok := pmMap["portInternal"].(float64); ok { + inside = int(v) + hasPort = true + } + if protocol, ok := pmMap["protocol"].(string); ok { + udp = strings.EqualFold(strings.TrimSpace(protocol), "udp") + } else if isUDP, ok := pmMap["isUdp"].(bool); ok { + udp = isUDP + } + if !hasPort { + return nil + } + return models.NewPortMapping(outside, inside, udp) +} + // parseMicroservice parses a microservice from JSON data func parseMicroservice(data map[string]any) (*models.Microservice, error) { uuid, ok := data["uuid"].(string) @@ -184,25 +216,21 @@ func parseMicroservice(data map[string]any) (*models.Microservice, error) { if isNats, ok := data["isNats"].(bool); ok { microservice.IsNats = isNats } + if isController, ok := data["isController"].(bool); ok { + microservice.IsController = isController + } + if isSystem, ok := data["isSystem"].(bool); ok { + microservice.IsSystem = isSystem + } // Parse port mappings if portMappings, ok := data["portMappings"].([]any); ok { microservice.PortMappings = make([]*models.PortMapping, 0, len(portMappings)) for _, pm := range portMappings { if pmMap, ok := pm.(map[string]any); ok { - var outside, inside int - var udp bool - if portExternal, ok := pmMap["portExternal"].(float64); ok { - outside = int(portExternal) - } - if portInternal, ok := pmMap["portInternal"].(float64); ok { - inside = int(portInternal) - } - if isUDP, ok := pmMap["isUdp"].(bool); ok { - udp = isUDP + if portMapping := parsePortMappingFromController(pmMap); portMapping != nil { + microservice.PortMappings = append(microservice.PortMappings, portMapping) } - portMapping := models.NewPortMapping(outside, inside, udp) - microservice.PortMappings = append(microservice.PortMappings, portMapping) } } } diff --git a/internal/fieldagent/sync_ports_test.go b/internal/fieldagent/sync_ports_test.go new file mode 100644 index 0000000..3b70433 --- /dev/null +++ b/internal/fieldagent/sync_ports_test.go @@ -0,0 +1,61 @@ +package fieldagent + +import ( + "testing" + + "github.com/eclipse-iofog/edgelet/internal/controlplane" +) + +func TestParsePortMappingFromController_WireFormat(t *testing.T) { + pm := parsePortMappingFromController(map[string]any{ + "internal": float64(8008), + "external": float64(80), + "protocol": "tcp", + }) + if pm == nil { + t.Fatal("expected port mapping") + } + if pm.Inside != 8008 || pm.Outside != 80 || pm.UDP { + t.Fatalf("unexpected mapping: inside=%d outside=%d udp=%v", pm.Inside, pm.Outside, pm.UDP) + } +} + +func TestParsePortMappingFromController_LegacyFormat(t *testing.T) { + pm := parsePortMappingFromController(map[string]any{ + "portInternal": float64(51121), + "portExternal": float64(51121), + "isUdp": true, + }) + if pm == nil { + t.Fatal("expected port mapping") + } + if pm.Inside != 51121 || pm.Outside != 51121 || !pm.UDP { + t.Fatalf("unexpected mapping: inside=%d outside=%d udp=%v", pm.Inside, pm.Outside, pm.UDP) + } +} + +func TestParseMicroservice_PortMappingsWireFormat(t *testing.T) { + ms, err := parseMicroservice(map[string]any{ + "uuid": "ms-1", + "imageId": "alpine:3.19", + "portMappings": []any{ + map[string]any{ + "internal": float64(controlplane.DefaultContainerAPIPort), + "external": float64(controlplane.HostAPIPort), + "protocol": "tcp", + }, + }, + }) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(ms.PortMappings) != 1 { + t.Fatalf("expected 1 port mapping, got %d", len(ms.PortMappings)) + } + if ms.PortMappings[0].Inside != controlplane.DefaultContainerAPIPort { + t.Fatalf("inside: got %d want %d", ms.PortMappings[0].Inside, controlplane.DefaultContainerAPIPort) + } + if ms.PortMappings[0].Outside != controlplane.HostAPIPort { + t.Fatalf("outside: got %d want %d", ms.PortMappings[0].Outside, controlplane.HostAPIPort) + } +} diff --git a/internal/fieldagent/workers.go b/internal/fieldagent/workers.go index 4be50fa..df4e409 100644 --- a/internal/fieldagent/workers.go +++ b/internal/fieldagent/workers.go @@ -447,6 +447,8 @@ func (fa *FieldAgent) scanVersionReadiness() { status.ReadyToUpgrade = readyUpgrade status.ReadyToRollback = readyRollback }) + + fa.retryOTAReprovisionIfNeeded() } func (fa *FieldAgent) serviceAccountTokenRotationWorker() { diff --git a/internal/models/control_plane_deployment.go b/internal/models/control_plane_deployment.go index 315faa7..ba22dcb 100644 --- a/internal/models/control_plane_deployment.go +++ b/internal/models/control_plane_deployment.go @@ -9,24 +9,26 @@ const ControlPlaneDefaultNamespace = "default" // ControlPlaneDeployment is the singleton SQLite record for a local ControlPlane manifest. type ControlPlaneDeployment struct { - ControllerUUID string - Namespace string - Name string - ManifestYAML string - Image string - State string - ContainerID string - DesiredState string - RuntimeState string - LastError string - RestartCount int - LastTransitionAt int64 - LastReconcileAt int64 - LastStartAttemptAt int64 - FailureCount int - DeletedAt *int64 - Generation int64 - ObservedGeneration int64 + ControllerUUID string + Namespace string + Name string + ManifestYAML string + Image string + State string + ContainerID string + DesiredState string + RuntimeState string + LastError string + RestartCount int + LastTransitionAt int64 + LastReconcileAt int64 + LastStartAttemptAt int64 + FailureCount int + DeletedAt *int64 + Generation int64 + ObservedGeneration int64 + ControllerRegistered bool + InitialRebuildSkipped bool } // NormalizeDefaults ensures lifecycle defaults for control plane records. diff --git a/internal/models/control_plane_manifest.go b/internal/models/control_plane_manifest.go index fc718d3..76a5a0b 100644 --- a/internal/models/control_plane_manifest.go +++ b/internal/models/control_plane_manifest.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "unicode" "gopkg.in/yaml.v3" ) @@ -16,10 +17,10 @@ import ( const ( controlPlaneKind = "ControlPlane" controlPlaneAPIVersion = "edgelet.iofog.org/v1" - // ControlPlaneHTTPS*Filename are container-relative names under /etc/iofog/controller-cert/. - ControlPlaneHTTPSCertFilename = "tls.crt" - ControlPlaneHTTPSKeyFilename = "tls.key" - ControlPlaneHTTPSCAFilename = "ca.crt" + // ControlPlaneTLS*Filename are container-relative names under /etc/iofog/controller-cert/. + ControlPlaneTLSCertFilename = "tls.crt" + ControlPlaneTLSKeyFilename = "tls.key" + ControlPlaneTLSCAFilename = "ca.crt" ) // ControlPlaneManifest is the operator YAML for kind ControlPlane. @@ -37,19 +38,14 @@ type ControlPlaneManifest struct { // ControlPlaneManifestSpec holds controller deployment configuration. type ControlPlaneManifestSpec struct { Controller struct { - Image string `yaml:"image" json:"image"` - Registry *int `yaml:"registry,omitempty" json:"registry,omitempty"` - Port *int `yaml:"port,omitempty" json:"port,omitempty"` + Image string `yaml:"image" json:"image"` + Registry *int `yaml:"registry,omitempty" json:"registry,omitempty"` + Port *int `yaml:"port,omitempty" json:"port,omitempty"` + PublicURL string `yaml:"publicUrl,omitempty" json:"publicUrl,omitempty"` + TrustProxy *bool `yaml:"trustProxy,omitempty" json:"trustProxy,omitempty"` } `yaml:"controller" json:"controller"` - Auth struct { - URL string `yaml:"url,omitempty" json:"url,omitempty"` - Realm string `yaml:"realm,omitempty" json:"realm,omitempty"` - RealmKey string `yaml:"realmKey,omitempty" json:"realmKey,omitempty"` - SSL string `yaml:"ssl,omitempty" json:"ssl,omitempty"` - ControllerClient string `yaml:"controllerClient,omitempty" json:"controllerClient,omitempty"` - ControllerSecret string `yaml:"controllerSecret,omitempty" json:"controllerSecret,omitempty"` - ViewerClient string `yaml:"viewerClient,omitempty" json:"viewerClient,omitempty"` - } `yaml:"auth,omitempty" json:"auth,omitempty"` + Console ControlPlaneConsoleSpec `yaml:"console,omitempty" json:"console,omitempty"` + Auth *ControlPlaneAuthSpec `yaml:"auth,omitempty" json:"auth,omitempty"` Database *struct { Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` User string `yaml:"user,omitempty" json:"user,omitempty"` @@ -73,27 +69,87 @@ type ControlPlaneManifestSpec struct { NATS *struct { Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` } `yaml:"nats,omitempty" json:"nats,omitempty"` - ECNViewerPort *int `yaml:"ecnViewerPort,omitempty" json:"ecnViewerPort,omitempty"` - ECNViewerURL string `yaml:"ecnViewerUrl,omitempty" json:"ecnViewerUrl,omitempty"` - LogLevel string `yaml:"logLevel,omitempty" json:"logLevel,omitempty"` - HTTPS *ControlPlaneHTTPSConfig `yaml:"https,omitempty" json:"https,omitempty"` - Vault *ControlPlaneVaultSpec `yaml:"vault,omitempty" json:"vault,omitempty"` + LogLevel string `yaml:"logLevel,omitempty" json:"logLevel,omitempty"` + TLS *ControlPlaneTLSConfig `yaml:"tls,omitempty" json:"tls,omitempty"` + Vault *ControlPlaneVaultSpec `yaml:"vault,omitempty" json:"vault,omitempty"` // Forbidden in Edgelet ControlPlane YAML (potctl REST import only). SiteCA any `yaml:"siteCA,omitempty" json:"-"` LocalCA any `yaml:"localCA,omitempty" json:"-"` } -// ControlPlaneHTTPSBase64 holds inline TLS material for ControlPlane HTTPS. -type ControlPlaneHTTPSBase64 struct { - CA string `yaml:"ca,omitempty" json:"ca,omitempty"` +// ControlPlaneConsoleSpec configures EdgeOps Console exposure. +type ControlPlaneConsoleSpec struct { + Port *int `yaml:"port,omitempty" json:"port,omitempty"` + URL string `yaml:"url,omitempty" json:"url,omitempty"` +} + +// ControlPlaneAuthSpec configures Controller OIDC (embedded or external). +type ControlPlaneAuthSpec struct { + Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` + InsecureAllowHTTP *bool `yaml:"insecureAllowHttp,omitempty" json:"insecureAllowHttp,omitempty"` + InsecureAllowBootstrapLog *bool `yaml:"insecureAllowBootstrapLog,omitempty" json:"insecureAllowBootstrapLog,omitempty"` + Bootstrap *ControlPlaneAuthBootstrap `yaml:"bootstrap,omitempty" json:"bootstrap,omitempty"` + IssuerURL string `yaml:"issuerUrl,omitempty" json:"issuerUrl,omitempty"` + Client *ControlPlaneAuthClient `yaml:"client,omitempty" json:"client,omitempty"` + ConsoleClient string `yaml:"consoleClient,omitempty" json:"consoleClient,omitempty"` + ConsoleClientEnabled *bool `yaml:"consoleClientEnabled,omitempty" json:"consoleClientEnabled,omitempty"` + RateLimit *ControlPlaneAuthRateLimit `yaml:"rateLimit,omitempty" json:"rateLimit,omitempty"` + SessionStore *ControlPlaneAuthSessionStore `yaml:"sessionStore,omitempty" json:"sessionStore,omitempty"` + TokenTTL *ControlPlaneAuthTokenTTL `yaml:"tokenTtl,omitempty" json:"tokenTtl,omitempty"` + OIDCTTL *ControlPlaneAuthOIDCTTL `yaml:"oidcTtl,omitempty" json:"oidcTtl,omitempty"` +} + +// ControlPlaneAuthBootstrap holds embedded OIDC bootstrap admin credentials. +type ControlPlaneAuthBootstrap struct { + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` +} + +// ControlPlaneAuthClient holds OIDC confidential client credentials. +type ControlPlaneAuthClient struct { + ID string `yaml:"id,omitempty" json:"id,omitempty"` + Secret string `yaml:"secret,omitempty" json:"secret,omitempty"` +} + +// ControlPlaneAuthRateLimit configures auth endpoint rate limiting. +type ControlPlaneAuthRateLimit struct { + Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + MaxRequestsPerWindow *int `yaml:"maxRequestsPerWindow,omitempty" json:"maxRequestsPerWindow,omitempty"` + WindowMs *int `yaml:"windowMs,omitempty" json:"windowMs,omitempty"` +} + +// ControlPlaneAuthSessionStore configures OAuth BFF session storage. +type ControlPlaneAuthSessionStore struct { + Type string `yaml:"type,omitempty" json:"type,omitempty"` + TTLMs *int `yaml:"ttlMs,omitempty" json:"ttlMs,omitempty"` + Secret string `yaml:"secret,omitempty" json:"secret,omitempty"` +} + +// ControlPlaneAuthTokenTTL configures JWT access/refresh token TTL overrides. +type ControlPlaneAuthTokenTTL struct { + AccessTokenTTLSeconds *int `yaml:"accessTokenTtlSeconds,omitempty" json:"accessTokenTtlSeconds,omitempty"` + RefreshTokenTTLSeconds *int `yaml:"refreshTokenTtlSeconds,omitempty" json:"refreshTokenTtlSeconds,omitempty"` +} + +// ControlPlaneAuthOIDCTTL configures embedded OIDC provider TTL overrides. +type ControlPlaneAuthOIDCTTL struct { + InteractionTTLSeconds *int `yaml:"interactionTtlSeconds,omitempty" json:"interactionTtlSeconds,omitempty"` + GrantTTLSeconds *int `yaml:"grantTtlSeconds,omitempty" json:"grantTtlSeconds,omitempty"` + SessionTTLSeconds *int `yaml:"sessionTtlSeconds,omitempty" json:"sessionTtlSeconds,omitempty"` + IDTokenTTLSeconds *int `yaml:"idTokenTtlSeconds,omitempty" json:"idTokenTtlSeconds,omitempty"` +} + +// ControlPlaneTLSBase64 holds inline TLS material for ControlPlane listeners. +type ControlPlaneTLSBase64 struct { Cert string `yaml:"cert,omitempty" json:"cert,omitempty"` Key string `yaml:"key,omitempty" json:"key,omitempty"` + CA string `yaml:"ca,omitempty" json:"ca,omitempty"` } -// ControlPlaneHTTPSConfig configures TLS for the control plane controller. -type ControlPlaneHTTPSConfig struct { - Path string `yaml:"path,omitempty" json:"path,omitempty"` - Base64 *ControlPlaneHTTPSBase64 `yaml:"base64,omitempty" json:"base64,omitempty"` +// ControlPlaneTLSConfig configures TLS for the control plane controller. +type ControlPlaneTLSConfig struct { + Path string `yaml:"path,omitempty" json:"path,omitempty"` + Base64 *ControlPlaneTLSBase64 `yaml:"base64,omitempty" json:"base64,omitempty"` } // ControlPlaneVaultSpec mirrors optional vault provider blocks. @@ -123,6 +179,17 @@ type ControlPlaneVaultSpec struct { } `yaml:"google,omitempty" json:"google,omitempty"` } +// ValidEmbeddedAuthForTest returns a minimal valid embedded auth block for unit tests. +func ValidEmbeddedAuthForTest() *ControlPlaneAuthSpec { + return &ControlPlaneAuthSpec{ + Mode: "embedded", + Bootstrap: &ControlPlaneAuthBootstrap{ + Username: "admin", + Password: "AdminPass123!", + }, + } +} + // ParseControlPlaneManifest decodes and validates ControlPlane YAML. func ParseControlPlaneManifest(manifest string) (*ControlPlaneManifest, error) { doc := &ControlPlaneManifest{} @@ -192,51 +259,245 @@ func (m *ControlPlaneManifest) Validate() error { if m.Spec.Controller.Port != nil && *m.Spec.Controller.Port <= 0 { return errors.New("spec.controller.port must be positive when set") } - if m.Spec.ECNViewerPort != nil && *m.Spec.ECNViewerPort <= 0 { - return errors.New("spec.ecnViewerPort must be positive when set") + if m.Spec.Console.Port != nil && *m.Spec.Console.Port <= 0 { + return errors.New("spec.console.port must be positive when set") + } + if err := validateControlPlaneAuth(m.Spec.Auth); err != nil { + return err + } + return validateControlPlaneTLS(m.Spec.TLS) +} + +func validateControlPlaneAuth(auth *ControlPlaneAuthSpec) error { + if auth == nil { + return errors.New("spec.auth is required") + } + mode := strings.TrimSpace(auth.Mode) + if mode == "" { + return errors.New("spec.auth.mode is required") + } + switch mode { + case "embedded": + if err := validateEmbeddedAuth(auth); err != nil { + return err + } + case "external": + if err := validateExternalAuth(auth); err != nil { + return err + } + default: + return errors.New("spec.auth.mode must be embedded or external") + } + return validateControlPlaneAuthOptions(auth) +} + +func validateEmbeddedAuth(auth *ControlPlaneAuthSpec) error { + if auth.Bootstrap == nil || strings.TrimSpace(auth.Bootstrap.Username) == "" { + return errors.New("spec.auth.bootstrap.username is required when spec.auth.mode is embedded") + } + return validateBootstrapPassword(auth.Bootstrap.Password) +} + +func validateExternalAuth(auth *ControlPlaneAuthSpec) error { + if strings.TrimSpace(auth.IssuerURL) == "" { + return errors.New("spec.auth.issuerUrl is required when spec.auth.mode is external") + } + if auth.Client == nil || strings.TrimSpace(auth.Client.ID) == "" { + return errors.New("spec.auth.client.id is required when spec.auth.mode is external") } - return validateControlPlaneHTTPS(m.Spec.HTTPS) + if auth.Client == nil || strings.TrimSpace(auth.Client.Secret) == "" { + return errors.New("spec.auth.client.secret is required when spec.auth.mode is external") + } + return nil +} + +const bootstrapPasswordComplexityMessage = "spec.auth.bootstrap.password must be at least 12 characters with 1 uppercase letter and 1 special character" // #nosec G101 -- validation error text, not a credential + +func validateBootstrapPassword(password string) error { + password = strings.TrimSpace(password) + if password == "" { + return errors.New("spec.auth.bootstrap.password is required when spec.auth.mode is embedded") + } + if len(password) < 12 { + return errors.New(bootstrapPasswordComplexityMessage) + } + hasUpper := false + hasSpecial := false + for _, r := range password { + if unicode.IsUpper(r) { + hasUpper = true + } + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + hasSpecial = true + } + } + if !hasUpper || !hasSpecial { + return errors.New(bootstrapPasswordComplexityMessage) + } + return nil } -func validateControlPlaneHTTPS(https *ControlPlaneHTTPSConfig) error { - if https == nil { +func validateControlPlaneAuthOptions(auth *ControlPlaneAuthSpec) error { + if auth.SessionStore != nil { + sessionType := strings.TrimSpace(auth.SessionStore.Type) + if sessionType != "" && sessionType != "memory" && sessionType != "database" { + return errors.New("spec.auth.sessionStore.type must be memory or database") + } + if auth.SessionStore.TTLMs != nil && *auth.SessionStore.TTLMs <= 0 { + return errors.New("spec.auth.sessionStore.ttlMs must be positive when set") + } + } + if auth.RateLimit != nil { + if auth.RateLimit.MaxRequestsPerWindow != nil && *auth.RateLimit.MaxRequestsPerWindow <= 0 { + return errors.New("spec.auth.rateLimit.maxRequestsPerWindow must be positive when set") + } + if auth.RateLimit.WindowMs != nil && *auth.RateLimit.WindowMs <= 0 { + return errors.New("spec.auth.rateLimit.windowMs must be positive when set") + } + } + if auth.TokenTTL != nil { + if auth.TokenTTL.AccessTokenTTLSeconds != nil && *auth.TokenTTL.AccessTokenTTLSeconds <= 0 { + return errors.New("spec.auth.tokenTtl.accessTokenTtlSeconds must be positive when set") + } + if auth.TokenTTL.RefreshTokenTTLSeconds != nil && *auth.TokenTTL.RefreshTokenTTLSeconds <= 0 { + return errors.New("spec.auth.tokenTtl.refreshTokenTtlSeconds must be positive when set") + } + } + if auth.OIDCTTL != nil { + if auth.OIDCTTL.InteractionTTLSeconds != nil && *auth.OIDCTTL.InteractionTTLSeconds <= 0 { + return errors.New("spec.auth.oidcTtl.interactionTtlSeconds must be positive when set") + } + if auth.OIDCTTL.GrantTTLSeconds != nil && *auth.OIDCTTL.GrantTTLSeconds <= 0 { + return errors.New("spec.auth.oidcTtl.grantTtlSeconds must be positive when set") + } + if auth.OIDCTTL.SessionTTLSeconds != nil && *auth.OIDCTTL.SessionTTLSeconds <= 0 { + return errors.New("spec.auth.oidcTtl.sessionTtlSeconds must be positive when set") + } + if auth.OIDCTTL.IDTokenTTLSeconds != nil && *auth.OIDCTTL.IDTokenTTLSeconds <= 0 { + return errors.New("spec.auth.oidcTtl.idTokenTtlSeconds must be positive when set") + } + } + return nil +} + +// ValidateControlPlaneTLSPath canonicalizes and validates a host TLS directory path. +func ValidateControlPlaneTLSPath(path string) (string, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "", errors.New("spec.tls.path must not be empty") + } + if !isValidHostPath(trimmed) { + return "", errors.New("spec.tls.path must be an absolute host path") + } + if tlsPathHasTraversal(trimmed) { + return "", errors.New("spec.tls.path must not contain parent directory traversal") + } + cleaned := filepath.Clean(trimmed) + if isTLSPathRoot(cleaned) { + return "", errors.New("spec.tls.path must not be root") + } + return cleaned, nil +} + +// StatControlPlaneTLSDir returns metadata for a validated host TLS directory. +func StatControlPlaneTLSDir(hostDir string) (os.FileInfo, error) { + cleaned, err := ValidateControlPlaneTLSPath(hostDir) + if err != nil { + return nil, err + } + info, err := os.Stat(cleaned) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, errors.New("spec.tls.path must be a directory") + } + return info, nil +} + +// StatControlPlaneTLSFile returns metadata for a known TLS file under a validated host directory. +func StatControlPlaneTLSFile(hostDir, basename string) (os.FileInfo, error) { + switch basename { + case ControlPlaneTLSCertFilename, ControlPlaneTLSKeyFilename, ControlPlaneTLSCAFilename: + default: + return nil, fmt.Errorf("invalid control plane TLS filename %q", basename) + } + cleaned, err := ValidateControlPlaneTLSPath(hostDir) + if err != nil { + return nil, err + } + return os.Stat(filepath.Join(cleaned, basename)) +} + +func tlsPathHasTraversal(path string) bool { + for _, part := range splitPathParts(path) { + if part == ".." { + return true + } + } + return false +} + +func splitPathParts(path string) []string { + return strings.FieldsFunc(path, func(r rune) bool { + return r == '/' || r == '\\' + }) +} + +func isTLSPathRoot(path string) bool { + cleaned := filepath.Clean(path) + if cleaned == string(filepath.Separator) { + return true + } + vol := filepath.VolumeName(cleaned) + if vol == "" { + return false + } + rest := strings.TrimPrefix(cleaned, vol) + rest = strings.Trim(rest, `/\`) + return rest == "" +} + +func validateControlPlaneTLS(tls *ControlPlaneTLSConfig) error { + if tls == nil { return nil } - path := strings.TrimSpace(https.Path) + path := strings.TrimSpace(tls.Path) hasPath := path != "" - hasBase64 := https.Base64 != nil && (strings.TrimSpace(https.Base64.Cert) != "" || - strings.TrimSpace(https.Base64.Key) != "" || - strings.TrimSpace(https.Base64.CA) != "") + hasBase64 := tls.Base64 != nil && (strings.TrimSpace(tls.Base64.Cert) != "" || + strings.TrimSpace(tls.Base64.Key) != "" || + strings.TrimSpace(tls.Base64.CA) != "") if hasPath && hasBase64 { - return errors.New("spec.https.path and spec.https.base64 are mutually exclusive") + return errors.New("spec.tls.path and spec.tls.base64 are mutually exclusive") } if !hasPath && !hasBase64 { return nil } if hasPath { - if !isValidHostPath(path) { - return errors.New("spec.https.path must be an absolute host path") - } - info, err := os.Stat(path) + cleaned, err := ValidateControlPlaneTLSPath(path) if err != nil { - return fmt.Errorf("spec.https.path must exist on the Edgelet host: %w", err) + return err } - if !info.IsDir() { - return errors.New("spec.https.path must be a directory") + tls.Path = cleaned + if _, err := StatControlPlaneTLSDir(cleaned); err != nil { + if errors.Is(err, os.ErrNotExist) || os.IsNotExist(err) { + return fmt.Errorf("spec.tls.path must exist on the Edgelet host: %w", err) + } + return err } - for _, file := range []string{ControlPlaneHTTPSCertFilename, ControlPlaneHTTPSKeyFilename} { - if _, err := os.Stat(filepath.Join(path, file)); err != nil { - return fmt.Errorf("spec.https.path must contain %s: %w", file, err) + for _, file := range []string{ControlPlaneTLSCertFilename, ControlPlaneTLSKeyFilename} { + if _, err := StatControlPlaneTLSFile(cleaned, file); err != nil { + return fmt.Errorf("spec.tls.path must contain %s: %w", file, err) } } return nil } - b := https.Base64 + b := tls.Base64 if strings.TrimSpace(b.Cert) == "" { - return errors.New("spec.https.base64.cert is required when spec.https.base64 is set") + return errors.New("spec.tls.base64.cert is required when spec.tls.base64 is set") } if strings.TrimSpace(b.Key) == "" { - return errors.New("spec.https.base64.key is required when spec.https.base64 is set") + return errors.New("spec.tls.base64.key is required when spec.tls.base64 is set") } for _, item := range []struct { field string @@ -250,7 +511,7 @@ func validateControlPlaneHTTPS(https *ControlPlaneHTTPSConfig) error { continue } if _, err := base64.StdEncoding.DecodeString(strings.TrimSpace(item.value)); err != nil { - return fmt.Errorf("spec.https.base64.%s must be valid base64: %w", item.field, err) + return fmt.Errorf("spec.tls.base64.%s must be valid base64: %w", item.field, err) } } return nil diff --git a/internal/models/control_plane_manifest_mask.go b/internal/models/control_plane_manifest_mask.go index 7efffbd..e1aa1c0 100644 --- a/internal/models/control_plane_manifest_mask.go +++ b/internal/models/control_plane_manifest_mask.go @@ -27,15 +27,25 @@ func (m *ControlPlaneManifest) MaskSecrets() { if m == nil { return } - m.Spec.Auth.ControllerSecret = maskSecretValue(m.Spec.Auth.ControllerSecret) + if m.Spec.Auth != nil { + if m.Spec.Auth.Bootstrap != nil { + m.Spec.Auth.Bootstrap.Password = maskSecretValue(m.Spec.Auth.Bootstrap.Password) + } + if m.Spec.Auth.Client != nil { + m.Spec.Auth.Client.Secret = maskSecretValue(m.Spec.Auth.Client.Secret) + } + if m.Spec.Auth.SessionStore != nil { + m.Spec.Auth.SessionStore.Secret = maskSecretValue(m.Spec.Auth.SessionStore.Secret) + } + } if m.Spec.Database != nil { m.Spec.Database.Password = maskSecretValue(m.Spec.Database.Password) m.Spec.Database.CA = maskSecretValue(m.Spec.Database.CA) } - if m.Spec.HTTPS != nil && m.Spec.HTTPS.Base64 != nil { - m.Spec.HTTPS.Base64.Cert = maskSecretValue(m.Spec.HTTPS.Base64.Cert) - m.Spec.HTTPS.Base64.Key = maskSecretValue(m.Spec.HTTPS.Base64.Key) - m.Spec.HTTPS.Base64.CA = maskSecretValue(m.Spec.HTTPS.Base64.CA) + if m.Spec.TLS != nil && m.Spec.TLS.Base64 != nil { + m.Spec.TLS.Base64.Cert = maskSecretValue(m.Spec.TLS.Base64.Cert) + m.Spec.TLS.Base64.Key = maskSecretValue(m.Spec.TLS.Base64.Key) + m.Spec.TLS.Base64.CA = maskSecretValue(m.Spec.TLS.Base64.CA) } if m.Spec.Vault != nil { if m.Spec.Vault.Hashicorp != nil { diff --git a/internal/models/control_plane_manifest_test.go b/internal/models/control_plane_manifest_test.go index fac64bf..9df5cbb 100644 --- a/internal/models/control_plane_manifest_test.go +++ b/internal/models/control_plane_manifest_test.go @@ -9,13 +9,16 @@ import ( "testing" ) +const controlPlaneTestImage = "ghcr.io/datasance/controller:3.8.0-beta.0" + func validControlPlaneManifestForTest() *ControlPlaneManifest { doc := &ControlPlaneManifest{} doc.APIVersion = controlPlaneAPIVersion doc.Kind = controlPlaneKind doc.Metadata.Name = "pot" doc.Metadata.Namespace = "default" - doc.Spec.Controller.Image = "ghcr.io/datasance/controller:3.7.0" + doc.Spec.Controller.Image = controlPlaneTestImage + doc.Spec.Auth = ValidEmbeddedAuthForTest() return doc } @@ -26,6 +29,76 @@ func TestControlPlaneManifestValidate_Minimal(t *testing.T) { } } +func TestControlPlaneManifestValidate_RequiresAuth(t *testing.T) { + doc := validControlPlaneManifestForTest() + doc.Spec.Auth = nil + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), "spec.auth is required") { + t.Fatalf("expected auth required error, got: %v", err) + } +} + +func TestControlPlaneManifestValidate_RequiresAuthMode(t *testing.T) { + doc := validControlPlaneManifestForTest() + doc.Spec.Auth.Mode = "" + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), "spec.auth.mode is required") { + t.Fatalf("expected auth mode required error, got: %v", err) + } +} + +func TestControlPlaneManifestValidate_EmbeddedRequiresBootstrapUsername(t *testing.T) { + doc := validControlPlaneManifestForTest() + doc.Spec.Auth.Bootstrap.Username = "" + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), "spec.auth.bootstrap.username is required") { + t.Fatalf("expected bootstrap username error, got: %v", err) + } +} + +func TestControlPlaneManifestValidate_EmbeddedPasswordComplexity(t *testing.T) { + doc := validControlPlaneManifestForTest() + doc.Spec.Auth.Bootstrap.Password = "short" + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), bootstrapPasswordComplexityMessage) { + t.Fatalf("expected password complexity error, got: %v", err) + } + + doc.Spec.Auth.Bootstrap.Password = "alllowercase12!" + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), bootstrapPasswordComplexityMessage) { + t.Fatalf("expected missing uppercase error, got: %v", err) + } + + doc.Spec.Auth.Bootstrap.Password = "NoSpecialChar12" + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), bootstrapPasswordComplexityMessage) { + t.Fatalf("expected missing special char error, got: %v", err) + } +} + +func TestControlPlaneManifestValidate_ExternalRequiresClientAndIssuer(t *testing.T) { + doc := validControlPlaneManifestForTest() + doc.Spec.Auth.Mode = "external" + doc.Spec.Auth.Bootstrap = nil + doc.Spec.Auth.IssuerURL = "https://auth.example.com/realms/pot" + doc.Spec.Auth.Client = &ControlPlaneAuthClient{ID: "client", Secret: "secret"} + if err := doc.Validate(); err != nil { + t.Fatalf("expected valid external auth, got: %v", err) + } + + doc.Spec.Auth.IssuerURL = "" + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), "spec.auth.issuerUrl is required") { + t.Fatalf("expected issuerUrl error, got: %v", err) + } + + doc.Spec.Auth.IssuerURL = "https://auth.example.com/realms/pot" + doc.Spec.Auth.Client.ID = "" + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), "spec.auth.client.id is required") { + t.Fatalf("expected client.id error, got: %v", err) + } + + doc.Spec.Auth.Client.ID = "client" + doc.Spec.Auth.Client.Secret = "" + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), "spec.auth.client.secret is required") { + t.Fatalf("expected client.secret error, got: %v", err) + } +} + func TestControlPlaneManifestValidate_RequiresControllerImage(t *testing.T) { doc := validControlPlaneManifestForTest() doc.Spec.Controller.Image = "" @@ -50,7 +123,12 @@ metadata: name: pot spec: controller: - image: ghcr.io/datasance/controller:3.7.0 + image: ghcr.io/datasance/controller:3.8.0-beta.0 + auth: + mode: embedded + bootstrap: + username: admin + password: AdminPass123! siteCA: cert: ignored ` @@ -68,7 +146,12 @@ metadata: name: pot spec: controller: - image: ghcr.io/datasance/controller:3.7.0 + image: ghcr.io/datasance/controller:3.8.0-beta.0 + auth: + mode: embedded + bootstrap: + username: admin + password: AdminPass123! localCA: cert: ignored ` @@ -78,21 +161,142 @@ spec: } } -func TestControlPlaneManifestValidate_HTTPSEmptyBlockAllowed(t *testing.T) { +func TestParseControlPlaneManifest_RejectsMissingAuth(t *testing.T) { + manifest := ` +apiVersion: edgelet.iofog.org/v1 +kind: ControlPlane +metadata: + name: pot +spec: + controller: + image: ghcr.io/datasance/controller:3.8.0-beta.0 +` + _, err := ParseControlPlaneManifest(manifest) + if err == nil || !strings.Contains(err.Error(), "spec.auth is required") { + t.Fatalf("expected missing auth error, got: %v", err) + } +} + +func TestParseControlPlaneManifest_RejectsLegacyAuthURL(t *testing.T) { + manifest := ` +apiVersion: edgelet.iofog.org/v1 +kind: ControlPlane +metadata: + name: pot +spec: + controller: + image: ghcr.io/datasance/controller:3.8.0-beta.0 + auth: + mode: external + url: https://auth.example.com/ + bootstrap: + username: admin + password: AdminPass123! +` + _, err := ParseControlPlaneManifest(manifest) + if err == nil || !strings.Contains(err.Error(), "url") { + t.Fatalf("expected legacy auth.url rejection, got: %v", err) + } +} + +func TestParseControlPlaneManifest_RejectsLegacyAuthExternal(t *testing.T) { + manifest := ` +apiVersion: edgelet.iofog.org/v1 +kind: ControlPlane +metadata: + name: pot +spec: + controller: + image: ghcr.io/datasance/controller:3.8.0-beta.0 + auth: + mode: external + external: + issuerUrl: https://auth.example.com/ + clientId: client + clientSecret: secret +` + _, err := ParseControlPlaneManifest(manifest) + if err == nil || !strings.Contains(err.Error(), "external") { + t.Fatalf("expected legacy auth.external rejection, got: %v", err) + } +} + +func TestParseControlPlaneManifest_RejectsLegacyECNViewerPort(t *testing.T) { + manifest := ` +apiVersion: edgelet.iofog.org/v1 +kind: ControlPlane +metadata: + name: pot +spec: + controller: + image: ghcr.io/datasance/controller:3.8.0-beta.0 + auth: + mode: embedded + bootstrap: + username: admin + password: AdminPass123! + ecnViewerPort: 8008 +` + _, err := ParseControlPlaneManifest(manifest) + if err == nil || !strings.Contains(err.Error(), "ecnViewerPort") { + t.Fatalf("expected legacy ecnViewerPort rejection, got: %v", err) + } +} + +func TestParseControlPlaneManifest_RejectsLegacyHTTPS(t *testing.T) { + manifest := ` +apiVersion: edgelet.iofog.org/v1 +kind: ControlPlane +metadata: + name: pot +spec: + controller: + image: ghcr.io/datasance/controller:3.8.0-beta.0 + auth: + mode: embedded + bootstrap: + username: admin + password: AdminPass123! + https: + path: /etc/certs +` + _, err := ParseControlPlaneManifest(manifest) + if err == nil || !strings.Contains(err.Error(), "https") { + t.Fatalf("expected legacy spec.https rejection, got: %v", err) + } +} + +func TestControlPlaneManifestValidate_InvalidAuthMode(t *testing.T) { doc := validControlPlaneManifestForTest() - doc.Spec.HTTPS = &ControlPlaneHTTPSConfig{} + doc.Spec.Auth.Mode = "keycloak" + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), "spec.auth.mode must be embedded or external") { + t.Fatalf("expected auth mode error, got: %v", err) + } +} + +func TestControlPlaneManifestValidate_InvalidSessionStoreType(t *testing.T) { + doc := validControlPlaneManifestForTest() + doc.Spec.Auth.SessionStore = &ControlPlaneAuthSessionStore{Type: "redis"} + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), "spec.auth.sessionStore.type must be memory or database") { + t.Fatalf("expected session store type error, got: %v", err) + } +} + +func TestControlPlaneManifestValidate_TLSEmptyBlockAllowed(t *testing.T) { + doc := validControlPlaneManifestForTest() + doc.Spec.TLS = &ControlPlaneTLSConfig{} if err := doc.Validate(); err != nil { - t.Fatalf("expected empty https block to pass: %v", err) + t.Fatalf("expected empty tls block to pass: %v", err) } } -func TestControlPlaneManifestValidate_HTTPSPathAndBase64MutuallyExclusive(t *testing.T) { +func TestControlPlaneManifestValidate_TLSPathAndBase64MutuallyExclusive(t *testing.T) { doc := validControlPlaneManifestForTest() dir := t.TempDir() writeControlPlaneCertFiles(t, dir, false) - doc.Spec.HTTPS = &ControlPlaneHTTPSConfig{ + doc.Spec.TLS = &ControlPlaneTLSConfig{ Path: dir, - Base64: &ControlPlaneHTTPSBase64{ + Base64: &ControlPlaneTLSBase64{ Cert: base64.StdEncoding.EncodeToString([]byte("cert")), Key: base64.StdEncoding.EncodeToString([]byte("key")), }, @@ -102,29 +306,118 @@ func TestControlPlaneManifestValidate_HTTPSPathAndBase64MutuallyExclusive(t *tes } } -func TestControlPlaneManifestValidate_HTTPSPathRequiresAbsoluteExistingDir(t *testing.T) { +func TestControlPlaneManifestValidate_TLSPathRequiresAbsoluteExistingDir(t *testing.T) { doc := validControlPlaneManifestForTest() - doc.Spec.HTTPS = &ControlPlaneHTTPSConfig{Path: "relative/certs"} + doc.Spec.TLS = &ControlPlaneTLSConfig{Path: "relative/certs"} if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), "absolute") { t.Fatalf("expected absolute path error, got: %v", err) } dir := t.TempDir() - doc.Spec.HTTPS.Path = dir + doc.Spec.TLS.Path = dir if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), "tls.crt") { t.Fatalf("expected missing tls.crt error, got: %v", err) } writeControlPlaneCertFiles(t, dir, true) if err := doc.Validate(); err != nil { - t.Fatalf("expected valid https path to pass: %v", err) + t.Fatalf("expected valid tls path to pass: %v", err) + } +} + +func TestValidateControlPlaneTLSPath_RejectsTraversalAndRoot(t *testing.T) { + t.Parallel() + + base := t.TempDir() + valid := filepath.Join(base, "certs") + if err := os.Mkdir(valid, 0o700); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + path string + wantErr string + }{ + {name: "valid", path: valid, wantErr: ""}, + {name: "relative", path: "relative/certs", wantErr: "absolute"}, + {name: "parent traversal", path: valid + string(filepath.Separator) + ".." + string(filepath.Separator) + "outside", wantErr: "traversal"}, + {name: "embedded traversal", path: filepath.Join(base, "nested") + string(filepath.Separator) + ".." + string(filepath.Separator) + ".." + string(filepath.Separator) + "escape", wantErr: "traversal"}, + {name: "unix root", path: string(filepath.Separator), wantErr: "root"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := ValidateControlPlaneTLSPath(tc.path) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("ValidateControlPlaneTLSPath(%q): %v", tc.path, err) + } + if got != filepath.Clean(tc.path) { + t.Fatalf("got cleaned path %q, want %q", got, filepath.Clean(tc.path)) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("ValidateControlPlaneTLSPath(%q) err=%v, want substring %q", tc.path, err, tc.wantErr) + } + }) + } +} + +func TestStatControlPlaneTLSFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeControlPlaneCertFiles(t, dir, true) + + info, err := StatControlPlaneTLSFile(dir, ControlPlaneTLSCertFilename) + if err != nil { + t.Fatalf("StatControlPlaneTLSFile(cert): %v", err) + } + if info.IsDir() { + t.Fatal("expected file, got directory") + } + + if _, err := StatControlPlaneTLSFile(dir, "evil.pem"); err == nil || !strings.Contains(err.Error(), "invalid control plane TLS filename") { + t.Fatalf("expected invalid basename error, got: %v", err) + } + + traversal := dir + string(filepath.Separator) + ".." + string(filepath.Separator) + filepath.Base(dir) + if _, err := StatControlPlaneTLSFile(traversal, ControlPlaneTLSCertFilename); err == nil || !strings.Contains(err.Error(), "traversal") { + t.Fatalf("expected traversal rejection, got: %v", err) + } +} + +func TestControlPlaneManifestValidate_TLSPathCanonicalizes(t *testing.T) { + doc := validControlPlaneManifestForTest() + dir := t.TempDir() + writeControlPlaneCertFiles(t, dir, false) + doc.Spec.TLS = &ControlPlaneTLSConfig{Path: dir + string(filepath.Separator)} + if err := doc.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if doc.Spec.TLS.Path != filepath.Clean(dir) { + t.Fatalf("got canonical path %q, want %q", doc.Spec.TLS.Path, filepath.Clean(dir)) + } +} + +func TestControlPlaneManifestValidate_TLSPathRejectsTraversal(t *testing.T) { + doc := validControlPlaneManifestForTest() + dir := t.TempDir() + writeControlPlaneCertFiles(t, dir, false) + traversal := dir + string(filepath.Separator) + ".." + string(filepath.Separator) + filepath.Base(dir) + doc.Spec.TLS = &ControlPlaneTLSConfig{Path: traversal} + if err := doc.Validate(); err == nil || !strings.Contains(err.Error(), "traversal") { + t.Fatalf("expected traversal rejection, got: %v", err) } } -func TestControlPlaneManifestValidate_HTTPSBase64RequiresValidEncoding(t *testing.T) { +func TestControlPlaneManifestValidate_TLSBase64RequiresValidEncoding(t *testing.T) { doc := validControlPlaneManifestForTest() - doc.Spec.HTTPS = &ControlPlaneHTTPSConfig{ - Base64: &ControlPlaneHTTPSBase64{ + doc.Spec.TLS = &ControlPlaneTLSConfig{ + Base64: &ControlPlaneTLSBase64{ Cert: "not-base64!!!", Key: base64.StdEncoding.EncodeToString([]byte("key")), }, @@ -143,16 +436,38 @@ func TestControlPlaneManifestNormalizeDefaults(t *testing.T) { } } -func writeControlPlaneCertFiles(t *testing.T, dir string, withCA bool) { +func TestControlPlaneManifestMaskSecrets(t *testing.T) { + doc := validControlPlaneManifestForTest() + doc.Spec.Auth.Client = &ControlPlaneAuthClient{ID: "client", Secret: "top-secret"} + doc.Spec.Auth.SessionStore = &ControlPlaneAuthSessionStore{Secret: "session-secret"} + doc.Spec.TLS = &ControlPlaneTLSConfig{ + Base64: &ControlPlaneTLSBase64{Cert: "cert-b64", Key: "key-b64", CA: "ca-b64"}, + } + doc.MaskSecrets() + if doc.Spec.Auth.Bootstrap.Password != controlPlaneSecretMask { + t.Fatalf("expected masked bootstrap password, got %q", doc.Spec.Auth.Bootstrap.Password) + } + if doc.Spec.Auth.Client.Secret != controlPlaneSecretMask { + t.Fatalf("expected masked client secret, got %q", doc.Spec.Auth.Client.Secret) + } + if doc.Spec.Auth.SessionStore.Secret != controlPlaneSecretMask { + t.Fatalf("expected masked session secret, got %q", doc.Spec.Auth.SessionStore.Secret) + } + if doc.Spec.TLS.Base64.Key != controlPlaneSecretMask { + t.Fatalf("expected masked tls key, got %q", doc.Spec.TLS.Base64.Key) + } +} + +func writeControlPlaneCertFiles(t *testing.T, dir string, withIntermediate bool) { t.Helper() - for _, name := range []string{ControlPlaneHTTPSCertFilename, ControlPlaneHTTPSKeyFilename} { + for _, name := range []string{ControlPlaneTLSCertFilename, ControlPlaneTLSKeyFilename} { if err := os.WriteFile(filepath.Join(dir, name), []byte("pem"), 0o600); err != nil { t.Fatalf("write %s: %v", name, err) } } - if withCA { - if err := os.WriteFile(filepath.Join(dir, ControlPlaneHTTPSCAFilename), []byte("ca"), 0o600); err != nil { - t.Fatalf("write ca: %v", err) + if withIntermediate { + if err := os.WriteFile(filepath.Join(dir, ControlPlaneTLSCAFilename), []byte("ca"), 0o600); err != nil { + t.Fatalf("write intermediate cert: %v", err) } } } diff --git a/internal/processmanager/controlplane_managed.go b/internal/processmanager/controlplane_managed.go new file mode 100644 index 0000000..b41dc18 --- /dev/null +++ b/internal/processmanager/controlplane_managed.go @@ -0,0 +1,24 @@ +package processmanager + +import ( + "strings" + + "github.com/eclipse-iofog/edgelet/internal/models" + "github.com/eclipse-iofog/edgelet/internal/store" +) + +func (pm *ProcessManager) isControlPlaneManagedMicroservice(ms *models.Microservice) bool { + if ms == nil { + return false + } + cp, found, err := store.GetInstance().GetSystemControlPlane() + if err != nil || !found || cp == nil { + return false + } + cpUUID := strings.TrimSpace(cp.ControllerUUID) + if cpUUID == "" { + return false + } + msUUID := strings.TrimSpace(ms.MicroserviceUUID) + return msUUID == cpUUID +} diff --git a/internal/processmanager/controlplane_managed_test.go b/internal/processmanager/controlplane_managed_test.go new file mode 100644 index 0000000..3aae355 --- /dev/null +++ b/internal/processmanager/controlplane_managed_test.go @@ -0,0 +1,36 @@ +package processmanager + +import ( + "testing" + + "github.com/eclipse-iofog/edgelet/internal/models" + "github.com/eclipse-iofog/edgelet/internal/store" +) + +func TestIsControlPlaneManagedMicroservice(t *testing.T) { + pm := &ProcessManager{} + if err := store.GetInstance().Open(t.TempDir()); err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = store.GetInstance().Close() }) + + if err := store.GetInstance().UpsertSystemControlPlane(&models.ControlPlaneDeployment{ + ControllerUUID: "cp-managed-1", + Namespace: "default", + Name: "pot", + ManifestYAML: "kind: ControlPlane", + DesiredState: "running", + }); err != nil { + t.Fatalf("upsert cp: %v", err) + } + + ms := models.NewMicroservice("cp-managed-1", "img") + ms.IsController = true + if !pm.isControlPlaneManagedMicroservice(ms) { + t.Fatal("expected controller uuid to be CP-managed") + } + other := models.NewMicroservice("other-ms", "img") + if pm.isControlPlaneManagedMicroservice(other) { + t.Fatal("expected unrelated ms not CP-managed") + } +} diff --git a/internal/processmanager/controlplane_reconcile.go b/internal/processmanager/controlplane_reconcile.go index 18fa41d..4d49388 100644 --- a/internal/processmanager/controlplane_reconcile.go +++ b/internal/processmanager/controlplane_reconcile.go @@ -9,10 +9,33 @@ import ( "github.com/eclipse-iofog/edgelet/internal/controlplane" "github.com/eclipse-iofog/edgelet/internal/models" "github.com/eclipse-iofog/edgelet/internal/network" + "github.com/eclipse-iofog/edgelet/internal/statusreporter" "github.com/eclipse-iofog/edgelet/internal/store" "github.com/eclipse-iofog/edgelet/pkg/engine" + "github.com/eclipse-iofog/edgelet/pkg/imageref" ) +// SetControlPlanePullOnRecreate requests an image pull on the next control plane generation recreate. +func (pm *ProcessManager) SetControlPlanePullOnRecreate(pull bool) { + if pm == nil { + return + } + pm.controlPlanePullOnRecreateMu.Lock() + defer pm.controlPlanePullOnRecreateMu.Unlock() + pm.controlPlanePullOnRecreate = pull +} + +func (pm *ProcessManager) consumeControlPlanePullOnRecreate() bool { + if pm == nil { + return false + } + pm.controlPlanePullOnRecreateMu.Lock() + defer pm.controlPlanePullOnRecreateMu.Unlock() + pull := pm.controlPlanePullOnRecreate + pm.controlPlanePullOnRecreate = false + return pull +} + func (pm *ProcessManager) reconcileControlPlane() { item, found, err := store.GetInstance().GetSystemControlPlane() if err != nil { @@ -123,7 +146,8 @@ func (pm *ProcessManager) reconcileControlPlaneDesiredRunning(item *models.Contr } if item.Generation > item.ObservedGeneration { - if err := pm.recreateControlPlaneDeployment(item, false, now); err == nil { + pullImage := pm.consumeControlPlanePullOnRecreate() + if err := pm.recreateControlPlaneDeployment(item, pullImage, now); err == nil { return } } @@ -208,6 +232,8 @@ func (pm *ProcessManager) reconcileControlPlaneDesiredRunning(item *models.Contr item.FailureCount = 0 } _ = store.GetInstance().UpsertSystemControlPlane(item) + pm.mergeControlPlaneContainerStats(item, container, status) + pm.syncControlPlaneProcessManagerStatus(item, container, status) return case "failed", "unknown": pm.bumpControlPlaneFailure(item, fmt.Errorf("runtime state=%s", runtime), runtime) @@ -218,6 +244,76 @@ func (pm *ProcessManager) reconcileControlPlaneDesiredRunning(item *models.Contr } _ = store.GetInstance().UpsertSystemControlPlane(item) + pm.mergeControlPlaneContainerStats(item, container, status) + pm.syncControlPlaneProcessManagerStatus(item, container, status) +} + +func (pm *ProcessManager) mergeControlPlaneContainerStats( + item *models.ControlPlaneDeployment, + container *engine.Container, + status *models.MicroserviceStatus, +) { + if pm == nil || item == nil || container == nil || status == nil { + return + } + if !item.ControllerRegistered { + return + } + if status.Status != models.MicroserviceStateRunning { + return + } + if pm.engine == nil { + return + } + if stats, err := pm.engine.GetContainerStats(container.ID); err == nil { + status.CPUUsage = stats.CPUUsage + status.MemoryUsage = stats.MemoryUsage + } +} + +func (pm *ProcessManager) syncControlPlaneProcessManagerStatus( + item *models.ControlPlaneDeployment, + container *engine.Container, + status *models.MicroserviceStatus, +) { + if item == nil { + return + } + uuid := strings.TrimSpace(item.ControllerUUID) + if uuid == "" { + return + } + statusreporter.GetInstance().UpdateProcessManagerStatus(func(pmStatus *models.ProcessManagerStatus) { + if status != nil { + pmStatus.SetMicroservicesStatus(uuid, status) + return + } + state := controlPlaneRuntimeStateToMicroserviceState(item.RuntimeState) + pmStatus.SetMicroservicesState(uuid, state) + if container != nil { + if existing := pmStatus.GetMicroserviceStatus(uuid); existing != nil { + existing.ContainerID = container.ID + pmStatus.SetMicroservicesStatus(uuid, existing) + } + } + }) +} + +func controlPlaneRuntimeStateToMicroserviceState(runtimeState string) models.MicroserviceState { + switch strings.ToLower(strings.TrimSpace(runtimeState)) { + case "running": + return models.MicroserviceStateRunning + case "starting", "stopping", "deleting": + return models.MicroserviceStateUpdating + case "stopped", "created": + return models.MicroserviceStateCreated + case "failed", "stuck_in_restart": + return models.MicroserviceStateFailed + case "exiting": + return models.MicroserviceStateExiting + default: + return models.MicroserviceStateUnknown + } } func controlPlaneLaunchInFlight(item *models.ControlPlaneDeployment, now int64) bool { @@ -326,6 +422,9 @@ func (pm *ProcessManager) recreateControlPlaneDeploymentWithProgress(item *model pm.bumpControlPlaneFailure(item, err, "failed") return err } + if pullImage { + pm.pullControlPlaneImage(ms, registry) + } if container, contErr := pm.containerForControlPlane(item.ControllerUUID, item.ContainerID); contErr == nil && container != nil { _ = pm.removeLocalContainerByID(container.ID) } @@ -371,3 +470,17 @@ func (pm *ProcessManager) buildControlPlaneLaunchSpec(item *models.ControlPlaneD } return doc, ms, registry, nil } + +func (pm *ProcessManager) pullControlPlaneImage(ms *models.Microservice, registry *models.Registry) { + if pm == nil || pm.engine == nil || ms == nil || registry == nil { + return + } + fromCache := strings.EqualFold(strings.TrimSpace(registry.URL), "from_cache") + pullRef, _ := imageref.Resolve(ms.ImageName, registry.URL, fromCache) + opts := &engine.PullImageOptions{Platform: msPlatform(ms)} + if err := pm.engine.PullImage(pullRef, registry, opts); err != nil { + pm.logger.Warnf("control plane recreate pull failed for %s, continuing with cache: %v", pullRef, err) + return + } + ms.ImageName = pullRef +} diff --git a/internal/processmanager/controlplane_reconcile_test.go b/internal/processmanager/controlplane_reconcile_test.go index 885daa1..62e8fa1 100644 --- a/internal/processmanager/controlplane_reconcile_test.go +++ b/internal/processmanager/controlplane_reconcile_test.go @@ -6,6 +6,7 @@ import ( "github.com/eclipse-iofog/edgelet/internal/controlplane" "github.com/eclipse-iofog/edgelet/internal/models" + "github.com/eclipse-iofog/edgelet/internal/statusreporter" "github.com/eclipse-iofog/edgelet/internal/store" "github.com/eclipse-iofog/edgelet/internal/utils/logging" "github.com/eclipse-iofog/edgelet/internal/workloadmeta" @@ -22,6 +23,18 @@ func (e *controlPlaneCaptureEngine) CreateContainer(ms *models.Microservice, _ s return "cp-cid-" + ms.MicroserviceUUID, nil } +type controlPlaneStatsEngine struct { + controlPlaneCaptureEngine + stats *engine.ContainerStats +} + +func (e *controlPlaneStatsEngine) GetContainerStats(string) (*engine.ContainerStats, error) { + if e.stats == nil { + return &engine.ContainerStats{}, nil + } + return e.stats, nil +} + func TestReconcileControlPlane_NoDeploymentIsNoOp(t *testing.T) { openLocalReconcileTestDB(t) pm := &ProcessManager{logger: logging.NewModuleLogger("test-process-manager")} @@ -148,6 +161,164 @@ func TestReconcileControlPlane_RecreatesOnGenerationBump(t *testing.T) { } } +func TestReconcileControlPlane_RecreatesWithPullOnRecreateFlag(t *testing.T) { + openLocalReconcileTestDB(t) + + pm := &ProcessManager{logger: logging.NewModuleLogger("test-process-manager")} + + dep := &models.ControlPlaneDeployment{ + ControllerUUID: "cp-recreate-pull", + Namespace: "default", + Name: "pot", + ManifestYAML: minimalControlPlaneManifestYAML(), + DesiredState: "running", + Generation: 2, + ObservedGeneration: 1, + RuntimeState: "running", + ContainerID: "old-cid", + } + if err := store.GetInstance().UpsertSystemControlPlane(dep); err != nil { + t.Fatalf("upsert control plane: %v", err) + } + + pm.SetControlPlanePullOnRecreate(true) + + pullImage := false + pm.recreateControlPlaneFn = func(_ *models.ControlPlaneDeployment, wantPull bool, _ int64) error { + pullImage = wantPull + return nil + } + pm.getContainerStatusFn = func(_, _ string) (*models.MicroserviceStatus, error) { + return &models.MicroserviceStatus{Status: models.MicroserviceStateRunning}, nil + } + + eng := &controlPlaneCaptureEngine{} + eng.workload = &engine.Container{ + ID: "old-cid", + Image: "ghcr.io/datasance/controller:3.7.0", + Labels: map[string]string{ + workloadmeta.LabelMicroserviceUID: "cp-recreate-pull", + }, + } + pm.engine = eng + pm.containerManager = NewContainerManager(eng, nil, "docker") + + pm.reconcileControlPlane() + + if !pullImage { + t.Fatal("expected recreateControlPlaneFn to receive pullImage=true") + } + if pm.consumeControlPlanePullOnRecreate() { + t.Fatal("expected pull-on-recreate flag to be consumed") + } +} + +func TestReconcileControlPlane_ReportsContainerStatsWhenRegistered(t *testing.T) { + openLocalReconcileTestDB(t) + statusreporter.GetInstance().ResetProcessManagerStatus() + t.Cleanup(func() { statusreporter.GetInstance().ResetProcessManagerStatus() }) + + eng := &controlPlaneStatsEngine{ + stats: &engine.ContainerStats{CPUUsage: 12.5, MemoryUsage: 987654}, + } + pm := &ProcessManager{ + logger: logging.NewModuleLogger("test-process-manager"), + engine: eng, + containerManager: NewContainerManager(eng, nil, "docker"), + } + + dep := &models.ControlPlaneDeployment{ + ControllerUUID: "cp-stats", + Namespace: "default", + Name: "pot", + ManifestYAML: minimalControlPlaneManifestYAML(), + DesiredState: "running", + Generation: 1, + ObservedGeneration: 1, + RuntimeState: "running", + ContainerID: "old-cid", + ControllerRegistered: true, + } + if err := store.GetInstance().UpsertSystemControlPlane(dep); err != nil { + t.Fatalf("upsert control plane: %v", err) + } + + eng.workload = &engine.Container{ + ID: "old-cid", + Image: "ghcr.io/datasance/controller:3.7.0", + Labels: map[string]string{ + workloadmeta.LabelMicroserviceUID: "cp-stats", + }, + } + pm.getContainerStatusFn = func(_, _ string) (*models.MicroserviceStatus, error) { + return &models.MicroserviceStatus{Status: models.MicroserviceStateRunning}, nil + } + + pm.reconcileControlPlane() + + msStatus := statusreporter.GetInstance().GetProcessManagerStatus().GetMicroserviceStatus("cp-stats") + if msStatus == nil { + t.Fatal("expected controller microservice status") + } + if msStatus.CPUUsage != 12.5 { + t.Fatalf("expected cpuUsage=12.5, got %v", msStatus.CPUUsage) + } + if msStatus.MemoryUsage != 987654 { + t.Fatalf("expected memoryUsage=987654, got %d", msStatus.MemoryUsage) + } +} + +func TestReconcileControlPlane_OmitsContainerStatsBeforeRegister(t *testing.T) { + openLocalReconcileTestDB(t) + statusreporter.GetInstance().ResetProcessManagerStatus() + t.Cleanup(func() { statusreporter.GetInstance().ResetProcessManagerStatus() }) + + eng := &controlPlaneStatsEngine{ + stats: &engine.ContainerStats{CPUUsage: 12.5, MemoryUsage: 987654}, + } + pm := &ProcessManager{ + logger: logging.NewModuleLogger("test-process-manager"), + engine: eng, + containerManager: NewContainerManager(eng, nil, "docker"), + } + + dep := &models.ControlPlaneDeployment{ + ControllerUUID: "cp-no-stats", + Namespace: "default", + Name: "pot", + ManifestYAML: minimalControlPlaneManifestYAML(), + DesiredState: "running", + Generation: 1, + ObservedGeneration: 1, + RuntimeState: "running", + ContainerID: "old-cid", + } + if err := store.GetInstance().UpsertSystemControlPlane(dep); err != nil { + t.Fatalf("upsert control plane: %v", err) + } + + eng.workload = &engine.Container{ + ID: "old-cid", + Image: "ghcr.io/datasance/controller:3.7.0", + Labels: map[string]string{ + workloadmeta.LabelMicroserviceUID: "cp-no-stats", + }, + } + pm.getContainerStatusFn = func(_, _ string) (*models.MicroserviceStatus, error) { + return &models.MicroserviceStatus{Status: models.MicroserviceStateRunning}, nil + } + + pm.reconcileControlPlane() + + msStatus := statusreporter.GetInstance().GetProcessManagerStatus().GetMicroserviceStatus("cp-no-stats") + if msStatus == nil { + t.Fatal("expected controller microservice status") + } + if msStatus.CPUUsage != 0 || msStatus.MemoryUsage != 0 { + t.Fatalf("expected stats omitted before register, got cpu=%v memory=%d", msStatus.CPUUsage, msStatus.MemoryUsage) + } +} + func TestBuildControlPlaneLaunchSpec(t *testing.T) { openLocalReconcileTestDB(t) pm := &ProcessManager{logger: logging.NewModuleLogger("test-process-manager")} @@ -177,7 +348,7 @@ func assertControlPlaneLaunchSpec(t *testing.T, ms *models.Microservice) { if len(ms.PortMappings) != 2 { t.Fatalf("expected 2 port mappings, got %d", len(ms.PortMappings)) } - if ms.PortMappings[0].Outside != controlplane.HostAPIPort || ms.PortMappings[1].Outside != controlplane.HostViewerPort { + if ms.PortMappings[0].Outside != controlplane.HostAPIPort || ms.PortMappings[1].Outside != controlplane.HostConsolePort { t.Fatalf("unexpected host ports: %+v", ms.PortMappings) } @@ -228,6 +399,11 @@ metadata: namespace: default spec: controller: - image: ghcr.io/datasance/controller:3.7.0 + image: ghcr.io/datasance/controller:3.8.0-beta.0 + auth: + mode: embedded + bootstrap: + username: admin + password: AdminPass123! ` } diff --git a/internal/processmanager/manager.go b/internal/processmanager/manager.go index dd85831..1413a33 100644 --- a/internal/processmanager/manager.go +++ b/internal/processmanager/manager.go @@ -40,25 +40,27 @@ const ( // ProcessManager manages container lifecycle via a ContainerEngine. type ProcessManager struct { - engine engine.ContainerEngine - engineName string - microserviceManager MicroserviceManagerInterface - containerManager *ContainerManager - taskQueue *TaskQueue - updateChan chan struct{} - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - logger *logging.ModuleLogger - startMicroserviceFn func(microserviceUUID string) error - removeContainerByIDFn func(containerID string) error - launchLocalDeploymentFn func(item *models.LocalDeployedMicroservice, now int64) - launchControlPlaneFn func(item *models.ControlPlaneDeployment, now int64) - recreateLocalDeploymentFn func(item *models.LocalDeployedMicroservice, pullImage bool, now int64) error - recreateControlPlaneFn func(item *models.ControlPlaneDeployment, pullImage bool, now int64) error - getContainerStatusFn func(containerID, microserviceUUID string) (*models.MicroserviceStatus, error) - reconcileMonitorTick uint64 - localLaunchLocks sync.Map // microservice UUID -> *sync.Mutex + engine engine.ContainerEngine + engineName string + microserviceManager MicroserviceManagerInterface + containerManager *ContainerManager + taskQueue *TaskQueue + updateChan chan struct{} + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + logger *logging.ModuleLogger + startMicroserviceFn func(microserviceUUID string) error + removeContainerByIDFn func(containerID string) error + launchLocalDeploymentFn func(item *models.LocalDeployedMicroservice, now int64) + launchControlPlaneFn func(item *models.ControlPlaneDeployment, now int64) + recreateLocalDeploymentFn func(item *models.LocalDeployedMicroservice, pullImage bool, now int64) error + recreateControlPlaneFn func(item *models.ControlPlaneDeployment, pullImage bool, now int64) error + getContainerStatusFn func(containerID, microserviceUUID string) (*models.MicroserviceStatus, error) + reconcileMonitorTick uint64 + localLaunchLocks sync.Map // microservice UUID -> *sync.Mutex + controlPlanePullOnRecreate bool + controlPlanePullOnRecreateMu sync.Mutex } // LocalDeployProgressCallback reports local deployment runtime stage transitions. @@ -1077,6 +1079,9 @@ func (pm *ProcessManager) handleLatestMicroservices(stats *reconcileCycleStats) if ms.GetIsUpdating() { continue } + if pm.isControlPlaneManagedMicroservice(ms) { + continue + } container, err := pm.containerManager.GetContainerForMicroservice(ms.MicroserviceUUID) if err != nil { diff --git a/internal/runtimeapi/controlplane.go b/internal/runtimeapi/controlplane.go index 637c7eb..5a66e4d 100644 --- a/internal/runtimeapi/controlplane.go +++ b/internal/runtimeapi/controlplane.go @@ -80,7 +80,7 @@ func ControlPlaneStatusMap(item *models.ControlPlaneDeployment) map[string]any { "observedGeneration": item.ObservedGeneration, "lastTransitionAt": item.LastTransitionAt, "source": "controlplane", - "type": "controlplane", + "type": "controller", } } @@ -95,6 +95,9 @@ func (f *Facade) GetControlPlaneManifestMasked() (string, error) { // DeleteControlPlane removes the controller deployment, container, and volumes. func (f *Facade) DeleteControlPlane() error { + if !f.fa.NotProvisioned() { + return &ErrControlPlaneDeleteBlocked{} + } pm := processmanager.GetInstance() if err := pm.DeleteControlPlane(); err != nil { if errors.Is(err, processmanager.ErrControlPlaneNotFound) { @@ -185,6 +188,10 @@ func (f *Facade) ApplyControlPlaneManifest(manifest, sourceName string, dryRun b if existing != nil && strings.TrimSpace(existing.ContainerID) != "" { item.ContainerID = strings.TrimSpace(existing.ContainerID) } + if existing != nil { + item.ControllerRegistered = existing.ControllerRegistered + item.InitialRebuildSkipped = existing.InitialRebuildSkipped + } emitDeployProgress(progress, DeployStagePersisting, "saving control plane deployment") if err := f.db.UpsertSystemControlPlane(item); err != nil { diff --git a/internal/runtimeapi/controlplane_ms.go b/internal/runtimeapi/controlplane_ms.go index ad49697..474d29e 100644 --- a/internal/runtimeapi/controlplane_ms.go +++ b/internal/runtimeapi/controlplane_ms.go @@ -19,11 +19,18 @@ func (e *ErrControlPlaneLifecycleBlocked) Error() string { op = "mutation" } return fmt.Sprintf( - "control plane microservice %s is not allowed; use DELETE /v1/system/controlplane or edgelet controlplane delete", + "controller microservice cannot be %s while agent is provisioned; deprovision the agent or use edgelet controlplane delete when unprovisioned", op, ) } +// ErrControlPlaneDeleteBlocked indicates DELETE /v1/system/controlplane is forbidden while provisioned. +type ErrControlPlaneDeleteBlocked struct{} + +func (e *ErrControlPlaneDeleteBlocked) Error() string { + return "control plane delete is not allowed while agent is provisioned; deprovision the agent first" +} + func (f *Facade) controlPlaneDeploymentRow() (*models.ControlPlaneDeployment, bool) { if f == nil || f.db == nil || f.db.Conn() == nil { return nil, false @@ -39,11 +46,21 @@ func (f *Facade) controlPlaneDeploymentRow() (*models.ControlPlaneDeployment, bo } func (f *Facade) guardControlPlaneMicroserviceMutation(uuid, operation string) error { + if f.fa.NotProvisioned() { + return nil + } + uuid = strings.TrimSpace(uuid) + if uuid == "" { + return nil + } + if ms := f.fa.FindLatestMicroserviceByUUID(uuid); ms != nil && ms.IsController { + return &ErrControlPlaneLifecycleBlocked{Operation: operation} + } item, ok := f.controlPlaneDeploymentRow() if !ok { return nil } - if strings.TrimSpace(item.ControllerUUID) != strings.TrimSpace(uuid) { + if strings.TrimSpace(item.ControllerUUID) != uuid { return nil } return &ErrControlPlaneLifecycleBlocked{Operation: operation} @@ -63,13 +80,19 @@ func controlPlaneRuntimeListEntry(item *models.ControlPlaneDeployment) map[strin "name": item.Name, "application": item.Namespace, "source": "controlplane", - "type": "controlplane", + "type": "controller", "state": state, "containerId": item.ContainerID, "image": item.Image, } } +// IsControlPlaneDeleteBlocked reports whether err blocks control-plane delete while provisioned. +func IsControlPlaneDeleteBlocked(err error) bool { + var blocked *ErrControlPlaneDeleteBlocked + return errors.As(err, &blocked) +} + // IsControlPlaneLifecycleBlocked reports whether err blocks control-plane ms lifecycle. func IsControlPlaneLifecycleBlocked(err error) bool { var blocked *ErrControlPlaneLifecycleBlocked diff --git a/internal/runtimeapi/controlplane_ms_test.go b/internal/runtimeapi/controlplane_ms_test.go index 2c9b785..5728519 100644 --- a/internal/runtimeapi/controlplane_ms_test.go +++ b/internal/runtimeapi/controlplane_ms_test.go @@ -47,7 +47,7 @@ func TestFacadeListRuntimeMicroservices_IncludesControlPlaneEntry(t *testing.T) "name": "pot", "application": "default", "source": "controlplane", - "type": "controlplane", + "type": "controller", } { if got := item[key]; got != want { t.Fatalf("%s: want %q got %v", key, want, got) @@ -73,6 +73,7 @@ func TestFacadeGuardControlPlaneMicroserviceMutation_BlocksLifecycle(t *testing. if err := f.db.UpsertSystemControlPlane(dep); err != nil { t.Fatalf("upsert: %v", err) } + f.fa.SetControllerStatus(models.ControllerStatusOK) for _, op := range []struct { name string @@ -93,7 +94,7 @@ func TestFacadeGuardControlPlaneMicroserviceMutation_BlocksLifecycle(t *testing. if !errors.As(err, &blocked) { t.Fatalf("expected ErrControlPlaneLifecycleBlocked, got %v", err) } - if !strings.Contains(err.Error(), "controlplane delete") { + if !strings.Contains(err.Error(), "while agent is provisioned") { t.Fatalf("unexpected message: %v", err) } }) @@ -124,7 +125,7 @@ func TestFacadeGetRuntimeMicroservice_ControlPlane(t *testing.T) { if err != nil { t.Fatalf("get: %v", err) } - if item["type"] != "controlplane" || item["application"] != "default" || item["name"] != "pot" { + if item["type"] != "controller" || item["application"] != "default" || item["name"] != "pot" { t.Fatalf("unexpected item: %#v", item) } raw, ok := item["raw"].(map[string]any) @@ -142,6 +143,33 @@ func TestFacadeGetRuntimeMicroservice_ControlPlane(t *testing.T) { } } +func TestFacadeDeleteControlPlane_BlockedWhenProvisioned(t *testing.T) { + f := NewFacade() + if err := f.db.Open(t.TempDir()); err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = f.db.Close() }) + + if err := f.db.UpsertSystemControlPlane(&models.ControlPlaneDeployment{ + ControllerUUID: "cp-del-1", + Namespace: "default", + Name: "pot", + ManifestYAML: "kind: ControlPlane", + DesiredState: "running", + }); err != nil { + t.Fatalf("upsert: %v", err) + } + f.fa.SetControllerStatus(models.ControllerStatusOK) + + err := f.DeleteControlPlane() + if err == nil { + t.Fatal("expected error") + } + if !IsControlPlaneDeleteBlocked(err) { + t.Fatalf("expected ErrControlPlaneDeleteBlocked, got %v", err) + } +} + func TestControlPlaneFQDNsFixtureDefaultPot(t *testing.T) { // Handoff fixture for IT: namespace=default name=pot want := []string{ diff --git a/internal/store/control_plane_deployments.go b/internal/store/control_plane_deployments.go index 4c7b422..687abd7 100644 --- a/internal/store/control_plane_deployments.go +++ b/internal/store/control_plane_deployments.go @@ -32,13 +32,17 @@ func (d *DB) UpsertSystemControlPlane(dep *models.ControlPlaneDeployment) error dep.NormalizeDefaults() + controllerRegistered := boolToSQLiteInt(dep.ControllerRegistered) + initialRebuildSkipped := boolToSQLiteInt(dep.InitialRebuildSkipped) + _, err := d.Conn().Exec(`INSERT INTO system_control_plane ( id, controller_uuid, namespace, name, manifest_yaml, image, container_id, state, desired_state, runtime_state, last_error, restart_count, last_transition_at, last_reconcile_at, last_start_attempt_at, failure_count, - deleted_at, generation, observed_generation, created_at, updated_at + deleted_at, generation, observed_generation, controller_registered, + initial_rebuild_skipped, created_at, updated_at ) VALUES ( - ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,strftime('%s','now'),? + ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,strftime('%s','now'),? ) ON CONFLICT(id) DO UPDATE SET controller_uuid=excluded.controller_uuid, @@ -59,12 +63,15 @@ func (d *DB) UpsertSystemControlPlane(dep *models.ControlPlaneDeployment) error deleted_at=excluded.deleted_at, generation=excluded.generation, observed_generation=excluded.observed_generation, + controller_registered=excluded.controller_registered, + initial_rebuild_skipped=excluded.initial_rebuild_skipped, updated_at=excluded.updated_at`, controlPlaneSingletonID, dep.ControllerUUID, dep.Namespace, dep.Name, dep.ManifestYAML, dep.Image, dep.ContainerID, dep.State, dep.DesiredState, dep.RuntimeState, dep.LastError, dep.RestartCount, dep.LastTransitionAt, dep.LastReconcileAt, dep.LastStartAttemptAt, dep.FailureCount, - dep.DeletedAt, dep.Generation, dep.ObservedGeneration, time.Now().Unix(), + dep.DeletedAt, dep.Generation, dep.ObservedGeneration, controllerRegistered, + initialRebuildSkipped, time.Now().Unix(), ) if err != nil { return fmt.Errorf("failed to upsert system control plane: %w", err) @@ -80,16 +87,18 @@ func (d *DB) GetSystemControlPlane() (*models.ControlPlaneDeployment, bool, erro } item := &models.ControlPlaneDeployment{} + var controllerRegistered, initialRebuildSkipped int err := d.Conn().QueryRow(`SELECT controller_uuid, namespace, name, manifest_yaml, image, container_id, state, desired_state, runtime_state, last_error, restart_count, last_transition_at, last_reconcile_at, last_start_attempt_at, failure_count, - deleted_at, generation, observed_generation + deleted_at, generation, observed_generation, controller_registered, initial_rebuild_skipped FROM system_control_plane WHERE id = ?`, controlPlaneSingletonID).Scan( &item.ControllerUUID, &item.Namespace, &item.Name, &item.ManifestYAML, &item.Image, &item.ContainerID, &item.State, &item.DesiredState, &item.RuntimeState, &item.LastError, &item.RestartCount, &item.LastTransitionAt, &item.LastReconcileAt, &item.LastStartAttemptAt, &item.FailureCount, &item.DeletedAt, &item.Generation, &item.ObservedGeneration, + &controllerRegistered, &initialRebuildSkipped, ) if err == sql.ErrNoRows { return nil, false, nil @@ -97,10 +106,19 @@ func (d *DB) GetSystemControlPlane() (*models.ControlPlaneDeployment, bool, erro if err != nil { return nil, false, fmt.Errorf("failed to get system control plane: %w", err) } + item.ControllerRegistered = controllerRegistered != 0 + item.InitialRebuildSkipped = initialRebuildSkipped != 0 item.NormalizeDefaults() return item, true, nil } +func boolToSQLiteInt(v bool) int { + if v { + return 1 + } + return 0 +} + // DeleteSystemControlPlane removes the singleton system control plane row. func (d *DB) DeleteSystemControlPlane() error { if d.Conn() == nil { diff --git a/internal/store/control_plane_deployments_test.go b/internal/store/control_plane_deployments_test.go index 4b0febe..2458d84 100644 --- a/internal/store/control_plane_deployments_test.go +++ b/internal/store/control_plane_deployments_test.go @@ -112,3 +112,29 @@ func TestSystemControlPlaneUpsertValidation(t *testing.T) { t.Fatal("expected error for missing controller_uuid") } } + +func TestSystemControlPlaneRegisterStateFlags(t *testing.T) { + db := openFreshStoreDB(t) + + dep := &models.ControlPlaneDeployment{ + ControllerUUID: "cp-uuid-flags", + Name: "pot", + ManifestYAML: "kind: ControlPlane", + ControllerRegistered: true, + InitialRebuildSkipped: true, + } + if err := db.UpsertSystemControlPlane(dep); err != nil { + t.Fatalf("upsert system control plane: %v", err) + } + + got, found, err := db.GetSystemControlPlane() + if err != nil || !found { + t.Fatalf("get system control plane: found=%v err=%v", found, err) + } + if !got.ControllerRegistered { + t.Fatal("expected controller_registered=true") + } + if !got.InitialRebuildSkipped { + t.Fatal("expected initial_rebuild_skipped=true") + } +} diff --git a/internal/store/migrations/001_edgelet_schema_v1.sql b/internal/store/migrations/001_edgelet_schema_v1.sql index b241c45..51f55cd 100644 --- a/internal/store/migrations/001_edgelet_schema_v1.sql +++ b/internal/store/migrations/001_edgelet_schema_v1.sql @@ -193,6 +193,8 @@ CREATE TABLE IF NOT EXISTS system_control_plane ( deleted_at INTEGER, generation INTEGER NOT NULL DEFAULT 1, observed_generation INTEGER NOT NULL DEFAULT 0, + controller_registered INTEGER NOT NULL DEFAULT 0, + initial_rebuild_skipped INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ) diff --git a/internal/store/schema_v1_contract_test.go b/internal/store/schema_v1_contract_test.go index 941b8bb..c7d8d89 100644 --- a/internal/store/schema_v1_contract_test.go +++ b/internal/store/schema_v1_contract_test.go @@ -250,6 +250,8 @@ func TestSchemaV1_SystemControlPlaneKeyColumns(t *testing.T) { "deleted_at", "generation", "observed_generation", + "controller_registered", + "initial_rebuild_skipped", }) if cols["id"].pk != 1 { t.Fatal("system_control_plane.id must be primary key") diff --git a/internal/supervisor/supervisor.go b/internal/supervisor/supervisor.go index c3144eb..87f64da 100644 --- a/internal/supervisor/supervisor.go +++ b/internal/supervisor/supervisor.go @@ -249,8 +249,8 @@ func (s *Supervisor) Start() error { // Start Edgelet API Server and wait until listeners are ready. s.localAPI = edgeletapi.GetInstance() - // Register Supervisor's ReloadConfig as the config reload callback - s.config.SetReloadCallback(s.ReloadConfig) + // Register full disk reload (validate, logger, supervisor) as the config reload callback. + s.config.SetReloadCallback(s.ReloadFromDisk) // Register FieldAgent GPS callback for dedicated config/gps controller sync. s.config.SetGPSConfigCallback(s.fieldAgent.InstanceGPSConfigUpdated) if err := s.localAPI.Start(); err != nil { @@ -687,6 +687,15 @@ func (s *Supervisor) GetModuleIndex() int { return utils.ProcessManager } +// ReloadFromDisk performs a full hot reload: read disk, validate, update logger, notify modules. +func (s *Supervisor) ReloadFromDisk() error { + return config.FullReload(config.ReloadHooks{ + ConfigPath: utils.ConfigYAMLPath, + BeginReload: s.BeginConfigReload, + NotifyModules: s.ReloadConfig, + }) +} + // BeginConfigReload snapshots engine connection settings before LoadConfig replaces them. func (s *Supervisor) BeginConfigReload() { s.pendingReloadCtx = s.captureReloadEngineContext() diff --git a/internal/utils/constants.go b/internal/utils/constants.go index f2cc31c..266ba2b 100644 --- a/internal/utils/constants.go +++ b/internal/utils/constants.go @@ -2,6 +2,7 @@ package utils //nolint:revive // legacy package name import ( "os" + "path/filepath" ) // ModulesStatus represents the status of a module @@ -95,7 +96,7 @@ var ( // Path constants var ( - WindowsEdgeletPath = getEnvOrDefault("EDGELET_PATH", "./") + WindowsEdgeletPath = getWindowsEdgeletBase() VarRun = getVarRunPath() ConfigDir = getConfigDir() EdgeletAPITokenPath = ConfigDir + "edgelet-api" @@ -143,16 +144,27 @@ func getEnvOrDefault(key, defaultValue string) string { return defaultValue } +func getWindowsEdgeletBase() string { + if val := os.Getenv("EDGELET_PATH"); val != "" { + return val + } + pd := os.Getenv("ProgramData") + if pd == "" { + pd = `C:\ProgramData` + } + return filepath.Join(pd, "Edgelet") +} + func getVarRunPath() string { if isWindows() { - return SNAPCommon + "./var/run/edgelet" + return filepath.Join(getWindowsEdgeletBase(), "run") + string(filepath.Separator) } - return SNAPCommon + "/var/run/edgelet" + return SNAPCommon + "/var/run/edgelet/" } func getConfigDir() string { if isWindows() { - return WindowsEdgeletPath + return filepath.Join(getWindowsEdgeletBase(), "config") + string(filepath.Separator) } return SNAPCommon + "/etc/edgelet/" } diff --git a/internal/version/handler.go b/internal/version/handler.go index 504219c..0b388c0 100644 --- a/internal/version/handler.go +++ b/internal/version/handler.go @@ -44,12 +44,16 @@ func ParseVersionCommand(actionData map[string]any) (VersionCommand, error) { } } +// RefreshFunc fetches a fresh controller version payload (used for OTA key refresh). +type RefreshFunc func() (map[string]any, error) + // Handler orchestrates controller-driven OTA via install.sh. type Handler struct { manager *ReleaseManager isContainer func() bool isDaemonHealthy func() bool startDetached func(script string, args ...string) error + refreshVersion RefreshFunc mu sync.Mutex otaUntil time.Time @@ -81,6 +85,14 @@ func GetInstance() *Handler { return instance } +// SetVersionRefreshFunc configures a callback to re-fetch controller version metadata. +func (h *Handler) SetVersionRefreshFunc(fn RefreshFunc) { + if h == nil { + return + } + h.refreshVersion = fn +} + // ChangeVersion performs a controller-initiated upgrade or rollback. func (h *Handler) ChangeVersion(actionData map[string]any) error { logging.LogInfo(moduleName, "Start performing change version operation, received from ioFog controller") @@ -117,8 +129,25 @@ func (h *Handler) ChangeVersion(actionData map[string]any) error { } func (h *Handler) executeChangeVersionScript(command VersionCommand, actionData map[string]any, provisionKey string) error { + actionData, provisionKey, err := h.refreshVersionActionIfNeeded(actionData, provisionKey) + if err != nil { + return err + } + if provisionKey != "" { - logging.LogInfo(moduleName, fmt.Sprintf("Change version audit provisionKey=%s", provisionKey)) + pending, err := PendingFromAction(actionData) + if err != nil { + return fmt.Errorf("OTA reprovision pending: %w", err) + } + if err := WriteOTAReprovisionPending( + pending.ProvisionKey, + pending.Command, + pending.TargetVersion, + pending.ExpirationTime, + ); err != nil { + return fmt.Errorf("write OTA reprovision pending: %w", err) + } + logging.LogInfo(moduleName, "Wrote OTA reprovision pending file before install.sh") } script := h.manager.InstallScriptPath() @@ -127,7 +156,7 @@ func (h *Handler) executeChangeVersionScript(command VersionCommand, actionData case VersionCommandUpgrade: args = append(args, "--upgrade") if target := targetVersionFromAction(actionData); target != "" { - args = append(args, "--version="+target) + args = append(args, "--version="+versionForInstallScript(target)) } case VersionCommandRollback: args = append(args, "--rollback") @@ -145,12 +174,53 @@ func (h *Handler) executeChangeVersionScript(command VersionCommand, actionData return nil } +func (h *Handler) refreshVersionActionIfNeeded(actionData map[string]any, provisionKey string) (map[string]any, string, error) { + if provisionKey == "" { + return actionData, provisionKey, nil + } + + pending, err := PendingFromAction(actionData) + if err != nil { + return actionData, provisionKey, err + } + if !pending.NeedsPreflightRefresh(time.Now()) { + return actionData, provisionKey, nil + } + if h.refreshVersion == nil { + logging.LogWarn(moduleName, "OTA provision key near expiry but version refresh callback is unset") + return actionData, provisionKey, nil + } + + raw, err := h.refreshVersion() + if err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("Pre-flight version refresh failed: %v", err)) + return actionData, provisionKey, nil + } + + refreshed, err := NormalizeVersionResponse(raw) + if err != nil { + logging.LogWarn(moduleName, fmt.Sprintf("Pre-flight version refresh normalize failed: %v", err)) + return actionData, provisionKey, nil + } + if refreshed == nil { + return actionData, provisionKey, nil + } + + key, ok := refreshed["provisionKey"].(string) + if !ok || strings.TrimSpace(key) == "" { + return actionData, provisionKey, nil + } + + logging.LogInfo(moduleName, "Refreshed OTA provision key before install.sh") + return refreshed, key, nil +} + func (h *Handler) isValidChangeVersionOperation(command VersionCommand, actionData map[string]any) bool { switch command { case VersionCommandUpgrade: return h.IsReadyToUpgradeWithAction(actionData) case VersionCommandRollback: - return h.IsReadyToRollback() + return h.IsReadyToRollbackWithAction(actionData) default: return false } @@ -213,6 +283,11 @@ func (h *Handler) containerUpgradeReady(actionData map[string]any) bool { // IsReadyToRollback reports rollback readiness from previous-release and cache/url. func (h *Handler) IsReadyToRollback() bool { + return h.IsReadyToRollbackWithAction(nil) +} + +// IsReadyToRollbackWithAction reports rollback readiness for an optional controller target. +func (h *Handler) IsReadyToRollbackWithAction(actionData map[string]any) bool { logging.LogDebug(moduleName, "Checking is ready to rollback") if h.otaInProgress() { @@ -240,6 +315,16 @@ func (h *Handler) IsReadyToRollback() bool { return false } + if target := targetVersionFromAction(actionData); target != "" { + if normalizeVersion(prev.PreviousVersion) != normalizeVersion(target) { + logging.LogDebug(moduleName, fmt.Sprintf( + "Is ready to rollback: false (semver/target %s != previous %s)", + target, prev.PreviousVersion, + )) + return false + } + } + osName := prev.PreviousOS arch := prev.PreviousArch if osName == "" { diff --git a/internal/version/handler_test.go b/internal/version/handler_test.go index 205ec84..84b87c8 100644 --- a/internal/version/handler_test.go +++ b/internal/version/handler_test.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/eclipse-iofog/edgelet/internal/models" ) @@ -156,8 +157,11 @@ func TestExecuteChangeVersionScript_LaunchesDetachedInstallSh(t *testing.T) { dir := t.TempDir() script := filepath.Join(dir, "install.sh") logFile := filepath.Join(dir, "invocation.log") + pendingFile := filepath.Join(dir, "ota-reprovision-pending") writeFile(t, script, "#!/bin/sh\nprintf '%s\\n' \"$@\" >> \""+logFile+"\"\n") _ = os.Chmod(script, 0o755) + SetOTAReprovisionPendingPath(pendingFile) + t.Cleanup(func() { SetOTAReprovisionPendingPath("") }) var mu sync.Mutex var launched []string @@ -172,9 +176,16 @@ func TestExecuteChangeVersionScript_LaunchesDetachedInstallSh(t *testing.T) { } writeKV(t, filepath.Join(dir, "receipt"), map[string]string{"installed_version": "1.0.0"}) + expiryMilli := time.Now().Add(20 * time.Minute).UnixMilli() err := h.executeChangeVersionScript( VersionCommandUpgrade, - map[string]any{"version": "v2.0.0", "provisionKey": "audit-key"}, + map[string]any{ + "version": "v2.0.0", + "semver": "2.0.0", + "provisionKey": "audit-key", + "expirationTime": expiryMilli, + "command": "UPGRADE", + }, "audit-key", ) if err != nil { @@ -189,6 +200,14 @@ func TestExecuteChangeVersionScript_LaunchesDetachedInstallSh(t *testing.T) { if !strings.Contains(launched[0], "--upgrade") || !strings.Contains(launched[0], "--version=v2.0.0") { t.Fatalf("unexpected launch args: %q", launched[0]) } + + pending, err := ReadOTAReprovisionPending() + if err != nil { + t.Fatalf("read pending: %v", err) + } + if pending == nil || pending.ProvisionKey != "audit-key" { + t.Fatalf("expected pending file written, got %+v", pending) + } } func TestChangeVersion_ContainerSkipsInstallScript(t *testing.T) { @@ -214,6 +233,105 @@ func TestNormalizeVersion(t *testing.T) { } } +func TestExecuteChangeVersionScript_NoPendingWithoutProvisionKey(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "install.sh") + pendingFile := filepath.Join(dir, "ota-reprovision-pending") + writeFile(t, script, "#!/bin/sh\n") + _ = os.Chmod(script, 0o755) + SetOTAReprovisionPendingPath(pendingFile) + t.Cleanup(func() { SetOTAReprovisionPendingPath("") }) + + h := NewHandler(NewReleaseManager(WithPaths(script, filepath.Join(dir, "receipt"), filepath.Join(dir, "prev"), filepath.Join(dir, "cache")))) + h.startDetached = func(string, ...string) error { return nil } + + err := h.executeChangeVersionScript( + VersionCommandUpgrade, + map[string]any{"command": "UPGRADE", "version": "2.0.0"}, + "", + ) + if err != nil { + t.Fatalf("executeChangeVersionScript failed: %v", err) + } + if pending, _ := ReadOTAReprovisionPending(); pending != nil { + t.Fatalf("expected no pending for manual-style upgrade, got %+v", pending) + } +} + +func TestExecuteChangeVersionScript_PreflightRefresh(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "install.sh") + pendingFile := filepath.Join(dir, "ota-reprovision-pending") + writeFile(t, script, "#!/bin/sh\n") + _ = os.Chmod(script, 0o755) + SetOTAReprovisionPendingPath(pendingFile) + t.Cleanup(func() { SetOTAReprovisionPendingPath("") }) + + var launched []string + h := NewHandler(NewReleaseManager(WithPaths(script, filepath.Join(dir, "receipt"), filepath.Join(dir, "prev"), filepath.Join(dir, "cache")))) + h.startDetached = func(_ string, args ...string) error { + launched = append(launched, strings.Join(args, ",")) + return nil + } + h.refreshVersion = func() (map[string]any, error) { + return map[string]any{ + "versionCommand": "upgrade", + "provisionKey": "fresh-key", + "expirationTime": time.Now().Add(15 * time.Minute).UnixMilli(), + "semver": "2.0.0", + }, nil + } + + nearExpiryMilli := time.Now().Add(2 * time.Minute).UnixMilli() + err := h.executeChangeVersionScript( + VersionCommandUpgrade, + map[string]any{ + "command": "UPGRADE", + "provisionKey": "stale-key", + "expirationTime": nearExpiryMilli, + "semver": "2.0.0", + }, + "stale-key", + ) + if err != nil { + t.Fatalf("executeChangeVersionScript failed: %v", err) + } + pending, err := ReadOTAReprovisionPending() + if err != nil { + t.Fatalf("read pending: %v", err) + } + if pending == nil || pending.ProvisionKey != "fresh-key" { + t.Fatalf("expected refreshed pending key, got %+v", pending) + } + if len(launched) != 1 || !strings.Contains(launched[0], "--version=v2.0.0") { + t.Fatalf("unexpected launch args: %#v", launched) + } +} + +func TestIsReadyToRollbackWithAction_SemverMustMatchPrevious(t *testing.T) { + dir := t.TempDir() + cacheDir := filepath.Join(dir, "cache") + previous := filepath.Join(dir, "previous-release") + _ = os.MkdirAll(cacheDir, 0o755) + writeKV(t, previous, map[string]string{ + "previous_version": "1.0.0", + "previous_os": "linux", + "previous_arch": "amd64", + }) + writeFile(t, filepath.Join(cacheDir, "edgelet-1.0.0-linux-amd64"), "binary") + + rm := NewReleaseManager(WithPaths(filepath.Join(dir, "install.sh"), filepath.Join(dir, "receipt"), previous, cacheDir)) + h := NewHandler(rm) + h.isContainer = func() bool { return false } + + if !h.IsReadyToRollbackWithAction(map[string]any{"semver": "1.0.0"}) { + t.Fatal("expected ready when semver matches previous version") + } + if h.IsReadyToRollbackWithAction(map[string]any{"semver": "9.9.9"}) { + t.Fatal("expected not ready when semver mismatches previous version") + } +} + func TestDefaultDaemonHealthy(t *testing.T) { // Smoke test: default hook reads supervisor status without panic. _ = defaultDaemonHealthy() diff --git a/internal/version/normalize_response.go b/internal/version/normalize_response.go new file mode 100644 index 0000000..88682a5 --- /dev/null +++ b/internal/version/normalize_response.go @@ -0,0 +1,80 @@ +package version + +import ( + "errors" + "fmt" + "strings" +) + +// NormalizeVersionResponse maps flat v3.8 or legacy nested controller version payloads +// to the internal action map consumed by ChangeVersion. +func NormalizeVersionResponse(raw map[string]any) (map[string]any, error) { + if raw == nil { + return nil, nil + } + + if nested, ok := raw["versionCommand"].(map[string]any); ok { + return normalizeNestedVersionResponse(nested) + } + + if cmdRaw, ok := raw["versionCommand"].(string); ok { + return normalizeFlatVersionResponse(cmdRaw, raw) + } + + return nil, nil +} + +func normalizeFlatVersionResponse(cmdRaw string, raw map[string]any) (map[string]any, error) { + cmd, err := parseVersionCommandString(cmdRaw) + if err != nil { + return nil, err + } + + action := map[string]any{ + "command": string(cmd), + } + copyVersionActionFields(action, raw) + return action, nil +} + +func normalizeNestedVersionResponse(nested map[string]any) (map[string]any, error) { + cmdRaw, ok := nested["command"].(string) + if !ok || strings.TrimSpace(cmdRaw) == "" { + return nil, errors.New("command not found in nested versionCommand") + } + + cmd, err := parseVersionCommandString(cmdRaw) + if err != nil { + return nil, err + } + + action := map[string]any{ + "command": string(cmd), + } + copyVersionActionFields(action, nested) + return action, nil +} + +func copyVersionActionFields(action, source map[string]any) { + if key, ok := source["provisionKey"].(string); ok && strings.TrimSpace(key) != "" { + action["provisionKey"] = key + } + if raw, ok := source["expirationTime"]; ok && raw != nil { + action["expirationTime"] = raw + } + for _, field := range []string{"semver", "version", "targetVersion", "target"} { + if value, ok := source[field].(string); ok && strings.TrimSpace(value) != "" { + action[field] = value + } + } +} + +func parseVersionCommandString(raw string) (VersionCommand, error) { + cmd := VersionCommand(strings.ToUpper(strings.TrimSpace(raw))) + switch cmd { + case VersionCommandUpgrade, VersionCommandRollback: + return cmd, nil + default: + return "", fmt.Errorf("unknown version command: %s", raw) + } +} diff --git a/internal/version/normalize_response_test.go b/internal/version/normalize_response_test.go new file mode 100644 index 0000000..11dd837 --- /dev/null +++ b/internal/version/normalize_response_test.go @@ -0,0 +1,184 @@ +package version + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestNormalizeVersionResponse_FlatV38(t *testing.T) { + raw := map[string]any{ + "versionCommand": "upgrade", + "provisionKey": "key-1", + "expirationTime": float64(1718380800000), + "semver": "1.0.0-beta.3", + } + + action, err := NormalizeVersionResponse(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action["command"] != "UPGRADE" { + t.Fatalf("command=%v", action["command"]) + } + if action["provisionKey"] != "key-1" { + t.Fatalf("provisionKey=%v", action["provisionKey"]) + } + if action["semver"] != "1.0.0-beta.3" { + t.Fatalf("semver=%v", action["semver"]) + } +} + +func TestNormalizeVersionResponse_LegacyNested(t *testing.T) { + raw := map[string]any{ + "versionCommand": map[string]any{ + "command": "ROLLBACK", + "version": "v1.2.3", + "provisionKey": "key-2", + "expirationTime": "1718380800000", + }, + } + + action, err := NormalizeVersionResponse(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action["command"] != "ROLLBACK" { + t.Fatalf("command=%v", action["command"]) + } + if action["version"] != "v1.2.3" { + t.Fatalf("version=%v", action["version"]) + } + if action["provisionKey"] != "key-2" { + t.Fatalf("provisionKey=%v", action["provisionKey"]) + } +} + +func TestNormalizeVersionResponse_AcceptsLowercaseCommand(t *testing.T) { + action, err := NormalizeVersionResponse(map[string]any{"versionCommand": "rollback"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action["command"] != "ROLLBACK" { + t.Fatalf("command=%v", action["command"]) + } +} + +func TestTargetVersionFromAction_SemverPrecedence(t *testing.T) { + action := map[string]any{ + "semver": "2.0.0", + "version": "1.0.0", + "target": "9.9.9", + } + if got := TargetVersionFromAction(action); got != "2.0.0" { + t.Fatalf("expected semver precedence, got %q", got) + } +} + +func TestVersionForInstallScript_AddsVPrefix(t *testing.T) { + if got := versionForInstallScript("1.0.0-beta.3"); got != "v1.0.0-beta.3" { + t.Fatalf("got %q", got) + } + if got := versionForInstallScript("v2.0.0"); got != "v2.0.0" { + t.Fatalf("got %q", got) + } +} + +func TestParseExpirationTimeMS(t *testing.T) { + cases := []struct { + name string + raw any + want int64 + }{ + {"float64", float64(1718380800000), 1718380800000}, + {"int64", int64(1718380800000), 1718380800000}, + {"string", "1718380800000", 1718380800000}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseExpirationTimeMS(tc.raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.UnixMilli() != tc.want { + t.Fatalf("got %d want %d", got.UnixMilli(), tc.want) + } + }) + } +} + +func TestOTAReprovisionPendingRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ota-reprovision-pending") + SetOTAReprovisionPendingPath(path) + t.Cleanup(func() { SetOTAReprovisionPendingPath("") }) + + expiry := time.UnixMilli(1718380800000) + if err := WriteOTAReprovisionPending("key-3", "upgrade", "1.0.0", expiry); err != nil { + t.Fatalf("write pending: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat pending: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("expected mode 0600, got %o", info.Mode().Perm()) + } + + got, err := ReadOTAReprovisionPending() + if err != nil { + t.Fatalf("read pending: %v", err) + } + if got.ProvisionKey != "key-3" || got.Command != "upgrade" || got.TargetVersion != "1.0.0" { + t.Fatalf("unexpected pending: %+v", got) + } + + if err := DeleteOTAReprovisionPending(); err != nil { + t.Fatalf("delete pending: %v", err) + } + if pending, err := ReadOTAReprovisionPending(); err != nil || pending != nil { + t.Fatalf("expected nil pending after delete, got %+v err=%v", pending, err) + } +} + +func TestPendingOTAReprovision_ExpirySkew(t *testing.T) { + expiry := time.UnixMilli(1718380800000) + pending := &PendingOTAReprovision{ExpirationTime: expiry} + + if pending.IsExpired(expiry.Add(29 * time.Second)) { + t.Fatal("expected valid within skew buffer") + } + if !pending.IsExpired(expiry.Add(31 * time.Second)) { + t.Fatal("expected expired after skew buffer") + } +} + +func TestPendingFromAction(t *testing.T) { + action := map[string]any{ + "command": "UPGRADE", + "provisionKey": "abc", + "expirationTime": json.Number("1718380800000"), + "semver": "1.0.0", + } + pending, err := PendingFromAction(action) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending.TargetVersion != "1.0.0" { + t.Fatalf("targetVersion=%q", pending.TargetVersion) + } +} + +func TestGetCandidateVersion_PrefersSemver(t *testing.T) { + rm := NewReleaseManager(WithRunningVersion(func() string { return "1.0.0" })) + got, err := rm.GetCandidateVersion(map[string]any{"semver": "v2.0.0", "version": "9.9.9"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "2.0.0" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/version/ota_reprovision.go b/internal/version/ota_reprovision.go new file mode 100644 index 0000000..7eb21ae --- /dev/null +++ b/internal/version/ota_reprovision.go @@ -0,0 +1,171 @@ +package version + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + defaultOTAReprovisionPending = "/var/backups/edgelet/ota-reprovision-pending" + otaExpirySkew = 30 * time.Second + otaPreflightRefreshBefore = 3 * time.Minute +) + +var otaReprovisionPendingPath = defaultOTAReprovisionPending + +// PendingOTAReprovision holds controller OTA reprovision state written before install.sh runs. +type PendingOTAReprovision struct { + ProvisionKey string `json:"provisionKey"` + ExpirationTime time.Time `json:"expirationTime"` + Command string `json:"command"` + TargetVersion string `json:"targetVersion"` +} + +// SetOTAReprovisionPendingPath overrides the pending file path (tests). +func SetOTAReprovisionPendingPath(path string) { + if strings.TrimSpace(path) == "" { + otaReprovisionPendingPath = defaultOTAReprovisionPending + return + } + otaReprovisionPendingPath = path +} + +// ParseExpirationTimeMS parses controller expirationTime as Unix epoch milliseconds. +func ParseExpirationTimeMS(raw any) (time.Time, error) { + switch v := raw.(type) { + case nil: + return time.Time{}, errors.New("expirationTime is required") + case float64: + return time.UnixMilli(int64(v)), nil + case int: + return time.UnixMilli(int64(v)), nil + case int64: + return time.UnixMilli(v), nil + case json.Number: + ms, err := v.Int64() + if err != nil { + return time.Time{}, fmt.Errorf("expirationTime: %w", err) + } + return time.UnixMilli(ms), nil + case string: + ms, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("expirationTime: %w", err) + } + return time.UnixMilli(ms), nil + default: + return time.Time{}, fmt.Errorf("unsupported expirationTime type %T", raw) + } +} + +// WriteOTAReprovisionPending persists pending reprovision state before detached install.sh. +func WriteOTAReprovisionPending(key, command, targetVersion string, expiry time.Time) error { + key = strings.TrimSpace(key) + if key == "" { + return errors.New("provisionKey is required") + } + if expiry.IsZero() { + return errors.New("expirationTime is required") + } + + pending := PendingOTAReprovision{ + ProvisionKey: key, + ExpirationTime: expiry.UTC(), + Command: strings.ToLower(strings.TrimSpace(command)), + TargetVersion: strings.TrimSpace(targetVersion), + } + data, err := json.Marshal(pending) + if err != nil { + return err + } + + dir := filepath.Dir(otaReprovisionPendingPath) + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + + tmp := otaReprovisionPendingPath + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, otaReprovisionPendingPath) +} + +// ReadOTAReprovisionPending reads pending reprovision state if present. +func ReadOTAReprovisionPending() (*PendingOTAReprovision, error) { + data, err := os.ReadFile(otaReprovisionPendingPath) // #nosec G304 -- fixed OTA pending path + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + if len(data) == 0 { + return nil, nil + } + + var pending PendingOTAReprovision + if err := json.Unmarshal(data, &pending); err != nil { + return nil, err + } + if strings.TrimSpace(pending.ProvisionKey) == "" { + return nil, errors.New("pending reprovision missing provisionKey") + } + return &pending, nil +} + +// DeleteOTAReprovisionPending removes pending reprovision state. +func DeleteOTAReprovisionPending() error { + err := os.Remove(otaReprovisionPendingPath) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// IsExpired reports whether the pending key is no longer valid (30s skew buffer). +func (p *PendingOTAReprovision) IsExpired(now time.Time) bool { + if p == nil || p.ExpirationTime.IsZero() { + return true + } + return now.After(p.ExpirationTime.Add(otaExpirySkew)) +} + +// NeedsPreflightRefresh reports whether the key is close enough to expiry to refresh before OTA. +func (p *PendingOTAReprovision) NeedsPreflightRefresh(now time.Time) bool { + if p == nil || p.ExpirationTime.IsZero() { + return false + } + return now.After(p.ExpirationTime.Add(-otaPreflightRefreshBefore)) +} + +// PendingFromAction builds pending state from a normalized version action map. +func PendingFromAction(actionData map[string]any) (*PendingOTAReprovision, error) { + if actionData == nil { + return nil, errors.New("action data is required") + } + key, ok := actionData["provisionKey"].(string) + if !ok || strings.TrimSpace(key) == "" { + return nil, errors.New("provisionKey is required") + } + expiry, err := ParseExpirationTimeMS(actionData["expirationTime"]) + if err != nil { + return nil, err + } + cmd, ok := actionData["command"].(string) + if !ok { + cmd = "" + } + return &PendingOTAReprovision{ + ProvisionKey: key, + ExpirationTime: expiry.UTC(), + Command: strings.ToLower(strings.TrimSpace(cmd)), + TargetVersion: TargetVersionFromAction(actionData), + }, nil +} diff --git a/internal/version/release.go b/internal/version/release.go index 915340d..f917634 100644 --- a/internal/version/release.go +++ b/internal/version/release.go @@ -292,10 +292,16 @@ func readKVFile(path string) (map[string]string, error) { return kv, nil } -func targetVersionFromAction(actionData map[string]any) string { +// TargetVersionFromAction resolves the controller target version with semver precedence. +func TargetVersionFromAction(actionData map[string]any) string { if actionData == nil { return "" } + if raw, ok := actionData["semver"].(string); ok { + if v := strings.TrimSpace(raw); v != "" { + return v + } + } for _, key := range []string{"version", "targetVersion", "target"} { if raw, ok := actionData[key].(string); ok { if v := strings.TrimSpace(raw); v != "" { @@ -306,6 +312,22 @@ func targetVersionFromAction(actionData map[string]any) string { return "" } +func targetVersionFromAction(actionData map[string]any) string { + return TargetVersionFromAction(actionData) +} + func normalizeVersion(version string) string { return strings.TrimPrefix(strings.TrimSpace(version), "v") } + +// versionForInstallScript formats a release tag for install.sh --version= (always v-prefixed). +func versionForInstallScript(version string) string { + version = strings.TrimSpace(version) + if version == "" || strings.EqualFold(version, "latest") { + return version + } + if strings.HasPrefix(version, "v") { + return version + } + return "v" + version +} diff --git a/packaging/PACKAGING-STRUCTURE.md b/packaging/PACKAGING-STRUCTURE.md index 66952de..23c5714 100644 --- a/packaging/PACKAGING-STRUCTURE.md +++ b/packaging/PACKAGING-STRUCTURE.md @@ -125,6 +125,6 @@ Operator matrix: `docs/edgelet/init-systems.md` (Plan 10). IT: `test/init/README | Target | Purpose | |--------|---------| | `scripts/ci` | Linux embed → build → test → size gate (≤55 MB thin) | -| `.github/workflows/ci-go.yml` | Unit tests + embed job | +| `.github/workflows/ci.yml` | Unit tests + embed job | | `test/install/*.sh` | Install / OTA script smoke (linux root) | | `test/embedded/run-all.sh` | Lima VM embedded engine IT | diff --git a/pkg/imageref/normalize.go b/pkg/imageref/normalize.go index 49ee2a9..23b322b 100644 --- a/pkg/imageref/normalize.go +++ b/pkg/imageref/normalize.go @@ -103,6 +103,48 @@ func Resolve(imageRef, registryURL string, fromCache bool) (string, []string) { return pullRef, lookup } +// Match reports whether two image refs refer to the same image when resolved +// against registryURL. fromCache mirrors launch/pull behavior for from_cache registries. +func Match(a, b, registryURL string, fromCache bool) bool { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + if a == "" || b == "" { + return false + } + if strings.EqualFold(a, b) { + return true + } + pullA, aliasesA := Resolve(a, registryURL, fromCache) + pullB, aliasesB := Resolve(b, registryURL, fromCache) + if pullA != "" && pullA == pullB { + return true + } + host := SanitizeRegistryHost(registryURL) + if !fromCache && host != "" && host != "docker.io" { + aliasesA = qualifiedRegistryAliases(aliasesA, host) + aliasesB = qualifiedRegistryAliases(aliasesB, host) + } + for _, la := range aliasesA { + for _, lb := range aliasesB { + if la == lb { + return true + } + } + } + return false +} + +func qualifiedRegistryAliases(aliases []string, host string) []string { + prefix := host + "/" + out := make([]string, 0, len(aliases)) + for _, alias := range aliases { + if HasRegistryHost(alias) && strings.HasPrefix(alias, prefix) { + out = append(out, alias) + } + } + return out +} + func hasTag(ref string) bool { // tag is defined on the last path component. last := ref diff --git a/pkg/imageref/normalize_test.go b/pkg/imageref/normalize_test.go index 5f4c747..52d22a0 100644 --- a/pkg/imageref/normalize_test.go +++ b/pkg/imageref/normalize_test.go @@ -52,6 +52,23 @@ func TestResolveKeepsQualifiedRef(t *testing.T) { } } +func TestMatchDockerHubShortAndQualified(t *testing.T) { + if !Match( + "emirhandurmus/controller:3.8.0-beta.1", + "docker.io/emirhandurmus/controller:3.8.0-beta.1", + "docker.io", + false, + ) { + t.Fatal("expected docker hub short and qualified refs to match") + } +} + +func TestMatchPrivateRegistryDoesNotMatchDockerHubAlias(t *testing.T) { + if Match("myapp:1.0", "docker.io/myapp:1.0", "registry.local:5000", false) { + t.Fatal("private registry hostless ref must not match docker.io alias") + } +} + func TestResolveDockerHubFullyQualifiedIncludesShortAlias(t *testing.T) { _, lookup := Resolve("docker.io/library/alpine:3.19", "from_cache", true) foundShort := false diff --git a/scripts/assemble-install.sh b/scripts/assemble-install.sh index 318d0f3..15567b4 100755 --- a/scripts/assemble-install.sh +++ b/scripts/assemble-install.sh @@ -1,62 +1,77 @@ #!/bin/sh -# assemble-install.sh — splice self-contained init/shutdown block into install.sh. +# assemble-install.sh — splice embedded blocks into install.sh. # # Authoring inputs (read-only): # scripts/lib/init-detect.sh, scripts/lib/init-edgelet.sh # packaging/init/** # scripts/edgelet-shutdown +# uninstall.sh # -# Output: install.sh (root) — replaces # ASSEMBLE:EMBEDDED_BEGIN … END region. +# Output: install.sh (root) — replaces ASSEMBLE:* regions. set -e ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) INSTALL_SH="${ROOT}/install.sh" -GEN="${ROOT}/scripts/install/gen-embedded-block.sh" -BEGIN_MARKER='# ASSEMBLE:EMBEDDED_BEGIN' -END_MARKER='# ASSEMBLE:EMBEDDED_END' +GEN_EMBEDDED="${ROOT}/scripts/install/gen-embedded-block.sh" +GEN_UNINSTALL="${ROOT}/scripts/install/gen-embedded-uninstall-block.sh" +GEN_INSTALL_SELF="${ROOT}/scripts/install/gen-embedded-install-self-block.sh" [ -f "$INSTALL_SH" ] || { echo "ERROR: missing ${INSTALL_SH}" >&2; exit 1; } -[ -f "$GEN" ] || { echo "ERROR: missing ${GEN}" >&2; exit 1; } -[ -x "$GEN" ] || chmod +x "$GEN" -grep -qF "$BEGIN_MARKER" "$INSTALL_SH" || { - echo "ERROR: ${INSTALL_SH} missing ${BEGIN_MARKER}" >&2 - exit 1 -} -grep -qF "$END_MARKER" "$INSTALL_SH" || { - echo "ERROR: ${INSTALL_SH} missing ${END_MARKER}" >&2 - exit 1 -} +for _gen in "$GEN_EMBEDDED" "$GEN_UNINSTALL" "$GEN_INSTALL_SELF"; do + [ -f "$_gen" ] || { echo "ERROR: missing ${_gen}" >&2; exit 1; } + [ -x "$_gen" ] || chmod +x "$_gen" +done -EMBED=$(mktemp) -trap 'rm -f "$EMBED"' EXIT +splice_region() { + _src="$1" + _begin="$2" + _end="$3" + _embed="$4" + _out="$5" -"$GEN" >"$EMBED" + awk -v beg="$_begin" -v end="$_end" -v ef="$_embed" ' + BEGIN { skip = 0 } + $0 == beg { + print + while ((getline line < ef) > 0) { + print line + } + close(ef) + skip = 1 + next + } + skip && $0 == end { + skip = 0 + print + next + } + skip { next } + { print } + ' "$_src" >"$_out" +} OUT=$(mktemp) -trap 'rm -f "$EMBED" "$OUT"' EXIT - -awk -v beg="$BEGIN_MARKER" -v end="$END_MARKER" -v ef="$EMBED" ' - BEGIN { skip = 0 } - $0 == beg { - print - while ((getline line < ef) > 0) { - print line - } - close(ef) - skip = 1 - next - } - skip && $0 == end { - skip = 0 - print - next - } - skip { next } - { print } -' "$INSTALL_SH" >"$OUT" +trap 'rm -f "$OUT"' EXIT +EMBED=$(mktemp) +"$GEN_EMBEDDED" >"$EMBED" +splice_region "$INSTALL_SH" '# ASSEMBLE:EMBEDDED_BEGIN' '# ASSEMBLE:EMBEDDED_END' "$EMBED" "$OUT" mv "$OUT" "$INSTALL_SH" +rm -f "$EMBED" + +EMBED=$(mktemp) +"$GEN_UNINSTALL" >"$EMBED" +splice_region "$INSTALL_SH" '# ASSEMBLE:UNINSTALL_BEGIN' '# ASSEMBLE:UNINSTALL_END' "$EMBED" "$OUT" +mv "$OUT" "$INSTALL_SH" +rm -f "$EMBED" + +EMBED=$(mktemp) +"$GEN_INSTALL_SELF" >"$EMBED" +splice_region "$INSTALL_SH" '# ASSEMBLE:INSTALL_SELF_BEGIN' '# ASSEMBLE:INSTALL_SELF_END' "$EMBED" "$OUT" +mv "$OUT" "$INSTALL_SH" +rm -f "$EMBED" + chmod +x "$INSTALL_SH" -echo ">>> Assembled embedded init block into ${INSTALL_SH}" +echo ">>> Assembled embedded blocks into ${INSTALL_SH}" diff --git a/scripts/install/gen-embedded-install-self-block.sh b/scripts/install/gen-embedded-install-self-block.sh new file mode 100755 index 0000000..be2094b --- /dev/null +++ b/scripts/install/gen-embedded-install-self-block.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# gen-embedded-install-self-block.sh — emit write_embedded_install for curl-pipe self-copy. +# Invoked by scripts/assemble-install.sh after init/uninstall regions are spliced. + +set -e + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd) +INSTALL_SH="${ROOT}/install.sh" +BEGIN_MARKER='# ASSEMBLE:INSTALL_SELF_BEGIN' +END_MARKER='# ASSEMBLE:INSTALL_SELF_END' + +[ -f "$INSTALL_SH" ] || { echo "ERROR: missing ${INSTALL_SH}" >&2; exit 1; } + +PAYLOAD=$(mktemp) +trap 'rm -f "$PAYLOAD"' EXIT + +awk -v beg="$BEGIN_MARKER" -v end="$END_MARKER" ' + $0 == beg { skip = 1; next } + skip && $0 == end { skip = 0; next } + skip { next } + { print } +' "$INSTALL_SH" >"$PAYLOAD" + +cat <<'HEADER' +# GENERATED by scripts/assemble-install.sh — DO NOT EDIT +# Authoring input: install.sh (excluding INSTALL_SELF region) + +HEADER + +printf '%s() {\n' write_embedded_install +printf ' _dest="$1"\n' +printf ' cat > "$_dest" << '"'"'EDGELET_INSTALL_SELF_EOF'"'"'\n' +cat "$PAYLOAD" +printf '%s\n' EDGELET_INSTALL_SELF_EOF +printf ' chmod 755 "$_dest"\n' +printf '}\n' diff --git a/scripts/install/gen-embedded-uninstall-block.sh b/scripts/install/gen-embedded-uninstall-block.sh new file mode 100755 index 0000000..c053c89 --- /dev/null +++ b/scripts/install/gen-embedded-uninstall-block.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# gen-embedded-uninstall-block.sh — emit embedded uninstall writer for install.sh monolith. +# Invoked by scripts/assemble-install.sh; not run at install time. + +set -e + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd) +SRC="${ROOT}/uninstall.sh" + +[ -f "$SRC" ] || { echo "missing embed source: ${SRC}" >&2; exit 1; } + +cat <<'HEADER' +# GENERATED by scripts/assemble-install.sh — DO NOT EDIT +# Authoring input: uninstall.sh + +HEADER + +printf '%s() {\n' write_embedded_uninstall +printf ' _dest="$1"\n' +printf ' cat > "$_dest" << '"'"'EDGELET_UNINSTALL_EOF'"'"'\n' +cat "$SRC" +printf '%s\n' EDGELET_UNINSTALL_EOF +printf ' chmod 755 "$_dest"\n' +printf '}\n' diff --git a/scripts/test-linux.sh b/scripts/test-linux.sh new file mode 100755 index 0000000..9074c26 --- /dev/null +++ b/scripts/test-linux.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Run make test-unit inside Linux Docker (macOS-friendly parity with ci.yml Test job). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +GO_IMAGE="${GO_IMAGE:-golang:1.26.4}" +GOARCH="${GOARCH:-$(go env GOARCH)}" +TEST_FLAGS="${TEST_FLAGS:-}" + +echo "=== test-linux (linux/${GOARCH}, same as CI make test-unit) ===" + +docker run --rm --platform "linux/${GOARCH}" \ + -e "TEST_FLAGS=${TEST_FLAGS}" \ + -v "${ROOT}:/src" -w /src "${GO_IMAGE}" bash -euxo pipefail -c " + go version + go mod download + export GO111MODULE=on + unset GOPATH + _TEST_PKGS='./cmd/... ./internal/... ./pkg/... ./test/...' + go test \${TEST_FLAGS} -v -short -tags '!linux' \${_TEST_PKGS} + go test \${TEST_FLAGS} -v -short -tags linux \${_TEST_PKGS} + CGO_ENABLED=1 go test \${TEST_FLAGS} -v -short -tags 'linux,cgo' \${_TEST_PKGS} + " + +echo "test-linux (linux/${GOARCH}): passed (test-unit parity)" diff --git a/test/control-plane/README.md b/test/control-plane/README.md index 0d68c1a..98da1a6 100644 --- a/test/control-plane/README.md +++ b/test/control-plane/README.md @@ -21,7 +21,7 @@ Operator guide: [docs/edgelet/control-plane.md](../../docs/edgelet/control-plane | embedded apply | `t12-embedded.sh` | `iofog-test` | embedded | | docker apply | `t12-docker.sh` | `edgelet-engine-lifecycle` | docker | | controller status API | both | — | `curl :51121/api/v3/status` | -| lifecycle guards | both | — | `ms rm` rejected; `controlplane delete` | +| lifecycle (unprovisioned) | both | — | `ms rm` allowed + reconcile; `controlplane delete` | | DNS resolution | `t12-embedded.sh` only | — | nslookup 3 FQDNs from probe MS | Deploy is **strict** (async apply): `cp_deploy` fails the suite on non-zero `edgelet deploy` exit. Async apply polls until terminal, then CLI checks `runtimeState=running`. Optional **`cp_wait_running`** sanity check after deploy. @@ -39,5 +39,5 @@ Regression (default when `--case=all`): `test/workload-continuity/run-all.sh`, ` ## Prerequisites - macOS host with Lima -- Network pull for `ghcr.io/datasance/controller:3.7.0` and router/nats images +- Network pull for `ghcr.io/datasance/controller:3.8.0-beta.0` and router/nats images - `go test` gates for packages are separate; this suite is end-to-end only diff --git a/test/control-plane/fixtures/controlplane-it.yaml b/test/control-plane/fixtures/controlplane-it.yaml index a253db3..e25ce08 100644 --- a/test/control-plane/fixtures/controlplane-it.yaml +++ b/test/control-plane/fixtures/controlplane-it.yaml @@ -3,6 +3,8 @@ # edgelet.controller.svc.bridge.local # controller.default.svc.bridge.local # default.pot.svc.bridge.local +# +# Lima dev profile: HTTP + embedded OIDC (no spec.tls). apiVersion: edgelet.iofog.org/v1 kind: ControlPlane metadata: @@ -10,17 +12,21 @@ metadata: namespace: default spec: controller: - image: ghcr.io/datasance/controller:3.7.0 + image: emirhandurmus/controller:3.8.0-beta.1 registry: 1 port: 51121 + # publicUrl: https://controller.example.com + publicUrl: http://localhost:51121 + # trustProxy: true + console: + port: 8008 + url: http://localhost auth: - url: https://auth-dt.datasance.com/ - realm: datasance - realmKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjQuSwvTMSk/WP5MYi3kyx7t0RQmdaQuM22XvLC8lYtE1qf/ZYfQt+hVsgyEbpNOo2N7/EjuNbPa3GmKKW9P0TvgSz4U6uOE47i1uUPUe16eMw0TipG+cQ0rh+FRx4OXp3xMUrrYgAoO0zg4gmg8JJ3Y4wA/1Zx9l9HbNw3iUUkf9Q5QTo5rwGKWOOZPo7I9XKMnvbxOrLIDCNjoU5mGmNdcA6LMB/qs5W3Pn1mCJZs6B9FisIjmr484rs20ozp9ZbQnRopS7dNEqc66HrvtQe3X/pChu4ZXumQ6mu2xzPKyIUewcEQcj73WCzs43510zrwD9/b8THc00Wjz3L2UnhQIDAQAB - ssl: external - controllerClient: pot-controller - controllerSecret: vLGWKJlXIf8GQTe2GfdubPxXedodtCU8 - viewerClient: ecn-viewer + mode: embedded + insecureAllowHttp: true + bootstrap: + username: admin + password: AdminPass123! events: auditEnabled: true retentionDays: 14 @@ -28,10 +34,10 @@ spec: captureIpAddress: true systemMicroservices: router: - amd64: ghcr.io/datasance/router:3.7.0 - arm64: ghcr.io/datasance/router:3.7.0 - riscv64: ghcr.io/datasance/router:3.7.0 - arm: ghcr.io/datasance/router:3.7.0 + amd64: ghcr.io/datasance/router:3.8.0-beta.0 + arm64: ghcr.io/datasance/router:3.8.0-beta.0 + riscv64: ghcr.io/datasance/router:3.8.0-beta.0 + arm: ghcr.io/datasance/router:3.8.0-beta.0 nats: amd64: ghcr.io/datasance/nats:2.12.4 arm64: ghcr.io/datasance/nats:2.12.4 diff --git a/test/control-plane/lib/cp.sh b/test/control-plane/lib/cp.sh index c0a7f4f..d89e069 100755 --- a/test/control-plane/lib/cp.sh +++ b/test/control-plane/lib/cp.sh @@ -157,7 +157,7 @@ cp_assert_deployed() { out=\$(edgelet ms ls --source controlplane) echo \"\${out}\" | grep -q '${CP_NS}' echo \"\${out}\" | grep -q '${CP_NAME}' - echo \"\${out}\" | grep -qi 'controlplane' + echo \"\${out}\" | grep -qi 'controller' echo \"\${out}\" | grep -qi 'running' " } @@ -173,7 +173,9 @@ cp_assert_status_api() { " } -# cp_assert_lifecycle VM — CP lifecycle guards (leaves CP deleted) +# cp_assert_lifecycle VM — CP lifecycle when unprovisioned (leaves CP deleted). +# ms rm is allowed; reconciler recreates the container while the DB row exists. +# Provisioned guard tests (ms rm + controlplane delete blocked) require provision IT. # Poll limits align with cp_delete_if_present (docker engine delete can be slow under load). cp_assert_lifecycle() { local _vm="$1" @@ -181,14 +183,27 @@ cp_assert_lifecycle() { _uuid="$(cp_controlplane_uuid "${_vm}")" [[ -n "${_uuid}" ]] || die "no controlplane uuid on ${_vm}" - assert_ok "ms rm on controlplane uuid is rejected" \ + assert_ok "ms rm on controlplane uuid succeeds when unprovisioned" \ cp_remote "${_vm}" " set +e out=\$(edgelet ms rm '${_uuid}' 2>&1) code=\$? set -e - test \"\${code}\" -ne 0 - echo \"\${out}\" | grep -Eiq 'controlplane|control plane' + test \"\${code}\" -eq 0 + " + + assert_ok "controlplane still running after ms rm (reconcile)" \ + cp_remote "${_vm}" " + set -e + for i in \$(seq 1 30); do + out=\$(edgelet controlplane get 2>/dev/null || true) + if echo \"\${out}\" | grep -qi 'runtimeState: running'; then + exit 0 + fi + sleep 2 + done + edgelet controlplane get 2>&1 || true + exit 1 " assert_ok "controlplane delete succeeds" \ diff --git a/test/control-plane/run-all.sh b/test/control-plane/run-all.sh index cdf7cf4..0eb1027 100755 --- a/test/control-plane/run-all.sh +++ b/test/control-plane/run-all.sh @@ -44,7 +44,7 @@ Runs ControlPlane IT: embedded CP apply embedded (iofog-test) docker CP apply docker (edgelet-engine-lifecycle) controller status API /api/v3/status (in t12-embedded.sh / t12-docker.sh) - CP lifecycle guards ms rm blocked; controlplane delete + CP lifecycle (unprovisioned) ms rm allowed + reconcile; controlplane delete CP DNS resolution DNS 3 FQDNs from fixture metadata (embedded only) Options: diff --git a/test/control-plane/t12-docker.sh b/test/control-plane/t12-docker.sh index 955ceb5..7a0c964 100755 --- a/test/control-plane/t12-docker.sh +++ b/test/control-plane/t12-docker.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# docker CP apply + controller status API + CP lifecycle guards on docker engine (edgelet-engine-lifecycle Lima VM). +# docker CP apply + controller status API + CP lifecycle (unprovisioned) on docker engine (edgelet-engine-lifecycle Lima VM). # # Usage: ./test/control-plane/t12-docker.sh [--vm-name=edgelet-engine-lifecycle] [--skip-setup] @@ -32,7 +32,7 @@ cp_assert_deployed "${VM_NAME}" log_step "controller status API controller /api/v3/status" cp_assert_status_api "${VM_NAME}" -log_step "CP lifecycle guards ms lifecycle block + controlplane delete" +log_step "CP lifecycle (unprovisioned) ms rm + reconcile + controlplane delete" cp_deploy "${VM_NAME}" "${FIXTURE}" cp_wait_running "${VM_NAME}" cp_assert_lifecycle "${VM_NAME}" diff --git a/test/control-plane/t12-embedded.sh b/test/control-plane/t12-embedded.sh index b72c8a6..bb028c0 100755 --- a/test/control-plane/t12-embedded.sh +++ b/test/control-plane/t12-embedded.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# embedded CP apply + controller status API + CP lifecycle guards + CP DNS resolution on embedded engine (iofog-test Lima VM). +# embedded CP apply + controller status API + CP lifecycle (unprovisioned) + CP DNS resolution on embedded engine (iofog-test Lima VM). # # Usage: ./test/control-plane/t12-embedded.sh [--vm-name=iofog-test] [--skip-setup] @@ -36,7 +36,7 @@ cp_assert_status_api "${VM_NAME}" log_step "CP DNS resolution DNS (fixture namespace/name FQDNs)" cp_assert_dns "${VM_NAME}" -log_step "CP lifecycle guards ms lifecycle block + controlplane delete" +log_step "CP lifecycle (unprovisioned) ms rm + reconcile + controlplane delete" cp_deploy "${VM_NAME}" "${FIXTURE}" cp_wait_running "${VM_NAME}" cp_assert_lifecycle "${VM_NAME}" diff --git a/test/deployment-yamls/controlplane.yaml b/test/deployment-yamls/controlplane.yaml index bea7f3a..85006b0 100644 --- a/test/deployment-yamls/controlplane.yaml +++ b/test/deployment-yamls/controlplane.yaml @@ -5,17 +5,28 @@ metadata: namespace: bar spec: controller: - image: ghcr.io/datasance/controller:3.7.0 + image: emirhandurmus/controller:3.8.0-beta.1 registry: 1 port: 51121 + # publicUrl: https://controller.example.com + publicUrl: http://localhost:51121 + # trustProxy: true + console: + port: 8008 + url: http://localhost auth: - url: - realm: - realmKey: - ssl: external - controllerClient: - controllerSecret: - viewerClient: + mode: embedded + insecureAllowHttp: true + insecureAllowBootstrapLog: false + bootstrap: + username: admin + password: AdminPass123!! + # auth external example: + # mode: external + # issuerUrl: https://auth.example.com/realms/myrealm + # client: + # id: pot-controller + # secret: # database: # an external database or have multiple controllers for the ControlPlane, you can set the database here # provider: postgres/mysql # user: @@ -32,44 +43,29 @@ spec: captureIpAddress: true systemMicroservices: router: - amd64: ghcr.io/datasance/router:3.7.0 - arm64: ghcr.io/datasance/router:3.7.0 - riscv64: ghcr.io/datasance/router:3.7.0 - arm: ghcr.io/datasance/router:3.7.0 - nats: # when nats.enabled is true - amd64: ghcr.io/datasance/nats:2.12.4 - arm64: ghcr.io/datasance/nats:2.12.4 - riscv64: ghcr.io/datasance/nats:2.12.4 - arm: ghcr.io/datasance/nats:2.12.4 + # amd64: ghcr.io/datasance/router:3.8.0-beta.0 + arm64: emirhandurmus/router:3.8.0-beta.4 + # riscv64: ghcr.io/datasance/router:3.8.0-beta.0 + # arm: ghcr.io/datasance/router:3.8.0-beta.0 + # nats: # when nats.enabled is true + # amd64: ghcr.io/datasance/nats:2.12.4 + # arm64: ghcr.io/datasance/nats:2.12.4 + # riscv64: ghcr.io/datasance/nats:2.12.4 + # arm: ghcr.io/datasance/nats:2.12.4 nats: # optional; when present and enabled, NATS is deployed with JetStream enabled: true # set to false to disable NATS - # ecnViewerPort: - # ecnViewerUrl: # set the URL for the ECN Viewer UI for Controller REST API endpoint - # logLevel: - # https: # optional; when set, controller will be deployed with HTTPS. User can provider either path or base64. cannot provide both. - # path: # path to the certificate and key files the path must be valid and include ca.crt(optional), tls.crt(must) and tls.key(must) files. path must be absolute path. - # base64: # base64 encoded string of the certificate and key files - # ca: base64 encoded string(optional) - # cert: base64 encoded string(must) - # key: base64 encoded string(must) + logLevel: debug + # tls: # optional; when set, controller listeners use HTTPS. Provide either path or base64, not both. + # path: /etc/edgelet/controller-tls # absolute host dir with tls.crt + tls.key (+ optional ca.crt) + # base64: + # ca: base64 encoded string (optional) + # cert: base64 encoded string (required when base64 block is used) + # key: base64 encoded string (required when base64 block is used) # vault: # optional; when set, operator creates a Secret from provider config and injects VAULT_* env vars into the controller # enabled: true # provider: openbao # hashicorp, openbao, vault, aws, aws-secrets-manager, azure, azure-key-vault, google, google-secret-manager # basePath: "pot/local-test/secrets" # $namespace is replaced with the ControlPlane namespace - # # Provide only the block for your selected provider. Operator creates Secret "controller-vault-credentials" from these values. # hashicorp: # address: "http://192.168.139.3:8200" - # token: "" # Vault token (sensitive; consider External Secrets to populate the CR) + # token: "" # mount: "kv" - # aws: - # region: "us-east-1" - # accessKeyId: "" - # accessKey: "" - # azure: - # url: "https://your-vault.vault.azure.net" - # tenantId: "" - # clientId: "" - # clientSecret: "" - # google: - # projectId: "" - # credentials: "" # path to service account key or JSON content \ No newline at end of file diff --git a/test/install/install-curl-pipe.sh b/test/install/install-curl-pipe.sh index 5ac2870..a285b6a 100755 --- a/test/install/install-curl-pipe.sh +++ b/test/install/install-curl-pipe.sh @@ -115,4 +115,14 @@ fi exit 1 } +[[ -f /usr/share/edgelet/install.sh ]] || { + echo "ERROR: /usr/share/edgelet/install.sh missing (OTA path)" >&2 + exit 1 +} + +[[ -f /usr/share/edgelet/uninstall.sh ]] || { + echo "ERROR: /usr/share/edgelet/uninstall.sh missing" >&2 + exit 1 +} + echo ">>> PASS: curl pipe install" diff --git a/test/install/install-release-layout.sh b/test/install/install-release-layout.sh index 5795a33..f1c2fe1 100755 --- a/test/install/install-release-layout.sh +++ b/test/install/install-release-layout.sh @@ -121,4 +121,9 @@ fi exit 1 } +[[ -f /usr/share/edgelet/uninstall.sh ]] || { + echo "ERROR: /usr/share/edgelet/uninstall.sh missing" >&2 + exit 1 +} + echo ">>> PASS: release-layout install (install.sh + binary only)" diff --git a/uninstall.sh b/uninstall.sh index 6f711f5..6d21995 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -4,14 +4,47 @@ # Usage: # sudo sh uninstall.sh [--remove-data] # -# --remove-data also removes /var/lib/edgelet, /var/lib/edgelet-containerd, -# /run/edgelet, /var/log/edgelet, /etc/edgelet, /var/backups/edgelet +# --remove-data also removes config, data, runtime, logs, and backup directories set -e die() { echo "ERROR: $1" >&2; exit 1; } info() { echo ">>> $1"; } +detect_os() { + _u=$(uname -s) + case "${_u}" in + Linux) echo "linux" ;; + Darwin) echo "darwin" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) echo "windows" ;; + *) die "Unsupported OS: ${_u}" ;; + esac +} + +windows_program_data_edgelet() { + echo "${ProgramData:-/c/ProgramData}/Edgelet" +} + +share_dir_for_os() { + case "$1" in + linux) echo "/usr/share/edgelet" ;; + darwin) echo "/usr/local/share/edgelet" ;; + windows) echo "$(windows_program_data_edgelet)/scripts" ;; + *) die "Unsupported OS: $1" ;; + esac +} + +binary_path_for_os() { + case "$1" in + linux|darwin) echo "/usr/local/bin/edgelet" ;; + windows) + _pf="${ProgramFiles:-/c/Program Files}" + echo "${_pf}/Edgelet/edgelet.exe" + ;; + *) die "Unsupported OS: $1" ;; + esac +} + # OpenRC ships /sbin/openrc-run on Alpine even when PID 1 is busybox (Lima # template:alpine). Require a running OpenRC supervisor, not merely openrc-run. openrc_is_pid1() { @@ -72,6 +105,10 @@ detect_init() { [ "$(id -u)" -eq 0 ] || die "Must be run as root. Try: sudo $0 $*" +OS=$(detect_os) +SHARE_DIR=$(share_dir_for_os "$OS") +BINARY_PATH=$(binary_path_for_os "$OS") + REMOVE_DATA=false for arg in "$@"; do case "${arg}" in @@ -155,7 +192,7 @@ lazy_umount_edgelet() { if ! command -v umount >/dev/null 2>&1; then return 0 fi - mount 2>/dev/null | grep -E '/run/edgelet|/var/lib/edgelet' | awk '{print $3}' | \ + mount 2>/dev/null | grep -E '/run/edgelet|/var/run/edgelet|/var/lib/edgelet' | awk '{print $3}' | \ sort -r | while read -r mp; do [ -n "$mp" ] || continue umount -l "${mp}" 2>/dev/null || true @@ -197,33 +234,75 @@ remove_init_service() { remove_init_service lazy_umount_edgelet -rm -f /usr/local/bin/edgelet +rm -f "$BINARY_PATH" info "Binary removed." -rm -rf /usr/libexec/edgelet -info "Init helpers removed from /usr/libexec/edgelet/" +case "$OS" in + linux) + rm -rf /usr/libexec/edgelet + info "Init helpers removed from /usr/libexec/edgelet/" + ;; +esac -rm -rf /usr/share/edgelet -info "Bundled scripts removed from /usr/share/edgelet/" +rm -rf "$SHARE_DIR" +info "Bundled scripts removed from ${SHARE_DIR}/" if [ "${REMOVE_DATA}" = "true" ]; then info "Removing agent data directories..." lazy_umount_edgelet - rm -rf /var/lib/edgelet - rm -rf /var/lib/edgelet-containerd - rm -rf /run/edgelet - rm -rf /var/log/edgelet - rm -rf /var/backups/edgelet - rm -rf /etc/edgelet + case "$OS" in + linux) + rm -rf /var/lib/edgelet + rm -rf /var/lib/edgelet-containerd + rm -rf /run/edgelet + rm -rf /var/log/edgelet + rm -rf /var/backups/edgelet + rm -rf /etc/edgelet + ;; + darwin) + rm -rf /var/lib/edgelet + rm -rf /var/run/edgelet + rm -rf /var/log/edgelet + rm -rf /var/backups/edgelet + rm -rf /etc/edgelet + ;; + windows) + _pd=$(windows_program_data_edgelet) + rm -rf "${_pd}/data" + rm -rf "${_pd}/config" + rm -rf "${_pd}/run" + rm -rf "${_pd}/log" + rm -rf "${_pd}/scripts" + rm -rf "${_pd}" + ;; + esac info "Data, backups, and configuration removed." else info "Data directories preserved (use --remove-data to remove):" - info " /var/lib/edgelet" - info " /var/lib/edgelet-containerd" - info " /run/edgelet" - info " /var/log/edgelet" - info " /var/backups/edgelet" - info " /etc/edgelet" + case "$OS" in + linux) + info " /var/lib/edgelet" + info " /var/lib/edgelet-containerd" + info " /run/edgelet" + info " /var/log/edgelet" + info " /var/backups/edgelet" + info " /etc/edgelet" + ;; + darwin) + info " /var/lib/edgelet" + info " /var/run/edgelet" + info " /var/log/edgelet" + info " /var/backups/edgelet" + info " /etc/edgelet" + ;; + windows) + _pd=$(windows_program_data_edgelet) + info " ${_pd}/data" + info " ${_pd}/config" + info " ${_pd}/run" + info " ${_pd}/log" + ;; + esac fi info ""